Komponententests in der Praxis

Komponententests sind ein guter Ausgangspunkt, um praktischen Testcode zu demonstrieren. Komponententests sind umfangreicher als einfache Einheitentests und weniger komplex als End-to-End-Tests und dienen der Interaktion mit dem DOM. Die Verwendung von React macht es Webentwicklern philosophischer geworden, Websites oder Webanwendungen als aus Komponenten bestehenden Elementen zu betrachten.

Das Testen einzelner Komponenten, unabhängig davon, wie komplex sie sind, ist also eine gute Möglichkeit, über das Testen einer neuen oder vorhandenen Anwendung nachzudenken.

Auf dieser Seite wird das Testen einer kleinen Komponente mit komplexen externen Abhängigkeiten beschrieben. Es ist einfach, eine Komponente zu testen, die nicht mit anderem Code interagiert. Dazu können Sie beispielsweise auf eine Schaltfläche klicken und bestätigen, dass sich eine Zahl erhöht. In Wirklichkeit ist jedoch nur sehr wenig Code so. Das Testen von Code, der keine Interaktionen hat, kann von begrenztem Wert sein.

Dies ist nicht als vollständige Anleitung gedacht. In einem späteren Abschnitt, „Automatisierte Tests in der Praxis“, wird das Testen einer echten Website mit Beispielcode beschrieben, den Sie als Anleitung verwenden können. Auf dieser Seite finden Sie jedoch immer noch einige Beispiele für praktische Komponententests.

Die zu testende Komponente

Wir verwenden Vitest und dessen JSDOM-Umgebung, um eine React-Komponente zu testen. Auf diese Weise können wir mit Node.js schnell Tests in der Befehlszeile ausführen, während wir einen Browser emulieren.

Eine Liste von Namen mit einer Auswahlschaltfläche neben jedem Namen.
Eine kleine React-Komponente, die eine Liste der Nutzer aus dem Netzwerk anzeigt.

Die React-Komponente namens UserList ruft eine Liste von Nutzern aus dem Netzwerk ab und lässt Sie einen davon auswählen. Die Liste der Nutzer wird mit fetch in einem useEffect abgerufen und der Auswahl-Handler wird von Context übergeben. Das ist der Code:

import React, { useEffect, useState, useContext } from 'react';
import { UserContext } from './UserContext.tsx';
import { UserRow } from './UserRow.tsx';

export function UserList({ count = 4 }: { count?: number }) {
  const [users, setUsers] = useState<any[]>([]);
  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users?_limit=' + count)
      .then((response) => response.json())
      .then((json) => setUsers(json));
  }, [count]);

  const c = useContext(UserContext);
  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users.map((u) => (
          <li key={u.id}>
            <button onClick={() => c.userChosen(u.id)}>Choose</button>{' '}
            <UserRow u={u} />
          </li>
        ))}
      </ul>
    </div>
  );
}

Dieses Beispiel zeigt keine Best Practices für React (z. B. wird fetch in useEffect verwendet). Es ist aber wahrscheinlich, dass Ihre Codebasis viele solche Fälle enthält. Genauer gesagt können diese Fälle auf den ersten Blick seltsam erscheinen. In einem späteren Abschnitt dieses Kurses wird das Schreiben von testbarem Code ausführlich behandelt.

Dies sind die Elemente, die wir in diesem Beispiel testen:

  • Prüfen Sie, ob als Reaktion auf Daten aus dem Netzwerk ein korrektes DOM erstellt wird.
  • Vergewissern Sie sich, dass ein Klick auf einen Nutzer einen Rückruf auslöst.

Jede Komponente ist anders. Warum ist das Testen so interessant?

  • Sie verwendet den globalen fetch, um reale Daten aus dem Netzwerk anzufordern, die beim Testen instabil oder langsam sein können.
  • Es importiert eine weitere Klasse, UserRow, die wir möglicherweise nicht implizit testen möchten.
  • Dabei wird ein Context verwendet, der nicht Teil des zu testenden Codes ist und normalerweise von einer übergeordneten Komponente bereitgestellt wird.

Schreiben Sie zuerst einen Schnelltest

Wir können schnell etwas sehr Grundlegendes über diese Komponente testen. Zur Klarstellung: Dieses Beispiel ist nicht sehr nützlich! Es ist jedoch hilfreich, den Textbaustein in einer Peer-Datei namens UserList.test.tsx einzurichten (denken Sie daran, dass Test-Runner wie Vitest standardmäßig Dateien ausführen, die mit .test.js oder ähnlich enden, einschließlich .tsx):

import { vi, test, assert, afterAll } from 'vitest';
import { render } from '@testing-library/react';
import { UserList } from './UserList.tsx';
import React, { ContextType } from 'react';

test('render', async () => {
  const c = render(<UserList />);

  const headingNode = await c.findAllByText(/Users);
  assert.isNotNull(headingNode);
});

Dieser Test bestätigt, dass die Komponente beim Rendern der Komponente den Text „Users“ enthält. Sie funktioniert, auch wenn die Komponente den Nebeneffekt hat, dass eine fetch an das Netzwerk gesendet wird. fetch wird am Ende des Tests noch ausgeführt und es wurde kein Endpunkt festgelegt. Wir können nicht bestätigen, dass Nutzerinformationen nach dem Ende des Tests angezeigt werden, zumindest nicht ohne ein Zeitlimit zu warten.

Mock fetch()

Beim Mocking wird eine echte Funktion oder Klasse im Rahmen eines Tests durch etwas ersetzt, das Sie kontrollieren. Dies ist mit Ausnahme der einfachsten Einheitentests bei fast allen Testtypen üblich. Dies wird unter Assertions und andere Primitive ausführlicher behandelt.

Sie können die fetch() für Ihren Test simulieren, damit er schnell abgeschlossen wird und die erwarteten Daten zurückgibt, keine Daten vom Typ „real“ oder „unbekannt“. fetch ist ein global, d. h. wir müssen weder import noch require in unseren Code einfügen.

In Vitest können Sie ein globales Modell simulieren, indem Sie vi.stubGlobal mit einem speziellen Objekt aufrufen, das von vi.fn() zurückgegeben wird. Dadurch wird eine Simulation erstellt, die später geändert werden kann. Diese Methoden werden in einem späteren Abschnitt dieses Kurses genauer betrachtet. Im folgenden Code können Sie sie in der Praxis sehen:

test('render', async () => {
  const fetchMock = vi.fn();
  fetchMock.mockReturnValue(
    Promise.resolve({
      json: () => Promise.resolve([{ name: 'Sam', id: 'sam' }]),
    }),
  );
  vi.stubGlobal('fetch', fetchMock);

  const c = render(<UserList />);

  const headingNode = await c.queryByText(/Users);
  assert.isNotNull(headingNode);

  await waitFor(async () => {
    const samNode = await c.queryByText(/Sam);
    assert.isNotNull(samNode);
  });
});

afterAll(() => {
  vi.unstubAllGlobals();
});

Mit diesem Code wird ein Mock hinzugefügt, eine „gefälschte“ Version des Netzwerkabrufs Response beschrieben und gewartet, bis sie angezeigt wird. Wenn der Text nicht angezeigt wird – können Sie dies prüfen, indem Sie die Abfrage in queryByText ändern – schlägt der Test fehl.

In diesem Beispiel wurden die integrierten Mocking-Hilfsmittel von Vitest verwendet. Andere Test-Frameworks haben jedoch einen ähnlichen Ansatz für das Mocking. Vitest ist insofern einmalig, als Sie nach allen Tests vi.unstubAllGlobals() aufrufen oder eine äquivalente globale Option festlegen müssen. Wenn unsere Arbeit nicht „rückgängig gemacht“ wird, kann sich das fetch-Mock auf andere Tests auswirken. Jede Anfrage wird mit einem anderen JSON-Datenstapel beantwortet.

Mock-Importe

Vielleicht haben Sie schon bemerkt, dass unsere UserList-Komponente selbst eine Komponente namens UserRow importiert. Auch wenn der Code nicht eingefügt wurde, wird der Name des Nutzers gerendert. Bei den vorherigen Testprüfungen wird nach „Sam“ gesucht und der Code wird nicht direkt in UserList gerendert, daher muss er aus UserRow stammen.

Ein Flussdiagramm dazu, wie sich die Namen von Nutzern durch unsere Komponente bewegen.
UserListTest hat keine Sichtbarkeit von UserRow.

UserRow kann jedoch selbst eine komplexe Komponente sein. Sie könnte weitere Nutzerdaten abrufen oder Nebenwirkungen haben, die für unseren Test nicht relevant sind. Durch das Entfernen dieser Variabilität werden Ihre Tests hilfreicher, insbesondere da die Komponenten, die Sie testen möchten, komplexer und verflochtener mit ihren Abhängigkeiten werden.

Glücklicherweise können Sie Vitest verwenden, um bestimmte Importe zu simulieren, selbst wenn sie in Ihrem Test nicht direkt verwendet werden. Daher wird jeder Code, der sie verwendet, mit einer einfachen oder bekannten Version bereitgestellt:

vi.mock('./UserRow.tsx', () => {
  return {
    UserRow(arg) {
      return <>{arg.u.name}</>;
    },
  }
});

test('render', async () => {
  // ...
});

Wie das Simulieren des globalen fetch ist dies ein leistungsstarkes Tool, aber es kann unrentabel werden, wenn Ihr Code viele Abhängigkeiten hat. Die beste Lösung hierfür ist das Schreiben von testbarem Code.

Klicken und Kontext angeben

React und andere Bibliotheken wie Lit haben ein Konzept namens Context. Der Beispielcode enthält ein UserContext, das die Methode aufruft, wenn ein Nutzer ausgewählt wird. Dies wird oft als Alternative zum "Prop-Drilldown" betrachtet, bei dem der Callback direkt an UserList übergeben wird.

In dem von uns geschriebenen Test-Harnisch wurden UserContext nicht bereitgestellt. Wenn Sie dem React-Test eine Klickaktion ohne sie hinzufügen, kommt es im schlimmsten Fall zum Absturz des Tests. Wenn an anderer Stelle eine Standardinstanz bereitgestellt wird, führt dies im schlimmsten Fall zu einem Verhalten, das außerhalb unserer Kontrolle liegt (ähnlich wie bei einem unbekannten UserRow oben):

  const c = render(<UserList />);
  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();

Stattdessen können Sie beim Rendern der Komponente Ihren eigenen Context angeben. In diesem Beispiel wird eine Instanz der vi.fn(), einer Vitest-Mock-Funktion, verwendet, mit der geprüft werden kann, ob ein Aufruf erfolgt ist und mit welchen Argumenten er ausgeführt wurde. In unserem Fall interagiert dies mit dem mockierten fetch im vorherigen Beispiel und der Test kann bestätigen, dass die übergebene ID „sam“ war:

  const userChosenFn = vi.fn();
  const ucForTest: ContextType<typeof UserContext> = { userChosen: userChosenFn as any };
  const c = render(
    <UserContext.Provider value={ucForTest}>
      <UserList />
    </UserContext.Provider>,
  );

  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();
  assert.deepStrictEqual(userChosenFn.mock.calls, [['sam']]);

Dies ist ein einfaches, aber leistungsstarkes Muster, mit dem Sie irrelevante Abhängigkeiten von der zu testenden Kernkomponente entfernen können.

Zusammenfassung

Dies war ein schnelles und vereinfachtes Beispiel, das zeigt, wie Sie einen Komponententest erstellen, um eine schwierig zu testende React-Komponente zu testen und zu schützen. Wichtig ist dabei, dass die Komponente richtig mit ihren Abhängigkeiten interagiert (globale fetch, importierte Unterkomponente und Context).

Wissen testen

Mit welchen Ansätzen wurde die React-Komponente getestet?

Komplexe Abhängigkeiten durch einfache Abhängigkeiten zum Testen simulieren
Abhängigkeitsinjektion mit Kontext
Globale Stubbing-Vorgänge
Überprüfen, ob eine Zahl erhöht wurde