Teil 6 - Unit-Tests für Helfer-Funktionen und Komponenten

Dieses Lab ist Teil der Amenity Finder Serie, einer umfassenden Anleitung, die Schritt für Schritt zeigt, wie man eine Web-Applikation mithilfe von Web-Components entwickelt. In jedem Teil der Serie werden spezifische Aspekte der Entwicklung behandelt und es werden bewährte Methoden und Techniken vermittelt, um eine moderne und skalierbare Webanwendung aufzubauen.

Im sechsten Teil der Serie fokussieren wir uns auf die Implementierung von Unit-Tests für unsere Helfer-Funktionen und Komponenten. Wir stellen sicher, dass die einzelnen Teile unserer Applikation ordnungsgemäss getestet werden, um eine hohe Qualität und Zuverlässigkeit zu gewährleisten.

Falls du dieses Lab bereits durchgearbeitet hast, lernst du im siebten und letzten Teil der Amenity Finder Serie, wie die Applikation in einzelnen Seiten unterteilt werden kann und das Routing implementiert wird.

Vorbereitung

Ziele

In diesem Lab werden wir uns mehreren Zielen widmen, um die Qualität und Zuverlässigkeit unserer Amenity Finder Applikation zu verbessern:

  • Infrastruktur für das Schreiben von Tests vorbereiten: Wir werden die notwendige Infrastruktur einrichten, um effektive Tests für unsere Amenity Finder Applikation schreiben zu können. Dazu gehören die Installation und Konfiguration von Test-Frameworks. Wir werden sicherstellen, dass unsere Entwicklungsumgebung optimal für das Testen vorbereitet ist und dass wir die erforderlichen Abhängigkeiten und Plugins installiert haben. Diese Infrastruktur wird uns dabei unterstützen, Tests einfach zu erstellen, auszuführen und die Ergebnisse zu analysieren.

  • Beispiele von Unit-Tests für Funktionen und Komponenten erstellen: Wir werden konkrete Beispiele von Unit-Tests erstellen, um verschiedene Funktionen und Komponenten unserer Amenity Finder Applikation zu überprüfen. Wir werden sicherstellen, dass unsere Funktionen ordnungsgemäss funktionieren und die erwarteten Ergebnisse liefern. Durch das Schreiben von Unit-Tests können wir sicherstellen, dass unser Code korrekt und zuverlässig ist und mögliche Fehler oder Probleme frühzeitig erkennen.

  • Tests in verschiedenen Browsern ausführen: Wir werden unsere Tests in verschiedenen Browsern ausführen, um sicherzustellen, dass unsere Amenity Finder Applikation auf verschiedenen Plattformen einwandfrei funktioniert. Dies beinhaltet das Testen in gängigen Browsern wie Google Chrome, Mozilla Firefox, Microsoft Edge und Safari. Durch das Testen in verschiedenen Browsern können wir mögliche Inkompatibilitäten oder Probleme identifizieren und entsprechende Anpassungen vornehmen, um eine konsistente Benutzererfahrung zu gewährleisten.

Schritte

Die Unit-Tests für Helfer-Funktionen und Komponenten werden in vier Schritten umgesetzt:

  1. Testing Setup mit open-wc
  2. Tests für AmenityItem
  3. Unit Tests
  4. Tests in verschiedenen Browsern

Branch

Der sechste Teil des Amenity Finder Labs ist auf GitHub verfügbar.


Los geht’s!

Testing Setup mit open-wc

Für das Testing Setup verwenden wir den von open-wc empfohlenen Testing Stack. Das Kernstück des Stack ist der @web/test-runner. Damit können sowohl Unit-Tests als auch Tests an im DOM gerenderten Elementen durchgeführt werden. Alle Tests laufen in headless Browsern. Eine gute Übersicht zum Testing mit @web/test-runner findet ihr auf der Modern Web Webseite. Der Blog-Post «Testing Workflow for Web Components» empfiehlt sich ebenfalls als Lektüre und zeigt eine Schritt-für-Schritt-Anleitung zum Thema Testen von Web-Components.

Testing Stack im bestehenden Projekt aufsetzen

Grundsätzlich haben wir zwei Möglichkeiten, die Test-Infrastruktur in unserem Projekt aufzusetzen. Wir können entweder den manuellen Weg wählen oder den gleichen open-wc Generator verwenden, mit dem wir unser Projekt bereits in der Lektion 1 aufgesetzt haben. Der Einfachheit halber wählen wir hier den automatisierten Weg und führen den folgenden Befehl aus:

$ npx @open-wc/create@0.38.22

Der Generator führt uns nun durch mehrere Fragen. Diese beantworten wir wie folgt:

✔ What would you like to do today? › Upgrade an existing project
✔ What would you like to add? › Testing (web-test-runner)
✔ Would you like to use typescript? › No
✔ What is the tag name of your web component? … amenity-finder

./
├── package.json
└── web-test-runner.config.mjs

✔ Do you want to write this file structure to disk? › Yes
✔ Do you want to overwrite …/package.json? › Yes
Writing..... done
✔ Do you want to install dependencies? › Yes, with npm

Der Generator hat unser package.json um ein paar Dependencies und vor allem folgende Skripte erweitert:

--- package.json
+++ package.json
     "build": "rimraf dist && rollup -c rollup.config.js",
     "start:build": "web-dev-server --root-dir dist --app-index index.html --open",
     "start": "web-dev-server",
     "deploy": "npm run build && surge --domain itchy-frog.surge.sh dist",
+    "test": "web-test-runner --coverage",
+    "test:watch": "web-test-runner --watch"
   },
   "devDependencies": {
     "@babel/preset-env": "^7.15.4",
     "@open-wc/building-rollup": "^1.10.0",
     "@open-wc/eslint-config": "^4.3.0",
+    "@open-wc/testing": "next",
     "@rollup/plugin-babel": "^5.3.0",
     "@rollup/plugin-node-resolve": "^13.0.4",
     "@web/dev-server": "^0.1.22",
     "@web/rollup-plugin-html": "^1.10.1",
     "@web/rollup-plugin-import-meta-assets": "^1.0.7",
+    "@web/test-runner": "^0.13.17",
     "babel-plugin-template-html-minifier": "^4.1.0",
     "deepmerge": "^4.2.2",
     "eslint": "^7.32.0",

Neben den Anpassungen im package.json hat der Generator in der Datei web-test-runner.config.mjs eine Konfiguration für @web/test-runner angelegt. Darin ist unter anderem definiert, welche Dateien standardmässig als Tests laufen sollen: test/**/*.test.js. Weil wir bis jetzt noch keine Tests geschrieben haben, schlägt der Befehl:

$ npm test

mit dem folgenden Output:

Error: Could not find any test files with pattern(s): test/**/*.test.js

fehl. Im nächsten Abschnitt schreiben wir unseren ersten Test.


Tests für AmenityItem

Für unseren ersten Test nehmen wir eine der einfachen Komponenten und schreiben Tests für AmenityItem. Wir legen ein Verzeichnis test/components an ($ mkdir -p test/components) und darin ein File mit dem Namen AmenityItem.test.js an. Unsere Verzeichnisstruktur sollte nun etwa so aussehen:

src/
├── components/
│   └── AmenityItem.js
test/
└── components/
    └── AmenityItem.test.js

Alternativ könnten wir Komponenten und die dazugehörigen Tests auch auf der gleichen Ordnerstufe nebeneinander haben:

src/
└── components/
    ├── AmenityItem.js
    └── AmenityItem.test.js

Dafür müssten wir entweder den Standardwert test/**/*.test.js im web-test-runner.config.mjs anpassen oder beim $ npm test Befehl einen entsprechenden Parameter übergeben $ npm test -- --files 'src/**/*.test.js'. Für unser Beispiel bleiben wir bei der ersten Variante und definieren den Inhalt der AmenityFinder.test.js Datei wie folgt:

import { fixture, expect } from '@open-wc/testing';
import { html } from 'lit/static-html.js';

import '../../src/components/AmenityItem.js';

const result = {
  name: 'Some name',
  distance: '1250',
};

describe('<amenity-item>', () => {
  describe('Structure', () => {
    it('has a .amenity-item element', async () => {
      const el = await fixture(html`<amenity-item .name="${result.name}" .distance="${result.distance}"></amenity-item>`);
      expect(el.shadowRoot.querySelector('.amenity-item')).to.exist;
    });

    it('has a .amenity-item.-selected when selected', async () => {
      const el = await fixture(html`<amenity-item .name="${result.name}" .distance="${result.distance}" .selected="${true}"></amenity-item>`);
      expect(el.shadowRoot.querySelector('.amenity-item.-selected')).to.exist;
    });

    it('renders the right HTML structure', async () => {
      const el = await fixture(html`<amenity-item .name="${result.name}" .distance="${result.distance}"></amenity-item>`);
      expect(el.shadowRoot.querySelector('.amenity-item')).dom.to.equal(`
        <div class="amenity-item">
            <span class="name">${result.name}</span>
            <span class="distance">${result.distance}.00 m</span>
        </div>
      `);
    });
  });

  describe('Accessibility', () => {
    it('is displayed by default', async () => {
      const el = await fixture(html`<amenity-item></amenity-item>`);
      expect(el).to.be.displayed;
    });

    it('is accessible', async () => {
      const el = await fixture(html`<amenity-item .name="${result.name}" .distance="${result.distance}"></amenity-item>`);
      await expect(el).to.be.accessible();
    });

    it('is accessible when selected', async () => {
      const el = await fixture(html`<amenity-item .name="${result.name}" .distance="${result.distance}" .selected="${true}"></amenity-item>`);
      await expect(el).to.be.accessible();
    });

    it('is hidden when attribute hidden is true', async () => {
      const el = await fixture(html`<amenity-item hidden></amenity-item>`);
      expect(el).not.to.be.displayed;
    });
  });
});

Unser Test ist in zwei Bereiche gegliedert. Einmal testen wir in Structure die Struktur unserer Webkomponente. Die Tests und Assertions sollten dabei selbsterklärend sein. Das Gute an diesem Setup ist, dass wir für die DOM-Tests einfach native Web APIs aufrufen können (z. B. el.shadowRoot.querySelector). Die fixture Funktion ist Teil der Testing-Helfer-Bibliothek von open-wc.

Im zweiten Bereich Accessibility können wir mittels eines Chai-Plugin von open-wc automatisch die Zugänglichkeit unserer Webkomponente testen. Wir lassen den Test mit dem folgenden Befehl:

$ npm test

laufen. Der Output sieht diesmal schon etwas besser aus:

> amenity-finder@0.0.0 test
> web-test-runner --coverage


test/components/AmenityItem.test.js:

 🚧 Browser logs:
      Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information.
      Multiple versions of Lit loaded. Loading multiple versions is not recommended. See https://lit.dev/msg/multiple-versions for more information.

Chrome: |██████████████████████████████| 1/1 test files | 7 passed, 0 failed

Code coverage: 100 %
View full coverage report at coverage/lcov-report/index.html

Finished running tests in 1.4s, all tests passed! 🎉

Wir sehen, dass 7 tests durchgelaufen sind und alle erfolgreich waren. Während wir an den Tests schreiben, können wir anstatt $ npm test den Befehl $ npm run test:watch verwenden.


Unit Tests

Bis jetzt haben wir nur einen Test für unsere AmenityItem Webkomponente geschrieben. Wir wollen zur Veranschaulichung auch noch einen Unit-Test schreiben. Damit wir das möglichst an einem konkreten Beispiel machen können, verbinden wir es mit einer UX Verbesserung unserer Applikation. Wir wollen die Distanz in den Suchresultaten formatiert im Schweizer Nummernformat und ab 1000 Metern in Kilometern wie folgt anzeigen:

  • Bis 1000 Meter ohne Nachkommastellen (z. B. 816 m)
  • Ab 1000 Metern in km und maximal 2 Nachkommastellen (z. B. 1.21 km)
  • Zahlen ab 1000 sollen mit typografischem Apostroph getrennt sein (z. B. 1’2100 km)1
Format der Distanz

Testgetriebene Entwicklung

Wir fangen ganz im Sinn einer testgetriebene Entwicklung zuerst mit der Definition der Funktion an, schreiben dann die Tests dazu und bauen erst am Schluss die Funktion in unsere Applikation ein. Für das Nummernformat können wir uns einer weiteren Web-API bedienen, dem Intl.NumberFormat. Wir schreiben einen kleinen Wrapper um diese API, die noch eine gewisse Normalisierung und Überprüfung der Parameter liefert. Wir erstellen eine Datei helpers.js im Ordner utils und definieren diese wie folgt:

export const formatDistance = (distance, fractionDigits = 2, locale = 'de-CH') => {
  const normalizedDistance = parseInt(`${distance}`, 10);
  if (Number.isNaN(normalizedDistance)) {
    return distance;
  }

  const isKm = normalizedDistance >= 1000;
  const formatter = new Intl.NumberFormat(locale, {
    style: 'unit',
    unit: isKm ? 'kilometer' : 'meter', // https://tc39.es/ecma402/#table-sanctioned-simple-unit-identifiers
    unitDisplay: 'narrow',
    maximumFractionDigits: fractionDigits,
  });

  return formatter.format(isKm ? normalizedDistance / 1000 : normalizedDistance);
};

Wir parsen zuerst den Input-Parameter distance als Nummer, berechnen danach, ob wir in Metern oder Kilometern formatieren möchten, definieren eine Konfiguration für die Nummernformatierung über Intl.NumberFormat mit dem de-CH Locale und führen zum Schluss die Formatierung mit formatter.format durch.

Für diese formatDistance Funktion schreiben wir als Nächstes einen Unit-Test im test/utils/helpers.test.js:

import { expect } from '@open-wc/testing';
import { formatDistance } from '../../src/utils/helpers.js';

describe('helpers', () => {
  describe('formatDistance', () => {
    it('properly formats distance', () => {
      expect(formatDistance(1)).to.equal('1 m');
      expect(formatDistance(100)).to.equal('100 m');
      expect(formatDistance(1000)).to.equal('1 km');
      expect(formatDistance(10000)).to.equal('10 km');
      expect(formatDistance(1000000)).to.equal('1’000 km');
      expect(formatDistance(1000000000)).to.equal('1’000’000 km');
      expect(formatDistance('100')).to.equal('100 m');
      expect(formatDistance('100.00')).to.equal('100 m');
      expect(formatDistance('100000.00')).to.equal('100 km');
    });

    it('properly rounds uneven distances', () => {
      expect(formatDistance(1200)).to.equal('1.2 km');
      expect(formatDistance(2450)).to.equal('2.45 km');
      expect(formatDistance(2455)).to.equal('2.46 km');
      expect(formatDistance(2454)).to.equal('2.45 km');
    });

    it('ignores invalid input', () => {
      expect(formatDistance(undefined)).to.equal(undefined);
      expect(formatDistance(null)).to.equal(null);
      expect(formatDistance('abc')).to.equal('abc');
    });
  });
});

Diese Tests lassen wir mit:

$ npm test -- --files 'test/utils/helpers.test.js'

laufen. Sie sollten grün sein und wir müssen die Distanz-Formatierung nur noch in unserer AmenityItem Komponente einbauen:

--- src/components/AmenityItem.js
+++ src/components/AmenityItem.js
import { css, html, LitElement, nothing } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
+import { formatDistance } from '../utils/helpers.js';

 export class AmenityItem extends LitElement {
   static get properties() {


     return html`<div class="amenity-item ${classMap({ '-selected': this.selected })}">
       <span class="name">${this.name}</span>
-      <span class="distance">${Number.parseFloat(this.distance).toFixed(2)} m</span>
+      <span class="distance">${formatDistance(this.distance)}</span>
     </div>`;
   }
 }

Wir lassen zum Schluss noch zur Kontrolle alle unsere Tests mit $ npm test laufen und stellen fest, dass einer der Tests der AmenityItem Komponente fehlschlägt. Das neue Format der Distanz müssen wir in unseren Tests noch entsprechend anpassen:

--- test/components/AmenityItem.test.js
+++ test/components/AmenityItem.test.js
 const result = {
   name: 'Some name',
   distance: '1250',
+  distanceFormatted: '1.25 km',
 };

 describe('<amenity-item>', () => {

       expect(el.shadowRoot.querySelector('.amenity-item')).dom.to.equal(`
         <div class="amenity-item">
             <span class="name">${result.name}</span>
-            <span class="distance">${result.distance}.00 m</span>
+            <span class="distance">${result.distanceFormatted}</span>
         </div>
       `);
     });

Fast geschafft!

Du hast den sechsten Teil der Amenity Finder Serie erfolgreich abgeschlossen. In diesem Teil haben wir uns intensiv mit der Implementierung von Unit-Tests für Helfer-Funktionen und Komponenten beschäftigt. Durch die Tests können wir sicherstellen, dass unsere Codebausteine korrekt funktionieren und unerwünschte Fehler vermeiden. Du hast gelernt, wie man Testfälle erstellt und verschiedene Test-Frameworks und Bibliotheken einsetzt, um die Qualität und Zuverlässigkeit unserer Applikation zu verbessern. Im siebten und letzten Teil werden wir ein typisches Architekturkonzept mithilfe von DOM APIs implementieren.


Footnotes

1 Obwohl wir in der Applikation solch hohe Zahlen vermutlich nie sehen werden, einen Test dafür schreiben können wir.