Model-basiertes Testen in React mit Zustandsautomaten

Avatar of David Khourshid
David Khourshid am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Das Testen von Anwendungen ist von entscheidender Bedeutung, um sicherzustellen, dass der Code fehlerfrei ist und die logischen Anforderungen erfüllt werden. Das manuelle Schreiben von Tests ist jedoch mühsam und anfällig für menschliche Voreingenommenheit und Fehler. Darüber hinaus kann die Wartung ein Albtraum sein, insbesondere wenn Features hinzugefügt oder Geschäftslogiken geändert werden. Wir werden lernen, wie modellbasiertes Testen die Notwendigkeit des manuellen Schreibens von Integrations- und End-to-End-Tests eliminieren kann, indem es automatisch vollständige Tests generiert, die mit einem abstrakten Modell für jede App Schritt halten.

Von Unit-Tests bis hin zu Integrationstests, End-to-End-Tests und mehr gibt es viele verschiedene Testmethoden, die bei der Entwicklung nicht-trivialer Softwareanwendungen wichtig sind. Sie alle verfolgen ein gemeinsames Ziel, aber auf unterschiedlichen Ebenen: sicherstellen, dass sich die Anwendung, wenn sie von jemandem genutzt wird, genau wie erwartet verhält, ohne unerwartete Zustände, Fehler oder schlimmer noch, Abstürze.

Testing Trophy
Testing Trophy (von testingjavascript.com) zeigt die Bedeutung verschiedener Testarten

Kent C. Dodds beschreibt die praktische Bedeutung des Schreibens dieser Tests in seinem Artikel "Write tests. Not too many. Mostly integration." Manche Tests, wie statische und Unit-Tests, sind einfach zu erstellen, aber sie stellen nicht vollständig sicher, dass jede Einheit zusammenarbeitet. Andere Tests, wie Integrations- und End-to-End-Tests (E2E), erfordern mehr Zeitaufwand, geben Ihnen aber mehr Vertrauen, dass die Anwendung so funktioniert, wie der Benutzer sie erwartet, da sie Szenarien nachbilden, die der Benutzer im wirklichen Leben mit der Anwendung durchführen würde.

Warum gibt es also heutzutage nie viele Integrations- oder E2E-Tests in Anwendungen, sondern Hunderte (wenn nicht Tausende) von Unit-Tests? Die Gründe reichen von fehlenden Ressourcen über fehlende Zeit bis hin zum mangelnden Verständnis für die Bedeutung dieser Tests. Selbst wenn zahlreiche Integrations-/E2E-Tests geschrieben werden, müssen die meisten dieser langen und komplizierten Tests neu geschrieben werden, wenn sich ein Teil der Anwendung ändert, und neue Tests müssen geschrieben werden. Unter Zeitdruck wird dies schnell unmöglich.

Von automatisiert zu automatisch generiert

Der Status Quo der Anwendungsprüfung ist

  1. Manuelle Tests, bei denen keine automatisierten Tests existieren und Features und User Flows der App manuell getestet werden
  2. Das Schreiben von automatisierten Tests, d. h. geskriptete Tests, die von einem Programm automatisch ausgeführt werden können, anstatt manuell von einem Menschen getestet zu werden
  3. Testautomatisierung, d. h. die Strategie zur Ausführung dieser automatisierten Tests im Entwicklungszyklus.

Selbstverständlich spart die Testautomatisierung viel Zeit bei der *Ausführung* der Tests, aber die Tests müssen immer noch manuell geschrieben werden. Es wäre schön, einem Werkzeug sagen zu können: "Hier ist eine Beschreibung, wie die Anwendung funktionieren soll. Generiere nun alle Tests, auch die Randfälle."

Glücklicherweise existiert diese Idee bereits (und wird seit Jahrzehnten erforscht) und nennt sich modellbasiertes Testen. So funktioniert es:

  1. Ein abstraktes "Modell", das das Verhalten Ihrer Anwendung beschreibt (in Form eines gerichteten Graphen), wird erstellt
  2. Aus dem gerichteten Graphen werden Testpfade generiert
  3. Jeder "Schritt" im Testpfad wird einem Test zugeordnet, der an der Anwendung ausgeführt werden kann.

Jeder Integrations- und E2E-Test ist im Wesentlichen eine Reihe von Schritten, die zwischen

  1. Überprüfung, ob die Anwendung korrekt aussieht (ein **Zustand**)
  2. Simulation einer Aktion (um ein **Ereignis** zu erzeugen)
  3. Überprüfung, ob die Anwendung nach der Aktion richtig aussieht (ein weiterer **Zustand**)

Wenn Sie mit dem Given-When-Then-Stil des Verhaltensbasierten Testens vertraut sind, wird Ihnen das bekannt vorkommen

  1. Gegeben sei ein bestimmter Anfangszustand (Präkondition)
  2. Wenn eine bestimmte Aktion auftritt (Verhalten)
  3. Dann wird ein neuer Zustand erwartet (Postkondition).

Ein Modell kann alle möglichen Zustände und Ereignisse beschreiben und automatisch die "Pfade" generieren, die benötigt werden, um von einem Zustand zum nächsten zu gelangen, so wie Google Maps die möglichen Routen zwischen zwei Orten generieren kann. Ähnlich wie eine Kartenroute ist jeder Pfad eine Sammlung von Schritten, die benötigt werden, um von Punkt A nach Punkt B zu gelangen.

Integrationstests ohne Modell

Um dies besser zu erklären, betrachten wir eine einfache "Feedback"-Anwendung. Wir können sie wie folgt beschreiben:

  • Ein Panel erscheint und fragt den Benutzer: "Wie war Ihre Erfahrung?"
  • Der Benutzer kann auf "Gut" oder "Schlecht" klicken.
  • Wenn der Benutzer auf "Gut" klickt, erscheint ein Bildschirm mit der Aufschrift "Danke für Ihr Feedback".
  • Wenn der Benutzer auf "Schlecht" klickt, erscheint ein Formular, das weitere Informationen anfordert.
  • Der Benutzer kann optional das Formular ausfüllen und das Feedback absenden.
  • Wenn das Formular abgeschickt wird, erscheint der Danke-Bildschirm.
  • Der Benutzer kann auf "Schließen" klicken oder die Escape-Taste drücken, um die Feedback-App auf jedem Bildschirm zu schließen.

Siehe den Pen
Untitled by David Khourshid(
@davidkpiano)
auf CodePen.

Manuelles Testen der App

Die Bibliothek @testing-library/react erleichtert das Rendern von React-Apps in einer Testumgebung mit ihrer render() Funktion. Diese gibt nützliche Methoden zurück, wie z. B.

  • getByText, die DOM-Elemente anhand des darin enthaltenen Textes identifiziert
  • baseElement, die das Root-document.documentElement repräsentiert und zum Auslösen eines keyDown-Ereignisses verwendet wird
  • queryByText, die keinen Fehler wirft, wenn ein DOM-Element, das den angegebenen Text enthält, fehlt (damit wir überprüfen können, ob nichts gerendert wird)
import Feedback from './App';
import { render, fireEvent, cleanup } from 'react-testing-library';

// ...

// Render the feedback app
const {
  getByText,
  getByTitle,
  getByPlaceholderText,
  baseElement,
  queryByText
} = render(<Feedback />);

// ...

Weitere Informationen finden Sie in der Dokumentation von @testing-library/react. Schreiben wir ein paar Integrationstests dafür mit Jest (oder Mocha) und @testing-library/react

import { render, fireEvent, cleanup } from '@testing-library/react';

describe('feedback app', () => {
  afterEach(cleanup);

  it('should show the thanks screen when "Good" is clicked', () => {
    const { getByText } = render(<Feedback />);

    // The question screen should be visible at first
    assert.ok(getByText('How was your experience?'));

    // Click the "Good" button
    fireEvent.click(getByText('Good'));

    // Now the thanks screen should be visible
    assert.ok(getByText('Thanks for your feedback.'));
  });

  it('should show the form screen when "Bad" is clicked', () => {
    const { getByText } = render(<Feedback />);

    // The question screen should be visible at first
    assert.ok(getByText('How was your experience?'));

    // Click the "Bad" button
    fireEvent.click(getByText('Bad'));

    // Now the form screen should be visible
    assert.ok(getByText('Care to tell us why?'));
  });
});

Nicht schlecht, aber Sie werden feststellen, dass es einige Wiederholungen gibt. Zuerst ist das kein großes Problem (Tests müssen nicht unbedingt DRY sein), aber diese Tests können weniger wartbar werden, wenn

  • Das Verhalten der Anwendung sich ändert, z. B. durch Hinzufügen oder Löschen von Schritten
  • Die Benutzeroberflächenelemente sich ändern, und zwar auf eine Weise, die nicht einmal eine einfache Komponentenänderung ist (z. B. das Ersetzen einer Schaltfläche durch eine Tastenkombination oder Geste)
  • Randfälle auftreten und berücksichtigt werden müssen.

Darüber hinaus testen E2E-Tests (End-to-End) dasselbe Verhalten (wenn auch in einer realistischeren Testumgebung, wie z. B. einem Live-Browser mit Puppeteer oder Selenium), können aber nicht dieselben Tests wiederverwenden, da der Code zur Ausführung der Tests mit diesen Umgebungen inkompatibel ist.

Der Zustandsautomat als abstraktes Modell

Erinnern Sie sich an die informelle Beschreibung unserer Feedback-App oben? Wir können diese in ein Modell übersetzen, das die verschiedenen Zustände, Ereignisse und Übergänge zwischen Zuständen darstellt, in denen sich die App befinden kann; mit anderen Worten, einen endlichen Zustandsautomaten. Ein endlicher Zustandsautomat ist eine Darstellung von

  • Die endlichen Zustände in der App (z. B. question, form, thanks, closed)
  • Ein Anfangszustand (z. B. question)
  • Die Ereignisse, die in der App auftreten können (z. B. CLICK_GOOD, CLICK_BAD für das Klicken auf die Gut/Schlecht-Buttons, CLOSE für das Klicken auf den Schließen-Button und SUBMIT für das Absenden des Formulars)
  • Übergänge, d. h. wie ein Zustand aufgrund eines Ereignisses zu einem anderen Zustand wechselt (z. B. wenn man sich im Zustand question befindet und die Aktion CLICK_GOOD ausgeführt wird, befindet sich der Benutzer nun im Zustand thanks)
  • Endzustände (z. B. closed), falls zutreffend.

Das Verhalten der Feedback-App kann mit diesen Zuständen, Ereignissen und Übergängen in einem endlichen Zustandsautomaten dargestellt werden und sieht wie folgt aus:

State diagram of example Feedback app

Eine visuelle Darstellung kann aus einer JSON-ähnlichen Beschreibung des Zustandsautomaten erstellt werden, mit XState

import { Machine } from 'xstate';

const feedbackMachine = Machine({
  id: 'feedback',
  initial: 'question',
  states: {
    question: {
      on: {
        CLICK_GOOD: 'thanks',
        CLICK_BAD: 'form',
        CLOSE: 'closed'
      }
    },
    form: {
      on: {
        SUBMIT: 'thanks',
        CLOSE: 'closed'
      }
    },
    thanks: {
      on: {
        CLOSE: 'closed'
      }
    },
    closed: {
      type: 'final'
    }
  }
});

https://xstate.js.org/viz/?gist=e711330f8aad8b52da76419282555820

Wenn Sie tiefer in XState eintauchen möchten, können Sie die XState-Dokumentation lesen oder einen großartigen Artikel über die Verwendung von XState mit React von Jon Bellah lesen. Beachten Sie, dass dieser endliche Zustandsautomat nur zum Testen verwendet wird und nicht in unserer tatsächlichen Anwendung – dies ist ein wichtiges Prinzip des modellbasierten Testens, da er darstellt, wie der Benutzer erwartet, dass die App funktioniert, und nicht ihre tatsächlichen Implementierungsdetails. Die App muss nicht unbedingt mit Blick auf endliche Zustandsautomaten erstellt werden (obwohl es eine sehr hilfreiche Praxis ist).

Erstellen eines Testmodells

Das Verhalten der App wird nun als **gerichteter Graph** beschrieben, bei dem die Knoten Zustände und die Kanten (oder Pfeile) Ereignisse sind, die die Übergänge zwischen Zuständen bezeichnen. Wir können diesen Zustandsautomaten (die abstrakte Darstellung des Verhaltens) verwenden, um ein **Testmodell** zu erstellen. Die Bibliothek @xstate/graph enthält eine Funktion createModel, um dies zu tun.

import { Machine } from 'xstate';
import { createModel } from '@xstate/test';

const feedbackMachine = Machine({/* ... */});

const feedbackModel = createModel(feedbackMachine);

Dieses Testmodell ist ein abstraktes Modell, das das gewünschte Verhalten des **Systems unter Test (SUT)** repräsentiert – in diesem Beispiel unsere App. Mit diesem Testmodell können Testpläne erstellt werden, mit denen wir überprüfen können, ob das SUT jeden Zustand im Modell erreichen kann. Ein Testplan beschreibt die Testpfade, die zum Erreichen eines Zielzustands genommen werden können.

Zustände überprüfen

Im Moment ist dieses Modell etwas nutzlos. Es kann Testpfade generieren (wie wir im nächsten Abschnitt sehen werden), aber um seinem Zweck als Testmodell zu dienen, müssen wir für jeden der Zustände einen Test hinzufügen. Das Paket @xstate/test liest diese Testfunktionen aus meta.test.

const feedbackMachine = Machine({
  id: 'feedback',
  initial: 'question',
  states: {
    question: {
      on: {
        CLICK_GOOD: 'thanks',
        CLICK_BAD: 'form',
        CLOSE: 'closed'
      },
      meta: {
        // getByTestId, etc. will be passed into path.test(...) later.
        test: ({ getByTestId }) => {
          assert.ok(getByTestId('question-screen'));
        }
      }
    },
    // ... etc.
  }
});

Beachten Sie, dass dies dieselben Überprüfungen sind wie in den manuell geschriebenen Tests, die wir zuvor mit @testing-library/react erstellt haben. Der Zweck dieser Tests ist es, die *Präkondition* zu überprüfen, dass sich das SUT im gegebenen Zustand befindet, bevor ein Ereignis ausgeführt wird.

Ereignisse ausführen

Um unser Testmodell zu vervollständigen, müssen wir jedes der Ereignisse, wie z. B. CLICK_GOOD oder CLOSE, "real" und ausführbar machen. Das heißt, wir müssen diese Ereignisse tatsächlichen Aktionen zuordnen, die im SUT ausgeführt werden. Die Ausführungsfunktionen für jedes dieser Ereignisse werden in createModel(…).withEvents(…) angegeben.

import { Machine } from 'xstate';
import { createModel } from '@xstate/test';

const feedbackMachine = Machine({/* ... */});

const feedbackModel = createModel(feedbackMachine)
  .withEvents({
    // getByTestId, etc. will be passed into path.test(...) later.
    CLICK_GOOD: ({ getByText }) => {
      fireEvent.click(getByText('Good'));
    },
    CLICK_BAD: ({ getByText }) => {
      fireEvent.click(getByText('Bad'));
    },
    CLOSE: ({ getByTestId }) => {
      fireEvent.click(getByTestId('close-button'));
    },
    SUBMIT: {
      exec: async ({ getByTestId }, event) => {
        fireEvent.change(getByTestId('response-input'), {
          target: { value: event.value }
        });
        fireEvent.click(getByTestId('submit-button'));
      },
      cases: [{ value: 'something' }, { value: '' }]
    }
  });

Beachten Sie, dass Sie entweder jedes Ereignis als Ausführungsfunktion angeben können, oder (im Fall von SUBMIT) als Objekt mit der Ausführungsfunktion in exec und Beispielereignisfällen in cases.

Vom Modell zu Testpfaden

Betrachten Sie noch einmal die Visualisierung und folgen Sie den Pfeilen, beginnend mit dem anfänglichen question-Zustand. Sie werden feststellen, dass es viele mögliche Pfade gibt, um zu jedem anderen Zustand zu gelangen. Zum Beispiel:

  • Vom question-Zustand führt das CLICK_GOOD-Ereignis zu...
  • dem form-Zustand, und dann führt das SUBMIT-Ereignis zu...
  • dem thanks-Zustand, und dann führt das CLOSE-Ereignis zu...
  • dem closed-Zustand.

Da das Verhalten der App ein gerichteter Graph ist, können wir alle möglichen einfachen Pfade oder kürzesten Pfade vom Startzustand aus generieren. Ein einfacher Pfad ist ein Pfad, bei dem kein Knoten wiederholt wird. Das heißt, wir gehen davon aus, dass der Benutzer einen Zustand nicht mehr als einmal besucht (obwohl dies in Zukunft ein valides Testziel sein könnte). Ein kürzester Pfad ist der kürzeste dieser einfachen Pfade.

Anstatt Algorithmen zur Traversierung von Graphen zur Ermittlung kürzester Pfade zu erklären (Vaidehi Joshi hat großartige Artikel zur Graphentraversierung, wenn Sie sich dafür interessieren), verfügt das mit @xstate/test erstellte Testmodell über eine Methode .getSimplePathPlans(…), die **Testpläne** generiert.

Jeder Testplan repräsentiert einen Zielzustand und einfache Pfade vom Startzustand zu diesem Zielzustand. Jeder Testpfad repräsentiert eine Reihe von Schritten, um zu diesem Zielzustand zu gelangen, wobei jeder Schritt einen state (Präkondition) und ein event (Aktion) enthält, das nach der Überprüfung, ob sich die App im state befindet, ausgeführt wird.

Beispielsweise kann ein einzelner Testplan das Erreichen des thanks-Zustands darstellen, und dieser Testplan kann einen oder mehrere Pfade zum Erreichen dieses Zustands haben, wie z. B. question -- CLICK_BAD → form -- SUBMIT → thanks oder question -- CLICK_GOOD → thanks.

testPlans.forEach(plan => {
  describe(plan.description, () => {
    // ...
  });
});

Wir können dann über diese Pläne iterieren, um jeden Zustand zu describen. Die plan.description wird von @xstate/test bereitgestellt, z. B. reaches state: "question".

// Get test plans to all states via simple paths
const testPlans = testModel.getSimplePathPlans();

Und jeder path in plan.paths kann getestet werden, ebenfalls mit einer bereitgestellten path.description wie via CLICK_GOOD → CLOSE.

testPlans.forEach(plan => {
  describe(plan.description, () => {
    // Do any cleanup work after testing each path
    afterEach(cleanup);

    plan.paths.forEach(path => {
      it(path.description, async () => {
        // Test setup
        const rendered = render(<Feedback />);

        // Test execution
        await path.test(rendered);
      });
    });
  });
});

Das Testen eines Pfades mit path.test(…) beinhaltet

  1. Überprüfung, ob sich die App in einem bestimmten state eines Pfadschritts befindet
  2. Ausführung der Aktion, die dem event eines Pfadschritts zugeordnet ist
  3. Wiederholung von 1. und 2., bis keine Schritte mehr übrig sind
  4. Schließlich Überprüfung, ob sich die App im Zielzustand plan.state befindet.

Schließlich wollen wir sicherstellen, dass jeder der Zustände in unserem Testmodell getestet wurde. Wenn die Tests ausgeführt werden, verfolgt das Testmodell die getesteten Zustände und stellt eine Funktion testModel.testCoverage() bereit, die fehlschlägt, wenn nicht alle Zustände abgedeckt wurden.

it('coverage', () => {
  testModel.testCoverage();
});

Insgesamt sieht unsere Testsuite wie folgt aus:

import React from 'react';
import Feedback from './App';
import { Machine } from 'xstate';
import { render, fireEvent, cleanup } from '@testing-library/react';
import { assert } from 'chai';
import { createModel } from '@xstate/test';

describe('feedback app', () => {
  const feedbackMachine = Machine({/* ... */});
  const testModel = createModel(feedbackMachine)
    .withEvents({/* ... */});

  const testPlans = testModel.getSimplePathPlans();
  testPlans.forEach(plan => {
    describe(plan.description, () => {
      afterEach(cleanup);
      plan.paths.forEach(path => {
        it(path.description, () => {
          const rendered = render(<Feedback />);
          return path.test(rendered);
        });
      });
    });
  });

  it('coverage', () => {
    testModel.testCoverage();
  });
});

Das mag nach etwas Einrichtungsaufwand aussehen, aber manuell geskriptete Integrationstests erfordern sowieso all diesen Einrichtungsaufwand, nur auf eine viel weniger abstrakte Weise. Einer der Hauptvorteile des modellbasierten Testens ist, dass Sie dies nur **einmal** einrichten müssen, unabhängig davon, ob 10 oder 1.000 Tests generiert werden.

Tests ausführen

In create-react-app werden die Tests mit Jest über den Befehl npm test (oder yarn test) ausgeführt. Wenn die Tests ausgeführt werden und alle bestanden werden, sieht die Ausgabe etwa so aus:

PASS  src/App.test.js
feedback app
  ✓ coverage
  reaches state: "question" 
    ✓ via  (44ms)
  reaches state: "thanks" 
    ✓ via CLICK_GOOD (17ms)
    ✓ via CLICK_BAD → SUBMIT ({"value":"something"}) (13ms)
  reaches state: "closed" 
    ✓ via CLICK_GOOD → CLOSE (6ms)
    ✓ via CLICK_BAD → SUBMIT ({"value":"something"}) → CLOSE (11ms)
    ✓ via CLICK_BAD → CLOSE (10ms)
    ✓ via CLOSE (4ms)
  reaches state: "form" 
    ✓ via CLICK_BAD (5ms)

Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        2.834s

Das sind neun Tests, die automatisch mit unserem endlichen Zustandsautomatenmodell der App generiert wurden! Jeder einzelne dieser Tests überprüft, ob sich die App im richtigen Zustand befindet und ob die richtigen Aktionen ausgeführt (und validiert) werden, um in jedem Schritt zum nächsten Zustand zu wechseln, und überprüft schließlich, ob sich die App im richtigen Zielzustand befindet.

Diese Tests können schnell wachsen, je komplexer Ihre App wird; zum Beispiel, wenn Sie jeder Bildschirm einen Zurück-Button hinzufügen oder eine Validierungslogik zur Formularseite hinzufügen (bitte tun Sie das nicht; seien Sie dankbar, dass der Benutzer das Feedback-Formular überhaupt ausfüllt) oder einen Ladezustand beim Absenden des Formulars hinzufügen, wird die Anzahl der möglichen Pfade zunehmen.

Vorteile des modellbasierten Testens

Das modellbasierte Testen vereinfacht die Erstellung von Integrations- und E2E-Tests erheblich, indem es sie basierend auf einem Modell (wie einem endlichen Zustandsautomaten) automatisch generiert, wie oben gezeigt. Da das manuelle Schreiben vollständiger Tests aus dem Testentwicklungsprozess entfällt, wird das Hinzufügen oder Entfernen neuer Features nicht mehr zu einer Testwartungsbelastung. Das abstrakte Modell muss nur aktualisiert werden, ohne andere Teile des Testcodes anzufassen.

Wenn Sie beispielsweise die Funktion hinzufügen möchten, dass das Formular angezeigt wird, unabhängig davon, ob der Benutzer auf die Schaltfläche "Gut" oder "Schlecht" klickt, ist dies eine einzeilige Änderung im endlichen Zustandsautomaten.

// ...
    question: {
      on: {
//      CLICK_GOOD: 'thanks',
        CLICK_GOOD: 'form',
        CLICK_BAD: 'form',
        CLOSE: 'closed',
        ESC: 'closed'
      },
      meta: {/* ... */}
    },
// ...

Alle von dem neuen Verhalten betroffenen Tests werden aktualisiert. Die Testwartung wird auf die Wartung des Modells reduziert, was Zeit spart und Fehler verhindert, die beim manuellen Aktualisieren von Tests auftreten können. Dies hat sich als Effizienzsteigerung sowohl bei der Entwicklung als auch beim Testen von Produktionsanwendungen erwiesen, insbesondere bei Microsoft-Kundenprojekten – wenn neue Features hinzugefügt oder Änderungen vorgenommen wurden, lieferten die automatisch generierten Tests sofortiges Feedback darüber, welche Teile der App-Logik betroffen waren, ohne dass verschiedene Flows manuell regressiv getestet werden mussten.

Da das Modell abstrakt und nicht an Implementierungsdetails gebunden ist, kann dasselbe Modell sowie der Großteil des Testcodes auch zur Erstellung von E2E-Tests verwendet werden. Die einzigen Dinge, die sich ändern würden, sind die Tests zur Überprüfung des Zustands und die Ausführung der Aktionen. Wenn Sie beispielsweise Puppeteer verwenden, können Sie den Zustandsautomaten aktualisieren.

// ...
question: {
  on: {
    CLICK_GOOD: 'thanks',
    CLICK_BAD: 'form',
    CLOSE: 'closed'
  },
  meta: {
    test: async (page) => {
      await page.waitFor('[data-testid="question-screen"]');
    }
  }
},
// ...
const testModel = createModel(/* ... */)
  .withEvents({
    CLICK_GOOD: async (page) => {
      const goodButton = await page.$('[data-testid="good-button"]');
      await goodButton.click();
    },
    // ...
  });

Und dann können diese Tests gegen eine Live-Chromium-Browserinstanz ausgeführt werden.

End-to-end tests for Feedback app being run in a browser

Die Tests werden automatisch generiert, und das kann nicht genug betont werden. Obwohl es nur wie eine schicke Art ist, DRY-Testcode zu erstellen, geht es exponentiell weiter – automatisch generierte Tests können Pfade erschöpfend darstellen, die alle möglichen Aktionen eines Benutzers in allen möglichen Zuständen der App erkunden, was leicht Randfälle aufdecken kann, die Sie sich vielleicht nicht einmal vorgestellt haben.

Der Code für die Integrationstests mit @testing-library/react und die E2E-Tests mit Puppeteer finden Sie im Demo-Repository für XState-Tests.

Herausforderungen des modellbasierten Testens

Da das modellbasierte Testen die Arbeit vom manuellen Schreiben von Tests auf das manuelle Schreiben von Modellen verlagert, gibt es eine Lernkurve. Die Erstellung des Modells erfordert das Verständnis von endlichen Zustandsautomaten und möglicherweise sogar von Statecharts. Das Erlernen dieser Konzepte ist aus mehr als nur Testgründen von großem Vorteil, da endliche Zustandsautomaten zu den Kernprinzipien der Informatik gehören und Statecharts Zustandsautomaten flexibler und skalierbarer für komplexe App- und Softwareentwicklungen machen. The World of Statecharts von Erik Mogensen ist eine großartige Ressource zum Verständnis und Erlernen der Funktionsweise von Statecharts.

Ein weiteres Problem ist, dass der Algorithmus zur Traversierung des endlichen Zustandsautomaten exponentiell viele Testpfade generieren kann. Dies kann als gutes Problem betrachtet werden, da jeder dieser Pfade eine gültige Möglichkeit darstellt, wie ein Benutzer mit einer App interagieren kann. Dies kann jedoch auch rechenintensiv sein und zu semi-redundanten Tests führen, die Ihr Team lieber überspringen würde, um Testzeit zu sparen. Es gibt auch Möglichkeiten, diese Testpfade zu begrenzen, z. B. durch die Verwendung kürzester Pfade anstelle von einfachen Pfaden oder durch Refactoring des Modells. Übermäßige Tests können ein Zeichen für ein zu komplexes Modell (oder sogar eine zu komplexe App 😉) sein.

Weniger Tests schreiben!

Das Modellieren des App-Verhaltens ist keine leichte Aufgabe, aber die Darstellung Ihrer App als deklaratives und abstraktes Modell, wie z. B. ein endlicher Zustandsautomat oder ein Statechart, bietet viele Vorteile. Obwohl das Konzept des modellbasierten Testens über zwei Jahrzehnte alt ist, ist es immer noch ein sich entwickelndes Feld. Aber mit den oben genannten Techniken können Sie noch heute beginnen und davon profitieren, Integrations- und E2E-Tests zu generieren, anstatt jeden einzelnen manuell zu schreiben.

Weitere Ressourcen

Ich habe auf der React Rally 2019 einen Vortrag gehalten, der modellbasiertes Testen in React-Apps demonstriert

Folien: Folien: Weniger Tests schreiben! Von Automatisierung zu Autogenerierung

Viel Spaß beim Testen!