Komponententests
Kurz gesagt: Ein Szenario ist für die Beispiele, auf die sich das Geschäft geeinigt hat und die es lesen will. Ein Komponententest ruft den Code direkt auf: zehn Beträge in Millisekunden, ohne Schritte und ohne Gherkin. Beide prüfen eine Regel für verschiedene Leser. Die Szenarien bleiben wenige, und die Komponententests übernehmen die technischen Fälle.
Problem
Fünfzehn Szenarien für eine Grenze
Der Programmierer hat beschlossen, die Lieferung gründlich zu prüfen: 1 Cent, 10 €, 49,49 €, 49,50 €, 49,99 €, 50 €, 50,01 €, 100 €, 250 €, ein negativer Betrag. Er hat alles als Szenarien aufgeschrieben.
Die Inhaberin des Shops hat die Datei mit den Szenarien geöffnet, um die Lieferregel zu sehen. Sie sah fünfzehn fast gleiche Szenarien und schloss die Datei. Die lebende Dokumentation wurde nicht mehr gelesen.
Die Idee
Der Test ruft den Code direkt auf
Aus dem Programmierkurs kennst du Komponententests: eine Methode, die den Code aufruft und das Ergebnis vergleicht. Die Lieferregel braucht weder Gherkin noch Schritte. Der Test ruft die Methode direkt auf:
@ParameterizedTest(name = "feeFor({0}) = {1}")@CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" })void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total));}[Theory][InlineData(4999, 490)][InlineData(5000, 0)][InlineData(1, 490)][InlineData(25000, 0)]public void FeeFor(int total, int fee){ Assert.Equal(fee, Delivery.FeeFor(total));}@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == feeconst cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}Das ist ein parametrisierter Test: eine Prüfung und eine Tabelle von Fällen. Er sieht aus wie ein Szenariogrundriss mit „Beispiele“, aber er ist für den Programmierer. Ein neuer Fall ist eine neue Zeile in der Tabelle.
Die Ablehnung wird mit einem eigenen Test geprüft. Die Regel wirft eine Exception, und der Test prüft, dass sie es tut:
@Test@DisplayName("ein leerer Warenkorb wird abgelehnt")void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0));}[Fact(DisplayName = "ein leerer Warenkorb wird abgelehnt")]public void EmptyCartIsRefused(){ Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0));}def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)test("ein leerer Warenkorb wird abgelehnt", () => { assert.throws(() => deliveryFee(0));});Genauer, für Neugierige
Die Werkzeuge sind die Standardwerkzeuge jeder Sprache: JUnit 5 (parametrisierte Tests leben im Modul junit-jupiter-params), xUnit mit [Theory] und [InlineData], pytest und node:test, in Node eingebaut. Im Programmierkurs nutzten die Python-Tests unittest. pytest ist das verbreitetere: Ein Test ist eine gewöhnliche Funktion mit assert, und parametrize baut die Tabelle.
Die Idee
Welche Regel auf welcher Stufe
Die Regel ist eine. Die Prüfungen liegen auf zwei Stufen, und jede hat ihren eigenen Leser:
- Ein Szenario ist für die Beispiele, auf die sich das Geschäft geeinigt hat: genau 50,00 €, 49,99 €, ein leerer Warenkorb. Ein oder zwei Beispiele pro Regel. Es ist die lebende Dokumentation, und das Geschäft liest sie.
- Ein Komponententest ist für die technischen Fälle: 1 Cent, ein großer Betrag, ein negativer Betrag, Rundung. Sie schützen den Code, aber sie sagen nichts Neues über die Regel.
Die Grenze auf beiden Stufen zu prüfen ist keine Verschwendung. Das Szenario sagt, was vereinbart wurde. Der Komponententest schlägt zuerst fehl, und zwar genau dort, wo der Fehler ist.
Die Regel für die Wahl ist kurz: Würde das Geschäft dieses Beispiel lesen wollen? Wenn ja, ein Szenario. Wenn nicht, ein Komponententest.
Probier es aus
Szenario oder Komponententest?
Die Idee
Der Lauf
Die ganze Testdatei, ausgeführt gegen den richtigen Code. Alles besteht:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;
class DeliveryTest { @ParameterizedTest(name = "feeFor({0}) = {1}") @CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" }) void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total)); }
@Test @DisplayName("ein leerer Warenkorb wird abgelehnt") void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0)); }}OK ein leerer Warenkorb wird abgelehntOK feeFor(1) = 490OK feeFor(25000) = 0OK feeFor(4999) = 490OK feeFor(5000) = 0Tests: 5, bestanden: 5, fehlgeschlagen: 0using Xunit;
namespace Shop;
public class DeliveryTest{ [Theory] [InlineData(4999, 490)] [InlineData(5000, 0)] [InlineData(1, 490)] [InlineData(25000, 0)] public void FeeFor(int total, int fee) { Assert.Equal(fee, Delivery.FeeFor(total)); }
[Fact(DisplayName = "ein leerer Warenkorb wird abgelehnt")] public void EmptyCartIsRefused() { Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0)); }}OK FeeFor(total: 1, fee: 490)OK FeeFor(total: 25000, fee: 0)OK FeeFor(total: 4999, fee: 490)OK FeeFor(total: 5000, fee: 0)OK ein leerer Warenkorb wird abgelehntTests: 5, bestanden: 5, fehlgeschlagen: 0import pytest
from delivery import delivery_fee
@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == fee
def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)OK test_empty_cart_is_refusedOK test_fee_for_total[1-490]OK test_fee_for_total[25000-0]OK test_fee_for_total[4999-490]OK test_fee_for_total[5000-0]Tests: 5, bestanden: 5, fehlgeschlagen: 0import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
const cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}
test("ein leerer Warenkorb wird abgelehnt", () => { assert.throws(() => deliveryFee(0));});OK deliveryFee(1) = 490OK deliveryFee(25000) = 0OK deliveryFee(4999) = 490OK deliveryFee(5000) = 0OK ein leerer Warenkorb wird abgelehntTests: 5, bestanden: 5, fehlgeschlagen: 0Jetzt „vereinfacht“ der Programmierer den Vergleich und rundet den Betrag davor auf ganze Euro. Aus 49,99 € werden 50 €, und die Lieferung wird kostenlos:
package shop;
public class Delivery { public static final int FREE_FROM = 5000; public static final int FEE = 490;
// Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent. public static int feeFor(int total) { if (total <= 0) { throw new IllegalArgumentException("Ein leerer Warenkorb kann nicht bestellt werden"); } return Math.round(total / 100.0) * 100 >= FREE_FROM ? 0 : FEE; }}namespace Shop;
public static class Delivery{ public const int FreeFrom = 5000; public const int Fee = 490;
// Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent. public static int FeeFor(int total) { if (total <= 0) { throw new ArgumentException("Ein leerer Warenkorb kann nicht bestellt werden"); } return Math.Round(total / 100.0) * 100 >= FreeFrom ? 0 : Fee; }}FREE_FROM = 5000FEE = 490
def delivery_fee(total): """Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent.""" if total <= 0: raise ValueError("Ein leerer Warenkorb kann nicht bestellt werden") return 0 if round(total / 100) * 100 >= FREE_FROM else FEEexport const FREE_FROM = 5000;export const FEE = 490;
// Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent.export function deliveryFee(total) { if (total <= 0) { throw new Error("Ein leerer Warenkorb kann nicht bestellt werden"); } return Math.round(total / 100) * 100 >= FREE_FROM ? 0 : FEE;}Dieselben Tests, gegen den neuen Code:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;
class DeliveryTest { @ParameterizedTest(name = "feeFor({0}) = {1}") @CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" }) void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total)); }
@Test @DisplayName("ein leerer Warenkorb wird abgelehnt") void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0)); }}FEHLER feeFor(4999) = 490: expected: <490> but was: <0>OK ein leerer Warenkorb wird abgelehntOK feeFor(1) = 490OK feeFor(25000) = 0OK feeFor(5000) = 0Tests: 5, bestanden: 4, fehlgeschlagen: 1using Xunit;
namespace Shop;
public class DeliveryTest{ [Theory] [InlineData(4999, 490)] [InlineData(5000, 0)] [InlineData(1, 490)] [InlineData(25000, 0)] public void FeeFor(int total, int fee) { Assert.Equal(fee, Delivery.FeeFor(total)); }
[Fact(DisplayName = "ein leerer Warenkorb wird abgelehnt")] public void EmptyCartIsRefused() { Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0)); }}FEHLER FeeFor(total: 4999, fee: 490): Assert.Equal() Failure: Values differ Expected: 490 Actual: 0OK FeeFor(total: 1, fee: 490)OK FeeFor(total: 25000, fee: 0)OK FeeFor(total: 5000, fee: 0)OK ein leerer Warenkorb wird abgelehntTests: 5, bestanden: 4, fehlgeschlagen: 1import pytest
from delivery import delivery_fee
@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == fee
def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)FEHLER test_fee_for_total[4999-490]: assert 0 == 490OK test_empty_cart_is_refusedOK test_fee_for_total[1-490]OK test_fee_for_total[25000-0]OK test_fee_for_total[5000-0]Tests: 5, bestanden: 4, fehlgeschlagen: 1import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
const cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}
test("ein leerer Warenkorb wird abgelehnt", () => { assert.throws(() => deliveryFee(0));});FEHLER deliveryFee(4999) = 490: 0 !== 490OK deliveryFee(1) = 490OK deliveryFee(25000) = 0OK deliveryFee(5000) = 0OK ein leerer Warenkorb wird abgelehntTests: 5, bestanden: 4, fehlgeschlagen: 1Genau ein Fall schlägt fehl: 49,99 €. Der Name des Tests sagt dir den Betrag, und die Meldung sagt dir den erwarteten und den tatsächlichen Wert. Du musst nicht suchen, welcher Schritt welches Szenarios es war.
Genauer, für Neugierige
Die Ausgabe hier ist ein kurzer Bericht, nach Namen geordnet: Die Werkzeuge laufen wirklich, aber in einer Hülle, die nur die Zeile für jeden Test und die Anzahl ausgibt. Die Namen der Tests unterscheiden sich zwischen den Sprachen, weil jedes Werkzeug sie auf seine Art baut: JUnit aus dem name des parametrisierten Tests, xUnit aus dem Methodennamen und den Argumenten, pytest aus dem Funktionsnamen und den Werten, node:test aus dem Text, den du angibst.
Die Idee
Warum nicht alles in Gherkin
Gherkin ist für das Gespräch mit dem Geschäft: die Beispiele, auf die sich die Three Amigos geeinigt haben, so geschrieben, dass sie jeder lesen kann. Als Testwerkzeug ist es teuer:
- Jede neue Zeile in einem Szenario braucht einen Schritt, und Schritte müssen gepflegt werden;
- ein Szenario geht durch mehr Code, ist also langsamer;
- wenn es fehlschlägt, ist die Ursache weiter von der Meldung entfernt.
Und vor allem: Fünfzehn technische Szenarien verstecken die drei, die die Regel aussagen. Lebende Dokumentation bleibt nur lebendig, solange sie jemand liest.
Also sind die Szenarien wenige und sagen die Regel aus. Die Komponententests sind viele und schützen den Code. Das ist auch die Pyramide: eine breite Basis schneller Tests und darüber weniger, langsamere, die dem Kunden näher sind.
Genauer, für Neugierige
Aslak Hellesøy, der Cucumber geschaffen hat, hat mehr als einmal geschrieben, dass Cucumber vor allem ein Werkzeug für eine gemeinsame Sprache und Zusammenarbeit ist und die Tests ein Nebenprodukt sind. Wenn die Szenarien nicht gemeinsam mit dem Geschäft geschrieben und gelesen werden, bleibt ein teurer Weg, Tests zu schreiben.
Übung
Die zwei Stufen
1. Welcher Test schützt was. Die Fragen gehen um die Szenarien aus der vorigen Lektion und die Tests aus dieser.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Teststufen · Testen im Lebenszyklus · Warum und was automatisieren · Vom Szenario zu den Schritten
- Geht weiter in: API und Benutzeroberfläche · Tests in der CI · Gutscheincodes: von der Anfrage bis zur Freigabe
- Aufgaben zum Thema: Automatisierung