Node.js mit TSOA und Authentifizierung - (Teil 7) Die Unit-Test Vorbereitung

In den bisherigen Artikeln haben wir die mit TSOA definierten Endpunkte gesichert. Nur wissen wir ja: Was nicht getestet ist, funktioniert nicht. Wir schreiben uns einen Vitest-Helfer um diesen Mangel in schlanken Unit-Tests abzustellen.

3 Minuten

In diesem Artikel widme ich mich der Vorbereitung auf den Unit-Test, den ich idealerweise direkt am Anfang definiert hätte (Mindset: Test-driven Development!). Das heutige Ziel leitet sich daraus ab: die Einrichtung eines Vitest-CustomMatchers, um das bereits gut ausgestattete Vitest-Framework um eigene Prüfungen zu ergänzen. Das vermeidet Duplikate.

Im Ergebnis soll der Unit-Test so schlank aussehen, wie im kommenden Codebeispiel:

it('should validate secure route is accessible by ADMIN', async () => {  
  // act  
  await agent.get('/entites')

  // assert  
  expect(mockExpressAuthentication).toHaveBeenCalledWithRequiredScopes(['ADMIN'])
})

Bevor wir anfangen, empfiehlt es sich, die Vorgänger der Artikelserie zu lesen:

Notwendige Installation und Einrichtung

Bevor wir loslegen, benötigen wir ein paar NPM-Pakete als DevDependency:

  • supertest für den Aufruf unserer Endpunkte im Unit-Test
  • vitest als Testframework

npm install -D vitest supertest @types/supertest

Für Vitest benötigen wir eine passende Konfiguration. Dabei stellen wir die Imports aus absoluten Pfaden sicher.

import path from 'path'  

export default {  
  test: {  
    globals: true,  
    environment: 'node',  
    clearMocks: true,  
    setupFiles: [
      'tests/helper/customMatchers/toHaveBeenCalledWithRequiredScopes.ts',  
    ],  
  },  
  resolve: {  
    alias: {  
      '@': path.resolve(__dirname, 'src'),  
    },  
  },  
}

Im TsConfig benötigen wir in den CompilerOptions noch einen passenden Eintrag:

{
  "compilerOptions": { 
    // ... 
    "types": [  
      "vitest/globals"  
    ],
  } 
}

Der Vitest-Helfer

Wir erstellen im Root-Verzeichnis im Ordner tests/helper/customMatchers die Typdefinition für unsere Testhelfer mit dem Dateinamen vitest.d.ts. Dadurch machen wir unseren Helfer in TypeScript bekannt und erhalten passende Autocomplete-Einträge in unserer IDE.

export {
} // Eslint doesn't complain, and everything works as expected, nice!  

interface CustomMatchers<R = unknown> {  
  toHaveBeenCalledWithRequiredScopes(scopes: string[]): R  
}  

declare module 'vitest' {  
  interface Assertion<T = any> extends CustomMatchers<T> {
  }
  interface AsymmetricMatchersContaining extends CustomMatchers {
  }
}

Jetzt benötigen wir noch die passende Funktion in der Datei toHaveBeenCalledWithRequiredScopes.ts im selben Ordner.

// eslint-disable-next-line @typescript-eslint/no-explicit-any  
export function toHaveBeenCalledWithRequiredScopes (received: any, scopes: string[]) {  
  if (!received.mock) {  
    throw new Error('Expected a mock function')  
  }  

  if (!received.mock.calls.length) {  
    return {  
      message: () => `expected authenticationMock to have been called at all`,  
      pass: false,  
    }  
  }  

  if (!Array.isArray(scopes)) {  
    return {  
      message: () => `expected scopes input to be string array: ${scopes}`,  
      pass: false,  
    }  
  }  

  if (scopes.length === 0) {  
    return {  
      message: () => `expected scopes input to have items, but is an empty array`,  
      pass: false,  
    }  
  }  

  if (received.mock.calls.length !== 1) {  
    return {  
      message: () =>  
        `expected authenticationMock to have been called only once, but has been called ${received.mock.calls.length} times`,  
      pass: false,  
    }  
  }  

  const call = received.mock.calls[0]  

  if (!call[2]) {  
    return {  
      message: () => `expected authenticationMock to have been called with some scopes`,  
      pass: false,  
    }  
  }  

  if (call[2].length !== scopes.length) {  
    return {  
      message: () =>  
        `expected authenticationMock to have been called with same amount of scopes ${scopes}, but it was called with ${call[2]}`,  
      pass: false,  
    }  
  }  

  const pass = call[2].every((scope: string) => scopes.includes(scope))  

  if (pass) {  
    return {  
      message: () =>  
        `expected authenticationMock not to have been called with scopes ${scopes}, but it was called with ${call[2]}`,  
      pass: true,  
    }  
  }  

  return {  
    message: () =>  
      `expected authenticationMock not to have been called with roles "${JSON.stringify(scopes)}", but it was called with ${JSON.stringify(call[2])}`,  

    pass: false,  
  }  
}  

expect.extend({toHaveBeenCalledWithRequiredScopes})

Hierbei kannst du, wie in meiner Vorlage, viele Prüfungen durchführen, sodass bei der Nutzung eine dedizierte Rückmeldung angezeigt wird. Das erleichtert mir die Fehleranalyse bei den Testaufrufen, weshalb ich diese umfangreiche Schreibweise bevorzuge.

Fazit

Mit diesen Schritten haben wir den Vitest-Helfer vorbereitet und eingebunden. Im nächsten Artikel schreiben wir unseren ersten Test in der neuen Express-Applikation.

call to action background image

Abonniere meinen Newsletter

Erhalte einmal im Monat Nachrichten aus den Bereichen Softwareentwicklung und Kommunikation gespikt mit Buch- und Linkempfehlungen.