BDD und End-to-End-Tests – Cypress.io mit Cucumber verbinden

2 Kommentare

Cypress.io (oder kurz Cypress) bekommt momentan sehr viel Aufmerksamkeit, wenn es um das Thema End-to-End-Testing geht. Speziell im JavaScript-Umfeld scheint sich Cypress.io langsam durchzusetzen. Es macht vieles richtig und ist Selenium-basierten Ansätzen meiner Ansicht nach vorzuziehen.

Im Bereich Frontend-Test habe ich in letzter Zeit gute Erfahrungen mit Cucumber.js und einem BDD-basierten Ansatz gemacht. Dort stand für mich aber bisher weniger der End-to-End-Test-Aspekt im Fokus, sondern war eher das natürlichsprachliche Beschreiben der clientseitigen Logik im Vordergrund. Bei einer komplexen clientseitigen Fachlichkeit und einem Offline-First-Ansatz funktionierte dies exzellent. Das Test-Setup wurde selten über die UI, sondern eher über den State der Anwendung gemacht. Das Testen des Resultats dann oft über die UI. Der Zugriff auf UI-Elemente erfolgte dabei über DOM-API (jsdom) bzw. Enzyme. Es gab bei diesem Vorgehen wenig Probleme beim Zugriff auf UI-Elemente (wie man es doch oft von Selenium kennt) und war ausreichend schnell.

Allerdings fühlte sich der Zugriff über die DOM-API doch manchmal etwas low-levellig an und als ich Cypress.io und die entsprechende Cucumber-Erweiterung gesehen habe, war ich doch neugierig, ob man mit diesem Toolset dieses Vorgehen nicht noch verfeinern kann.

Die Beispielanwendung

Für diesen Zweck habe ich eine sehr kleine Beispielanwendung geschrieben, in der ein Händler von Kaffeebohnen diese verwalten kann.
Anwendung Kaffeebohnen Screenshot

Es ist eine React-Anwendung (basierend auf Create-React-App), die kein Backend hat. Sie enthält aber clientseitig eine (noch) recht einfache Logik um Preise zu berechnen, die aber schon Margen und Rabatte berücksichtigt. Dies simuliert uns den Offline-First-Fall. Die Sourcen der Anwendung findet man hier.

Aufsetzen von Cypress.io

Ein yarn add cypress installiert uns wie gewohnt Cypress. Auch wie gewohnt ergänze ich Skripte in der package.json

"scripts": {
  ...
 "cypress:open": "cypress open",
 "cypress:run": "cypress run",
 ...
}

Der erste Aufruf von Cypress über yarn cypress open initialisiert und öffnet Cypress. Ich verwende yarn in diesem Beispiel, npm funktioniert hier aber genauso gut.
Die Initialisierung erzeugt aber auch die Cypress-Ordnerstrukur.

cypress
├── fixtures
├── integration
├── plugins
├── screenshots
├── support
└── videos

Aufsetzen des cypress-cucumber-preprocessor

Soweit so bekannt. Als nächstes installieren wir die Cucumber-Erweiterung für Cypress – cypress-cucumber-preprocessor – um Cucumber-Tests mit Cypress verwenden zu können. Dieser ist als Cypress Preprocessor implementiert und wandelt unsere Cucumber-Tests vor der Testausführung in Cypress-Tests um.
yarn add cypress-cucumber-preprocessor
Dann registrieren wir den Preprozessor unter cypress/plugins/index.js

const cucumber = require('cypress-cucumber-preprocessor').default
module.exports = (on, config) => {
  on('file:preprocessor', cucumber())
}

Mehr ist an Konfiguration zunächst mal nicht nötig.

Ein Cucumber-Test

Dann können wir auch schon loslegen und unser erstes Cucumber-Feature schreiben. Die Features legen wir unter cypress/integration/ ab.

#language: de
Funktionalität: Rabatt für eine Bohnenart berechnen
 Als Bohnenverkäufer möchte ich einen Rabatt gewähren können:

 Grundlage:
   Angenommen, die Anwendung ist geöffnet:

 Szenario: Ein neuer Rabatt soll gewährt werden
   Angenommen, es gibt folgende Bohnenarten in der Anwendung:
     | Id | Bohne     | Einkaufspreis in Euro | Marge in Prozent | Verkaufspreis in Euro | Rabatt |
     | 1  | Äthiopien | 10.00                 | 30               | 13.00                 | 0.00   |
   Wenn der Bohnenverkäufer einen Rabatt von "10" Prozent gewährt
   Dann ist der Rabatt von "10" Prozent in der Anwendung sichtbar
   Und ist der Verkaufspreis mit Rabatt '11.70' Euro
   Und ist der Verkaufspreis '13.00' Euro

Das Feature an sich ist wahrscheinlich schon so verständlich. Jemand mit Cucumber-Vorwissen wird auch nicht überrascht werden.
Das Feature ist so natürlich noch nicht lauffähig, sondern es bedarf noch etwas Code zur Implementierung der einzelnen Schritte: die Step Definitions.
Schauen wir uns die Schritte mal im Einzelnen an. Zunächst sagen wir, dass wir Cucumber gerne auf Deutsch verwenden möchten #language: de. Auch die Funktionalität bedarf keiner Implementierung, da diese rein informativ ist.
Spannender wird es dann bei der Grundlage. Dies ist ein Schritt, der vor jedem Szenario einmal ausgeführt werden soll. Hier sagen wir, dass die Anwendung geöffnet sein soll, als Grundlage für unsere Szenarien. So müssen wir das nicht in jedem Szenario wieder bauen. Dies ist ein Schritt, der wahrscheinlich von vielen Features gebraucht wird, und wir sehen diesen als Common Code an.

Wir legen also unter cypress/integration/common/ eine Datei mit dem Namen global.js ab. Der Ort ist ein Default, ist aber konfigurierbar. Wie die Auflösung von Steps zu Implementierung aussieht, werde ich später noch etwas näher erläutern. Sowohl der Pfad zu den Step Definitions als auch der Pfad zum Common-Code ist natürlich konfigurierbar.

import {Given as Angenommen} from 'cypress-cucumber-preprocessor/steps';
 
export function oeffneAnwendung() {
   cy.visit('/');
}
 
Angenommen('die Anwendung ist geöffnet', function () {
   oeffneAnwendung();
});

Als erstes wird also ein cy.visit(‘/’) aufgerufen. Dies kennt man wahrscheinlich aus seinen Cypress-Tests.

Schauen wir uns dann das Szenario an. Auch hier brauchen wir keine Implementierung. Dies hat wieder beschreibende Funktion. Im ersten Step des Szenarios setzen wir Testdaten. Ein Arrange-Schritt, wie man es z.B. aus JUNit- oder Jest-Tests kennt.

 Angenommen es gibt folgende Bohnenarten in der Anwendung:
     | Id | Bohne     | Einkaufspreis in Euro | Marge in Prozent | Verkaufspreis in Euro | Rabatt |
     | 1  | Äthiopien | 10.00                 | 30               | 13.00                 | 0.00   |

Wie ich in der Einleitung beschrieben habe, möchte ich dieses Aufbauen der Testdaten nicht über die UI machen. Hier habe ich mich für den Weg entschieden, eine Redux Action zu nutzen, um Daten in das System zu laden.
Auch diesen Schritt habe ich in die global.js gelegt, da ich ihn in vielen Fällen brauchen werde:

Angenommen('es gibt folgende Bohnenarten in der Anwendung', function (dataTable) {
   for (const row of dataTable.hashes()) {
       const id = row['Id'];
       const art = row['Bohne'];
       const ekp = row['Einkaufspreis in Euro'];
       const vkp = row['Verkaufspreis in Euro'];
       const marge = row['Marge in Prozent'];
       cy
           .window()
           .its('store')
           .invoke('dispatch', updateData({id, art, ekp, vkp, marge}));
   }
});

Das erste was man hier sieht, ist die DataTable API, die uns hier aber nicht weiter interessiert. Wir lesen Zeile für Zeile unsere Tabelle aus dem Feature ein und lösen eine Redux Action aus.
Hierfür ist es nötig, dass Cypress Zugriff auf den Redux-Store hat. Ich habe mich hier dazu entschieden, den Redux-Store an das window-Objekt zu kleben, und kann dann ein (Redux-) Dispatch auslösen.
Im nächsten Schritt wollen wir einen neuen Rabatt setzen.

Wenn der Bohnenverkäufer einen Rabatt von "10" Prozent gewährt

Dies soll über die UI passieren. Da kann Cypress nun wirklich seine Stärken ausspielen: Es ist zunächst mal eine Funktionalität, die nur für das Rabatt-Feature interessant ist. Wir legen eine Datei rabatt.js unter cypress/integration/rabatt an.

Wenn('der Bohnenverkäufer einen Rabatt von {string} Prozent gewährt', function (rabatt) {
   cy.get('#rabatt').type(`{selectall}{backspace}${rabatt}`);
});

Cucumber stellt uns hier den im Feature definierten Rabatt zur Verfügung und wir tippen diesen Rabatt ein (und löschen eine mögliche vorherige Eingabe). Der Einfachheit halber benutze ich hier die ID des Feldes.

Nun müssen wir noch überprüfen, ob die Eingabe auch geklappt hat und die Anwendung richtig rechnet.

   Dann ist der Rabatt von "10" Prozent in der Anwendung sichtbar.
   Und ist der Verkaufspreis mit Rabatt '11.70' Euro.

Auch in der rabatt.js hinterlegen wir:

Dann('ist der Rabatt von {string} Prozent in der Anwendung sichtbar', function (rabatt) {
   cy.get('#rabatt').should('have.value', rabatt);
});
Dann('ist der Verkaufspreis mit Rabatt {string} Euro', function (vkpRabatt) {
   cy.get('#vkpRabatt').should('have.value', vkpRabatt);
});

Das wars auch schon!

Wo liegen die Step-Definitionen?

Wie oben schon angedeutet, bringt der cypress-cucumber-preprocessor im Default das Verhalten mit, dass die Step-Definition zu einem Feature in einem gleichnamigen Verzeichnis zu liegen hat und das Feature auch nur auf diese Step-Definitionen zurückgreift (und zusätzlich auf solche, die ich im common-Verzeichnis abgelegt habe). Dies ist zunächst einmal ungewöhnlich, wenn man schon mit Cucumber.js gearbeitet hat. Dort hat man im Default diese Einschränkung nicht.

Das Fehlen dieser Einschränkung macht es viel einfacher, eine Step-Bibliothek aufzubauen, da es einfacher ist, Steps wiederzuverwenden. Problematisch wird es natürlich, falls Duplikate auftreten und/oder ein Step Zoo entsteht. Die Maintainer des cypress-cucumber-preprocessor geben auch noch Performance-Gründe an.
Wie dem auch sei, auch dieses Verhalten lässt sich konfigurieren.
In der package.json:

"cypress-cucumber-preprocessor": {
  "nonGlobalStepDefinitions": false
}

Testausführung

Aber führen wir doch unsere Tests aus. Wir starten unseren Server mit yarn start. Natürlich spricht auch nichts gegen ein eingebautes start-server-Skript, wie es vielleicht schon von Cypress in einer CI-Umgebung bekannt ist. Ist hier aber nicht unbedingt nötig.
Dann können wir unsere Tests wie gehabt entweder headless mit yarn cypress:run oder mit sichtbarer Testausführung mit yarn cypress:open ausführen.

Wenn wir Cypress über yarn cypress:open öffnen, sehen wir, dass sich die Cucumber-Tests in der Ausführung gar nicht so sehr von den gewohnten Cypress-Tests unterscheiden:

Cypress-Test-Runner, Cypress.io, Electron

Im Headless-Mode sieht dies ähnlich aus:

$ cypress run
 
====================================================================================================
 
  (Run Starting)
 
  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
  │ Cypress:    3.2.0                                                                              │
  │ Browser:    Electron 59 (headless)                                                             │
  │ Specs:      1 found (rabatt.feature)                                                           │
  └────────────────────────────────────────────────────────────────────────────────────────────────┘
 
────────────────────────────────────────────────────────────────────────────────────────────────────
 
  Running: rabatt.feature...                                                               (1 of 1)
 
 
  Rabatt für eine Bohnenart berechnen
    ✓ Ein neuer Rabatt soll gewährt werden (1009ms)
 
  1 passing (1s)
 
 
  (Results)
 
  ┌──────────────────────────────┐
  │ Tests:        1              │
  │ Passing:      1              │
  │ Failing:      0              │
  │ Pending:      0              │
  │ Skipped:      0              │
  │ Screenshots:  0              │
  │ Video:        true           │
  │ Duration:     1 second       │
  │ Spec Ran:     rabatt.feature │
  └──────────────────────────────┘
 
  (Video)
 
  - Started processing:   Compressing to 32 CRF
  - Finished processing:  /Users/holgergp/development/sideprojects/cypress.io/cypressioCucumberExample/cypress/videos/rabatt.feature.mp4 (0 seconds)
====================================================================================================
 
  (Run Finished)
      Spec                                                Tests  Passing  Failing  Pending  Skipped
  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
  │ ✔ rabatt.feature                            00:01        1        1        -        -        - │
  └────────────────────────────────────────────────────────────────────────────────────────────────┘
    All specs passed!                           00:01        1        1        -        -        -
 
✨  Done in 16.85s.

Wir können die Cucumber-Tests also genauso ausführen wie wir es gewohnt sind. Super!

Falls man irgendwann eine größere Cucumber-Testsuite hat, kann die Laufzeit aller Tests bei der Entwicklung vielleicht hinderlich sein. Hier bietet schon Cucumber.js die Möglichkeit, Tests zu taggen, um z.B. nur nur Tests einer bestimmten Fachlichkeit ausführen zu können.
Hier kommt der cypress-cucumber-preprocessor mit einem netten Feature: Er liefert den Tag @focus mit, der Cypress nur die aktuell betrachteten Tests ausführen lässt.

@focus
Szenario: Ein weiterer Rabatt soll gewährt werden
 Angenommen, es gibt folgende Bohnenarten in der Anwendung:
   | Id | Bohne     | Einkaufspreis in Euro | Marge in Prozent | Verkaufspreis in Euro | Rabatt |
   | 1  | Äthiopien | 10.00                 | 30               | 13.00                 | 0.00   |
 Wenn der Bohnenverkäufer einen Rabatt von "20" Prozent gewährt
 Dann ist der Rabatt von "20" Prozent in der Anwendung sichtbar

Fehler

Erfolgreiche Tests sind nun das eine. Tests sollen mir aber auch helfen ein Fehlverhalten aufzudecken. Sie sollen also auch mal fehlschlagen, dann aber auch sinnvolle Fehlermeldungen liefern, so dass ich den Fehler schnell beheben kann.

Wenn wir mal hingehen und einen der Tests fehlerhaft anpassen:

Szenario: Ein weiterer Rabatt soll gewährt werden
 Angenommen es gibt folgende Bohnenarten in der Anwendung:
   | Id | Bohne     | Einkaufspreis in Euro | Marge in Prozent | Verkaufspreis in Euro | Rabatt |
   | 1  | Äthiopien | 10.00                 | 30               | 13.00                 | 0.00   |
 Wenn der Bohnenverkäufer einen Rabatt von "20" Prozent gewährt
 Dann ist der Rabatt von "30" Prozent in der Anwendung sichtbar

Cypress-Test-Runner, Cypress.io, Fehlermeldung

In der Cypress-Umgebung sehen wir diesen Fehler recht schön und es ist ersichtlich, was ich anpassen muss. Im Headless-Mode ist dieser Fehler etwas technischer, woran man sich vielleicht erst gewöhnen muss. Dies stellt aber aus meiner Sicht kein größeres Problem dar.

 Rabatt für eine Bohnenart berechnen
    1) Ein weiterer Rabatt soll gewährt werden:
 
  0 passing (5s)
  1 failing
 
  1) Rabatt für eine Bohnenart berechnen Ein weiterer Rabatt soll gewährt werden:
     CypressError: Timed out retrying: expected '<input#rabatt>' to have value '30', but the value was '20'
      at Object.cypressErr (http://localhost:3000/__cypress/runner/cypress_runner.js:65727:11)
      at Object.throwErr (http://localhost:3000/__cypress/runner/cypress_runner.js:65692:18)
      at Object.throwErrByPath (http://localhost:3000/__cypress/runner/cypress_runner.js:65719:17)
      at retry (http://localhost:3000/__cypress/runner/cypress_runner.js:59237:16)
      at http://localhost:3000/__cypress/runner/cypress_runner.js:51312:18
      at tryCatcher (http://localhost:3000/__cypress/runner/cypress_runner.js:131273:23)
      at Promise._settlePromiseFromHandler (http://localhost:3000/__cypress/runner/cypress_runner.js:129291:31)
      at Promise._settlePromise (http://localhost:3000/__cypress/runner/cypress_runner.js:129348:18)
      at Promise._settlePromise0 (http://localhost:3000/__cypress/runner/cypress_runner.js:129393:10)
      at Promise._settlePromises (http://localhost:3000/__cypress/runner/cypress_runner.js:129468:18)
      at Async._drainQueue (http://localhost:3000/__cypress/runner/cypress_runner.js:126197:16)
      at Async._drainQueues (http://localhost:3000/__cypress/runner/cypress_runner.js:126207:10)
      at Async.drainQueues (http://localhost:3000/__cypress/runner/cypress_runner.js:126081:14)
      at <anonymous>

Fazit

Der cypress-cucumber-preprocessor macht mir echt Spaß. Die Integration von Cucumber in die Cypress.io-Umgebung ist gelungen und ist aus meiner Sicht reif, auch produktiv eingesetzt zu werden. Der elegante Weg, wie man mit Cypress auf UI-Elemente zugreift, hilft auch, Cucumber Step-Definitonen ausdrucksstärker zu gestalten.

Falls man Cucumber.js schon stand-alone kennt, muss man sich an der ein oder anderen Stelle vielleicht umgewöhnen. Das Handhaben der Step-Definitionen und das Bereitstellen des Redux-Stores wären hier als Beispiel genannt.

Ich habe Cucumber.js auch bisher weniger im Kontext E2E eingesetzt, sondern eher als Werkzeug um komplexe clientseitige Anwendungen zu testen, wobei der Server-Part bewusst außen vor gelassen wurde. Dies macht auch nach wie vor für mich Sinn, heißt im Umkehrschluss: Nur für Cucumber würde ich Cypress.io nicht ins Boot holen.

Falls ich allerdings Cypress.io sowieso schon an Bord haben und/oder ich E2E-Tests formulieren möchte, so ist dies ein exzellenter Weg, auch BDD-Tests zu verwenden.

Viel Spaß mit Cypress.io und Cucumber!

Holger Grosse-Plankermann

Holger Grosse-Plankermann ist seit 2015 für die codecentric als Senior Consultant und Senior Developer tätig. Gerne entwickelt und entwirft er gut testbare Software und bewegt sich dabei sowohl im Backend als auch im Frontend. Holger nutzt dabei oft den Java EE/Spring Stack und ist zudem ein Verfechter von JavaScript- und Web-Technologien.
Er hält immer Ausschau nach innovativen Lösungen für Kundenprobleme. Sein aktuelles Interesse gilt dem Bereich der funktionalen Programmierung, und er bleibt im Bereich JavaScript-Frameworks immer am Ball.

Kommentare

  • Johannes Edmeier

    17. April 2019 von Johannes Edmeier

    Hallo Holger,

    ein unbestechliches Feature hast du vergessen:
    Es werden Screenshots und Videos aufgezeichnet und das ohne aufwändige Konfigurationion – auch im Headless Mode – sogar auf dem Gitlab CI Runner. Wir hängen die als Attachments zu unseren CI Builds an und können dort reingucken wenn was schief lief.

    • Holger Grosse-Plankermann

      17. April 2019 von Holger Grosse-Plankermann

      Hi Johannes,

      ja das hab ich auch in dem Beispiel Projekt verwendet. das klappt auch super über TravisCI!
      Ich hab da mit mir gehadert aber mich entschieden das nicht im Rahmen dieses Posts noch zu erwähnen. Der Artikel ist jetzt schon recht lang :).
      Das ist für mich eher ein Thema für einen Cypress.io-Übersichtsartikel. Da kann man dann noch etwas mehr auf die ganze Dashboard Thematik eingehen.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.