Programmierung

Apr 2, 2021

Integrieren Sie E2E-Tests in Ihre PRs mit GitHub Actions & Cypress

Integrieren Sie E2E-Tests in Ihre PRs mit GitHub Actions & Cypress

Integrieren Sie E2E-Tests in Ihre PRs mit GitHub Actions & Cypress

Adrian Pilarczyk

Adrian Pilarczyk

Senior Backend-Entwickler

E2E-Tests zu schreiben ist einfach, aber eine CI/CD-Pipeline für sie zu erstellen ist... noch einfacher?

Es gibt nicht viele Dienste, die den Ruf von Cypress geniessen, und ich habe immer gewusst, dass es nicht nur Gerede ist. Obwohl das Schreiben von Tests mit Cypress jedes Mal ein Vergnügen war, hatte ich das Gefühl, dass es zu hoch für mich ist, Cypress in eine echte CI/CD-Pipeline einzubinden. Nun bin ich froh, berichten zu können, dass diese Annahme genau das war - eine Annahme, eine falsche noch dazu. GitHub Actions ermöglicht den Aufbau einer E2E-Testing-Pipeline zum Nulltarif*: weder zeitlich, noch finanziell.

In den nächsten Abschnitten werden wir dank der wunderbaren Kombination von Cypress und GitHub Actions ein einfaches CI/CD einrichten, das in der Lage sein wird, unsere E2E-Tests auf verschiedenen Geräten und Betriebssystemen auszuführen, Anfragen auf ihrem Weg zur API abzufangen und ein Video der tatsächlichen Interaktionen zurückzugeben, wenn der Testlauf fehlgeschlagen ist.

Inhalt

  1. Einrichten einer einfachen Next.js Anwendung

  2. Erstellen eines Endpunkts in Next.js

  3. Hinzufügen von Cypress und Schreiben eines Tests

  4. Integration von Cypress in GitHub

  5. Ausführen von Cypress über GitHub-Aktionen

Useful links

1. Einrichten einer einfachen Next.js-Anwendung

Für diesen Artikel habe ich eine einfache Next.js-Anwendung erstellt, die ein Formular mit asynchroner Logik enthält. Ich habe Next.js aus reiner Bequemlichkeit gewählt, denn Cypress ist völlig unabhängig von der verwendeten Front-End-Technologie und kann für die Durchführung von Tests auf jeder bestehenden Website eingesetzt werden. Wie auch immer, unser Hauptaugenmerk liegt auf der Datei Form.js, die wie folgt aussieht:

// src/components/Form.js

import React from 'react'
import styles from '../../styles/Home.module.css'

// our request to a Next.js endpoint
const sendForm = () =>
  fetch('/api/ping', {
    method: 'POST',
  })

const Form = () => {
  const [loading, setLoading] = React.useState(false)
  const [error, setError] = React.useState(null)
  const [data, setData] = React.useState(false)

  const submitHandler = async (e) => {
    e.preventDefault()
    setData(false)
    setLoading(true)
    try {
      const response = await sendForm()
      await response.json()
      setData(true)
      setLoading(false)
      setError(null)
    } catch (error) {
      setData(false)
      setLoading(false)
      setError(error)
    }
  }

  return (
    <form className={styles.form} onSubmit={submitHandler}>
      <label>
        First Name
        <input type="text" name="first_name" />
      </label>
      <label>
        Last Name
        <input type="text" name="last_name" />
      </label>
      <button type="submit">Submit</button>
      {loading && <h2>Loading...</h2>}
      {error && <h2>Error :(</h2>}
      {data && <h2>Success!</h2>}
    </form>
  )
}

export default Form

Der Anwendungsfall ist sehr einfach: Der/die Benutzer:in füllt alle Felder aus, klickt auf "Einsenden", wartet auf die Antwort von einem recht langsamen Server und erfreut sich an einer grossen hässlichen Überschrift "Erfolg!".

2. Erstellen eines Endpunkts in Next.js

Um einem realen Szenario möglichst nahe zu kommen, werden wir unsere Anfrage nicht wie Wilde nachahmen, sondern eine Native-Next.js-Funktion namens API Routes, um einen tatsächlichen (wenn auch sehr einfachen) Endpunkt zu erstellen:

// pages/api/ping.js

export default (req, res) => {
  setTimeout(() => {
    res.status(200).json({})
  }, 3000);
}

Alles, was dieser Endpunkt tut, ist den Status 200 zurückzugeben, nachdem wir 3 Sekunden unseres Lebens verschwendet haben. Zum Glück ist das alles, was wir brauchen, um mit dem Testen unserer Anwendung zu beginnen.

3. Hinzufügen von Cypress und Schreiben eines Tests

Nun wird es spannend. Wir fügen eine Abhängigkeit hinzu, die ein absoluter Star der heutigen Show ist: Cypress.

yarn add cypress --dev

// or

npm install cypress --save-dev

Die Macher:innen von Cypress nennen es "ein Front-End-Testing-Tool der nächsten Generation für das moderne Web", aber ich würde es eher als "das einzige Tool, das Sie jemals für das Testen begeistern kann 😎" bezeichnen. E2E-Tests sind per Definition viel näher an der Simulation der tatsächlichen Benutzererfahrung und daher viel angenehmer zu schreiben (weil man nicht in die Implementierungsdetails eintaucht), aber Cypress hebt es auf eine andere Ebene.

Cypress hat eine exzellente Dokumentation mit tonnenweise Beispielen, von denen ich Ihnen besonders einen Blick auf das sogenannte Kitchen Sink, weil es so ziemlich jedes Szenario enthält, das Ihnen in Ihrer Anwendung begegnen könnte. Leider wird sich der heutige Artikel nicht unbedingt auf die Leistungsfähigkeit von Cypress in Bezug auf das Schreiben von Assertions und die Simulation von Benutzerinteraktionen konzentrieren, sondern eher auf die Einfachheit, alles in einer Konfiguration einzurichten, die in produktionsreifen Anwendungen nützlich sein kann.

Das erste Mal, wenn Sie Cypress durch yarn/npm run cypress open starten, wird es eine grundlegende Ordnerstruktur mit einer Reihe von Dateien generieren und ich schlage vor, dass Sie diese nicht sofort durchstreichen, da z.B., die Beispiele, die in cypress/integration/examples zur Verfügung gestellt werden, können sehr nützlich sein.

Cypress kommt mit einer sehr praktischen GUI, mit der wir alle unsere Tests durchsuchen und verwalten können.

Sie wird ihren Wert beweisen, wenn wir endlich unseren ersten Test schreiben:

// cypress/integration/form.spec.js

describe('The form', () => {
  it('shows success message when submitted correctly', () => {
		cy.visit("http://localhost:3000");
    cy.get('[name="first_name"]').type("Adrian");
    cy.get('[name="last_name"]').type("Pilarczyk");
    cy.get('[type="submit"').click();
    cy.get('form').should('contain', 'Success!')
  });
});

Lassen Sie uns aufschlüsseln, was hier vor sich geht:

// cypress/integration/form.spec.js

// the name of the test suite
describe('The form', () => {
	// the description of the individual test
  it('shows success message when submitted correctly', () => {
		// command: tell Cypress to visit this address
		cy.visit("http://localhost:3000");

		// command: select an element of name === "first_name"
    cy.get('[name="first_name"]')
			.type("Adrian"); /* <- command: type a string into it */

		// repeat the above
    cy.get('[name="last_name"]').type("Pilarczyk");

		// command: find an element of a type === "submit" and click it
    cy.get('[type="submit"').click();

		// command: find a form element
    cy.get('form')
			.should('contain', 'Success!') /* assertion: check whether it has a content saying "Success!" */
  });
});

Ich wollte diese explizite Unterscheidung zwischen "Befehlen" und "Assertionen" machen (genauso wie die Cypress Dokumentation), um zu verdeutlichen, was die Hauptintuition hinter dem Schreiben von Cypress-Tests (und eigentlich jedem anderen UI-Test) ist: Zuerst wählen wir ein Element aus und dann führen wir entweder eine Aktion darauf aus oder wir prüfen, ob es bestimmte Bedingungen erfüllt.

In diesem Beispiel ist vor allem die letzte Zeile hervorzuheben:

(...).should('contain', 'Success!')

Beachten Sie, dass ich nicht angeben musste, welche Art von Inhalt Cypress erwarten soll. Ich habe es nur darüber informiert, dass es den String "Erfolg!" enthalten soll. Die Leute hinter Cypress sind sich dessen bewusst, was lästiges Testen für die Meisten von uns ist, also können Sie eine Menge KISSes von Ermutigung von ihnen erwarten.

Unser Test sollte sofort in der Cypress GUI landen. Lassen wir es dort laufen:

Wenn Sie Glück haben, sollten Sie folgendes sehen:

In der linken Spalte gibt Cypress eine Übersicht über alle Aktionen, die es für uns durchgeführt hat. Wir sehen auch das Ergebnis unserer Behauptung, das - und ich hoffe, dass Sie das oft sehen werden - in diesem Fall ein Erfolg ist.

In diesem einfachen Test haben wir unbewusst eine bestimmte E2E-Testdesign-Entscheidung getroffen, der Cypress einen ganzen Artikel gewidmet hat: Wir haben beschlossen, eine Anfrage an einen tatsächlichen Endpunkt zu senden. Jedes Mal, wenn die Ergebnisse unserer Tests die Datenbank erreichen, müssen wir eine ganz neue Schicht pflegen. Bereinigen wir die Datenbank, nachdem wir die Tests ausgeführt haben? Tun wir das in der Produktion? Oder richten wir eine spezielle Staging-Umgebung für sie ein? 🤯

Auf der anderen Seite: Das ist so realistisch wie es nur geht. Es ist genau die Erfahrung, die der/die Benutzer:in erwarten kann, was die Motivation hinter E2E-Tests bedeutet. Wir müssen uns nicht um das Mocking der Serverantworten kümmern (die sich im Laufe der Zeit auch ändern können), aber neben der Überwachung der Daten müssen wir auch die Zeit beachten, die für die Beendigung der Anfrage benötigt wird. Wie wir alle wissen, lautet das älteste Sprichwort der Welt "Zeit ist Geld", also nehme ich an, dass wir in der Lage sein wollen, dies in unseren Tests zu berücksichtigen.

Glücklicherweise stellt uns Cypress eine Methode namens intercept zur Verfügung, die genau das tut - sie fängt die Anfragen ab, die wir zu senden versuchen und gibt eine Antwort zurück, die wir in unserem Test angeben. Mit anderen Worten, es ermöglicht uns, Netzwerkanfragen nachzustellen. Mit dieser bescheidenen Ergänzung...

describe('The form', () => {
  it('shows success message when submitted correctly', () => {
    cy.intercept('POST', '**/api/ping', {
      statusCode: 200,
      body: {},
    })

    cy.visit('http://localhost:3000')
    cy.get('[name="first_name"]').type('Adrian')
    cy.get('[name="last_name"]').type('Pilarczyk')
    cy.get('[type="submit"').click()
    cy.get('form').should('contain', 'Success!')
  })
})

...können wir erwarten, dass unser Test etwas weniger Zeit benötigt:

4. Integration von Cypress in GitHub

Nun, da wir unseren ersten Test haben, ist es an der Zeit, ihn von unserer lokalen Umgebung in ein tatsächliches CI/CD zu übertragen. Dies beginnt mit der Integration von Cypress in unser Repository, was durch einen Dienst namens Cypress Dashboard ermöglicht wird. Der gesamte Prozess ist in der Cypress-Dokumentation perfekt beschrieben.

Wenn wir autorisiert sind, müssen wir unser Projekt initialisieren:

Den Anweisungen von Cypress Dashboard folgend, fügen wir den projectId zu cypress.json, die Cypress bei seinem ersten Lauf für uns hätte erstellen sollen. Der gesamte Prozess sollte, wenn er korrekt ausgeführt wird, dazu führen, dass die Cypress-Integration im Abschnitt GitHub "Integrations" in den Repository-Einstellungen vorhanden ist.

Hier beginnt der letzte und lustigste Teil.

5. Ausführen von Cypress über GitHub Actions

GitHub Actions ist eine grossartige Automatisierungspipeline, mit der wir unsere Cypress-Testsuite für jeden Pull Request in unserem Repository ausführen werden. Dazu stellen wir ihr eine .yml-Datei zur Verfügung, die alle Anweisungen enthält, die für die Ausführung unserer Tests benötigt werden:

# .github/workflows/e2e.yml

name: e2e
on: [pull_request]
jobs:
  cypress-run:
    name: Cypress run
    runs-on: ubuntu-16.04
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Cypress run
        uses: cypress-io/github-action@v2
        with:
          build: npm run build
          start: npm run start
          wait-on: http://localhost:3000

Eben noch waren Sie kurz davor zu kündigen, weil Sie nie vorhatten, Dev-ops zu werden, richtig? Diese Befürchtung hat mich viel zu lange davon abgehalten, Cypress in einer kommerziellen Umgebung einzusetzen. Doch mit Hilfe von GitHub Actions und Cypress (und deren vordefinierten Routinen namens cypress-io/github-action), liess mir der Kinn herunterfallen, als ich sie das erste Mal in Aktion sah:

Sie dachten, das wäre cool? Wie würden Sie das Hinzufügen einer Aufzeichnung unserer Tests mit nur 4 Zeilen Konfiguration nennen?

# .github/workflows/e2e.yml

name: e2e
on: [pull_request]
jobs:
  cypress-run:
    name: Cypress run
    runs-on: ubuntu-16.04
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Cypress run
        uses: cypress-io/github-action@v2
        with:
          build: npm run build
          start: npm run start
          wait-on: http://localhost:3000
      - uses: actions/upload-artifact@v1
        if: always() # / if: failure() <- more suitable for production
        with:
          name: cypress-videos
          path

Die Möglichkeiten enden nicht an diesem Punkt. Mit dem Cypress-Plugin für GitHub Actions können Sie alle möglichen verrückten Dinge tun, wie zum Beispiel: Verschiedene Browser spezifizieren, die durchzuführenden Tests eingrenzen oder Tests parallelisieren. Kombinieren Sie dies mit der Kenntnis von GitHub Actions events und Sie können sich einen massgeschneiderten E2E-Test-Workflow zusammenstellen.

Aaaaand that's a wrap! Wenn Ihnen das nicht Lust macht, Tests zu schreiben, gibt es keine Hoffnung für Sie 💀.

✍️

ÜBER DEN AUTOR

Adrian Pilarczyk

Adrian Pilarczyk

Senior Backend-Entwickler

Bester Freund von React und TypeScript. Engagierter Medienkonsument: Podcast-Kopf, Kinobesucher, Bücherwurm, immer aufnahmefähig. Liebhaber von MMA & BJJ 🥊.

Sie haben eine Projektidee? Lassen Sie uns darüber reden und sie zum Leben erwecken.

Ihre hochqualifizierten Spezialisten sind da. Kontaktieren Sie uns, um zu sehen, was wir gemeinsam tun können.

Dariusz Michalski

Dariusz Michalski, CEO

dariusz@useo.pl

Konrad Pochodaj

Konrad Pochodaj, CGO

konrad@useo.pl

Sie haben eine Projektidee? Lassen Sie uns darüber reden und sie zum Leben erwecken.

Ihre hochqualifizierten Spezialisten sind da. Kontaktieren Sie uns, um zu sehen, was wir gemeinsam tun können.

Dariusz Michalski

Dariusz Michalski, CEO

dariusz@useo.pl

Konrad Pochodaj

Konrad Pochodaj, CGO

konrad@useo.pl

Sie haben eine Projektidee? Lassen Sie uns darüber reden und sie zum Leben erwecken.

Ihre hochqualifizierten Spezialisten sind da. Kontaktieren Sie uns, um zu sehen, was wir gemeinsam tun können.