Auf dem Prüfstand: Wie JavaScript-Tests wieder lesbar werden

Seite 3: 6. Parametrisieren statt Kopieren

Inhaltsverzeichnis

Ein besonders einfach anzuwendendes Pattern ist das Parametrisieren von Tests anstelle des Kopierens. Jest bietet für parametrisierte Tests zwei verschiedene APIs: eine Array-Notation und eine Template-Literal-Notation. Template-Literale bieten sich vor allem bei mehreren Parametern an, da sie durch die angegebenen Namen für Parameter übersichtlicher sind.

Folgendes Listing zeigt beide Notationen anhand eines Tests, der Validierungs-Grenzfälle überprüft:

it.each([
  ['http://test.com#example', 'No anchors allowed.'],
  ['test.de', 'Protocol missing.'],
  ['', 'URI is required.'],
])('should show correct validation message for invalid URI %s', (uri, validationMessage) => {
	[...]
})

it.each`
uri                          | validationMessage  
${'http://test.com#example'} | ${'No anchors allowed.'}
${'test.de'}                 | ${'Protocol missing.'}
{''}                         | ${'URI is required.'}
`('should show correct validation message for invalid URI $uri',
({ uri, validationMessage }) => {
	[...]
})

Parametrisierung ist nicht nur bei Grenzfällen sinnvoll. Sie kann auch helfen, gegensätzliche Fälle zu testen, etwa wann ein Text angezeigt oder ausgeblendet werden soll. Damit lässt sich sicherstellen, dass beide Tests valide Daten verwenden. Bei getrennten Tests können Änderungen am Datenmodell dazu führen, dass Tests, die nur prüfen, ob etwas nicht vorhanden ist, nicht mehr korrekt funktionieren. Sie schlagen möglicherweise nie mehr fehl, da das Geprüfte aufgrund falscher Daten nie vorhanden sein kann und somit eine Überprüfung der eigentlichen Logik gar nicht mehr stattfindet.

Matcher werden in Jest in Kombination mit dem expect-Statement verwendet, um zu validieren, dass ein Wert bestimmten Erwartungen entspricht. Jest bietet von sich aus bereits viele Matcher, die jedoch sehr allgemein und nicht auf das Testen von Frontends ausgelegt sind. Als zusätzliche Bibliotheken, die Matcher anbieten und Abhilfe schaffen können, kommen jest-extended oder jest-dom infrage. Mit den dort angebotenen Matchern lässt sich beispielsweise sehr einfach verifizieren, ob ein Eingabeelement sichtbar, deaktiviert oder ungültig ist. Zusätzlich erlaubt Jest, eigene Matcher durch das Erweitern des expect-Statements zu definieren.

Um zu überprüfen, ob eine Vue-Komponente im Code existiert, können verschiedene Jest-Matcher und Schreibweisen verwendet werden:

expect(wrapper.find(<...>).exists()).toBeFalsy()
expect(wrapper.find(<...>).exists()).toBe(false)
expect(wrapper.find(<...>).element).toBeDefined()

Spezifische Matcher helfen, solche Statements lesbar und einheitlich zu gestalten:

expect(wrapper.find(<...>)).toExist()

Das folgende Listing zeigt die Implementierung des eigenen Matchers toExist durch den Aufruf der extend-Methode:

expect.extend({
  toExist(received) {
    if (received.exists()) {
      return {
        pass: true,
        message: () => `Element ${received.selector} exists`,
      }
    } else {
      return {
        pass: false,
        message: () => `Element ${received.selector} does not exist.`,
      }
    }
  },
})

Spezifische Matcher eignen sich vor allem dann, wenn Statements durch ihre Länge unübersichtlich sind, wenn sie nicht mehr intuitiv verständlich sind oder wenn für ähnliche Situationen unterschiedliche generische Matcher infrage kommen. Als Vorteil ergeben sich selbsterklärende Matcher-Namen und eine geteilte sowie wiedererkennbare Logik. Dazu erhöhen sie die Geschwindigkeit beim Schreiben der Tests.

Die meisten JavaScript-Projekte verwenden unzählige Bibliotheken, um schneller und einfacher Produktionscode zu schreiben. Oft jedoch sind wenige Bibliotheken im Einsatz, die das Schreiben von Tests vereinfachen. Zusätzlich zu den bereits erwähnten Bibliotheken für spezifische Matcher soll hier ein kleines Beispiel für den Einsatz von jest-when und flush-promises folgen. Diese beiden Bibliotheken können beispielsweise das Testen von Axios-Aufrufen vereinfachen. Mit jest-when lassen sich generische Axios-Mocks schreiben, die ihre Argumente bereits verifizieren, sodass im Test keine zusätzlichen Überprüfungen notwendig sind. flush-promises sorgt dafür, dass die Ergebnisse aller gelösten Promises nach dem Aufruf der Funktion direkt zur Verfügung stehen.

Zur Verdeutlichung folgt hier ein Beispiel für einen generischen axios-Mock. Die Methode calledWith erhält hier die Information, welche Argumente sie bei einem Aufruf erwarten soll:

export function mockCreateProject(productId, productName) {
  when(axios.post)
  .calledWith(`/products/${productId}`, {
    name: productName,
  })
  .mockResolvedValue({
    data: { id: productId, productName: productName },
  })
}

Ein Test könnte dann so aussehen:

it('[...]', async () => {
	[...]
	stubCreateProject(PRODUCT.id, PRODUCT.name)
	[...] // Aktion die den Call auslöst: z.B. Mount oder Klick
	await flushPromises()
	[...]
}

Im globalen beforeEach- und afterEach-Block lassen sich die jest-when-Mocks global zurücksetzen und abgleichen, damit das in einzelnen Tests nicht vergessen wird:

global.beforeEach(() => {
  resetAllWhenMocks()
})
global.afterEach(() => {
  verifyAllWhenMocksCalled()
})

Entwicklerinnen und Entwickler sollten jedoch vor dem Einsatz zusätzlicher Bibliotheken stets den Nachteil weiterer Abhängigkeiten im Verhältnis zum Nutzen der zusätzlichen Bibliothek abwägen.

Folgt man den Empfehlungen der Testing Library, sollten Elemente in Tests über ihre ARIA-Attribute für Rollen (ARIA = Accessible Rich Internet Applications) und ihren Text identifiziert werden. Das funktioniert jedoch nicht, wenn gar keine ARIA-Attribute verwendet werden, weil Accessibility (Barrierefreiheit) keine explizite Anforderung ist oder in älteren, bereits bestehenden Projekten nicht beachtet wurde. Andere Test-Frameworks bieten diese Art der Identifizierung nicht an und verwenden CSS-Selektoren. An einigen Stellen lassen sich Selektoren vermeiden, indem man aus der Benutzerperspektive testet: beispielsweise, ob der richtige Text angezeigt wird. An anderen Stellen wie beim Anklicken von Knöpfen ist dies aber nur schwer möglich.

CSS-Selektoren zum Identifizieren von Elementen in Tests sollten folgende Eigenschaften besitzen: Sie sollten eindeutig sein, möglichst prägnant und sich bei einem Refactoring nicht ändern. Hier können für Test-IDs die HTML-Data-Attribute helfen. Bei einem Refactoring bleibt das Data-Attribut normalerweise bestehen, auch wenn sich beispielsweise HTML-Elemente und CSS-Klassen ändern.

Dafür gibt es ein Beispiel:

<button class=”button button--submit” data-label=”purchase-submit-button”>Submit</button>

Ohne das Verwenden des data-Attributs sähe der Code wie folgt aus:

expect(wrapper.find(‘.button.button--submit’)).trigger(‘click’)

Unter Verwendung des data-Attributs ergibt sich hingegen folgende Syntax:

expect(wrapper.find([data-label=”purchase-submit-button”])).trigger(‘click’)

Problematisch bleibt, dass Selektoren dadurch nicht zwingend prägnant und kurz sind. Zusätzlich werden sie häufig dupliziert, und zwar nicht nur für verschiedene Tests, sondern auch für unterschiedliche Testarten. JavaScript-Dateien, in denen die Selektoren genau einmal definiert sind, können da Abhilfe schaffen. Selektoren von Kind-Komponenten können mit dem Spread-Operator in Dateien für Eltern-Komponenten ergänzt werden und lassen sich beim Import dekonstruieren.

Folgendes Listing zeigt die Selektoren-Datei einer Eltern-Komponente productPage.js:

const productDeletionDialog = require('./productDeletionDialog.js')

module.exports = {
  selector: ‘[data-label=product-page]’,
  productTitle: ‘[data-label=product-title]’,
  productDeletionDialog: {
    ...productDeletionDialog,
  }
}

Hier folgt die Selektoren-Datei einer Kind-Komponente productDeletionDialog.js:

module.exports = {
  selector: ‘[data-label=product-deletion-dialog]’,
  confirmButton: '[data-label=confirm-button]',
}

Und im Test könnte der Code dann folgendermaßen aussehen:

import { productTitle, productDeletionDialog } from ‘<...>/productPage.js’

it(‘<...>’, () => {
  wrapper.find(productDeletionDialog.confirmButton).trigger(‘click’)
}‘)

Die gleichen Selektoren-Dateien können auch als Basis für End-to-End-Tests dienen. Mit Hilfe des selector für jede Komponente lassen sich beispielsweise recht einfach neue Dateien in der vom End-to-End-Test-Framework nightwatch.js geforderten Struktur generieren.

Je nachdem, wo Tests abgelegt werden, kann es in der Entwicklungsumgebung schnell unübersichtlich werden, wenn viele Hilfs- und Selektoren-Dateien anzulegen sind. Ein Tipp: Ordner, die solche Dateien enthalten, können unter anderem einen Unterstrich als Präfix erhalten, um sie zu gruppieren und klar von Ordnern mit Testdateien abzugrenzen. Auch Features von Entwicklungsumgebungen, die es ermöglichen, von einer Komponente direkt zu ihrem Test zu springen, sind sehr zu empfehlen.

Analog zu Webpack-Aliassen ist es möglich, in der Jest-Konfiguration moduleNameMapper einzuführen:

moduleNameMapper: {
  [...]
  '^@/(.*)$': '<rootDir>/src/$1',
  '^#/(.*)$': '<rootDir>/tests/$1',
}

So sind auch die Importe in den Tests ausgesprochen übersichtlich:

import ProductPage from '@/components/product/ProductPage'
import { productTitle,  productDeletionDialog } from '#/_selectors/productPage.js'