Integration Tests mit Gürkchen – Teil 2

In dieser Artikelserie geht es um die lesbare Beschreibung von Integration-Tests mit Gherkin. Im ersten Teil haben wir Behat eingerichtet und einen ersten Test in Gherkin geschrieben, sowie Fixtures eingesammelt. Heute geht es darum, den eigentlichen Test auszuführen.


Commando mit gemocktem Server ausführen

Letzte Woche haben wir begonnen, einen Integrationtest mit Gherkin zu schreiben, und diesen mit Behat auszuführen. Wir haben den ersten Sentence übersetzt, der es ermöglicht, Testdaten für die gemockte Jira-API zu sammeln.

Damit haben wir nun ein Ticket, auf das wir das PullRequestToJira-Command loslassen können. Wir führen uns nochmal das Testszenario vor Augen:

Given I have an issue "PVKZU-123" with the summary "Change background color of all CTA Buttons to unicorn pink"
When I execute the PullRequestToJira command
Then I expect the issue "PVKZU-123" to have the field "customfield_11600" with the content "---"

Wir legen also einen neuen CommandFeatureContext an (PhpStorm macht das auf <Alt>-Enter für uns), und registrieren diesen in der behat.yml.

default:
  autoload:
    '': src/
  suites:
    default:
      path: features
      contexts:
        - \Contexts\JiraFeatureContext
        - \Contexts\CommandFeatureContext

Die Implementierung beginnt damit, dass wir uns erst mal die Fixtures holen, also die Daten, gegen die wir testen wollen:

/**
 * Execute the command with mocks
 *
 * @When /^I execute the PullRequestToJira command$/
 * @return void
 */
public function iExecuteThePullRequestToJiraCommand(): void
{
    $issues = JiraIssueContainer::getInstance()->getIssues();

Diese Testdaten muss unsere Jira-Client-Klasse nun zurückgeben, wenn das Command die Liste der Tickets anfragt. Also mocken wir den Client, den wir für die Jira-API geschrieben haben. Ich verwende zum Mocken Prophecy, weil ich das lustiger finde als PhpMock. Aber natürlich kann man hier ein beliebiges Mocking-Framework verwenden.

Hier kommt uns zugute, dass Behat nur erwartet, dass die FeatureContext-Klasse ein Interface implementiert, und keine Ableitung von einer Basisklasse verlangt. Darum können wir neben der Implementierung von Context auch von TestCase ableiten, und haben somit alle Funktionalität von PhpUnit und Prophecy verfügbar. Heureka!

class CommandFeatureContext extends \PHPUnit\Framework\TestCase implements Context

Dieser Trick ist der eigentliche Knackpunkt, der das Verbinden der beiden Technologien so einfach und elegant ermöglicht. Denn nun kann ich, obwohl ich mich eigentlich im Kontext von Behat befinde einfach Mocken und Asserten soviel ich will!


//region Prepare Jira mock
$jira = $this->prophesize(Jira::class);
$jira->searchIssues('project=pvkzu AND sprint in openSprints()')
	 ->shouldBeCalled()
	 ->willReturn($issues);
/** @var string $stringArgument */
$stringArgument = Argument::type('string');
$jira->updateFieldInIssue($stringArgument, $prStateField, $stringArgument, 'FreeTextField')
	 ->shouldBeCalled()
	 ->will(
		 function ($args) use ($issues) {
			 $issues->issues[$args[0]]->fields->{$args[1]} = $args[2];
		 }
	 );
/** @var Jira $jiraMock */
$jiraMock = $jira->reveal();
//endregion

Ich habe das in eine Region gepackt, weil es dann beim Öffnen der Methode in PhpStorm erstmal eingeklappt ist und die Methode lesbarer wird. Mehr zu Regions in einem eigenen Artikel.

Der JiraClient hat eine Such-Methode, der eine JQL-Abfrage übergeben werden kann. Das Command wird diese aufrufen, um die Tickets des aktuellen Sprints abzufragen. Wir mocken sie, und geben stattdessen unser Ticket aus den Fixtures zurück.

Außerdem sollte das Command das CustomField aktualisieren, also erwarten wir, dass die “updateFieldInIssue”-Methode aufgerufen wird. Hier aktualisieren wir einfach unsere Test-Issues.

Man sieht hier schon gut, warum das Schreiben von Tests oft verwirrend ist: Im Gegensatz zu der Formulierung in Gherkin mit der klaren Reihenfolge “Wenn ich diesen Zustand habe, und FooBar mache, dann wird etwas passieren”, ist bei Tests die Reihenfolge oft durcheinander, und die zu testende Annahme (“Assertion”) oft über mehrere Stellen verstreut. “shouldBeCalled” ist bereits Teil der Assertion, die eigentlich erst im dritten Sentence stattfinden sollte.

Hierfür habe ich keine Lösung, über Vorschläge und Diskussion hierzu freue ich mich in den Kommentaren.

So, nun haben wir die Jira-API gemockt. Der Command wird natürlich auch noch die PullRequests aus Bitbucket lesen, somit müssen wir den Client hierfür auch noch mocken:


//region Prepare Bitbucket mock
$bitbucket = $this->prophesize(Bitbucket::class);
$bitbucket->getPullRequests('VVZZV', 'zzv-core', ['OPEN', 'MERGED'], PHP_INT_MAX, true)
          ->shouldBeCalled()
          ->willReturn($prs->prs);
/** @var Bitbucket $bitbucketMock */
$bitbucketMock = $bitbucket->reveal();
//endregion

Also: Wenn das Command nach den Tickets in unserem Core-Repository anfragt, dann sollen die PullRequests aus den Fixtures benutzt werden. Auch hierfür habe ich eine triviale Singleton-Klasse angelegt, die als Container für das PullRequests-Objekt dient. Das werde ich später vermutlich noch ein wenig erweitern, dass PullRequests aus mehreren Repositories verwaltet werden können, aber nach dem KISS-Prinzip löse ich das dann, wenn ich es benötige.

Nun haben wir alles gesammelt, und können endlich das PullRequestsToJira-Command aufrufen. Es handelt sich um ein Symfony-Console-Command, also erzeugen wir noch schnell Konsolenein- und -ausgabe, und rufen execute() auf:


$command = new PullRequestsToJiraCommand(
	$prStateField,
	$jiraMock,
	$bitbucketMock,
	new Repositories('test')
);
$command->configure();
$inputOptions = ['--dry-run' => false];
$output       = new BufferedOutput();
$command->execute(
	new ArrayInput($inputOptions, $command->getDefinition()),
	$output
);

Die weiteren Parameter sind Konfiguration. Die Konfiguration kann man im Konstruktor der Klasse initialisieren, einfach vendor/autoload.php laden und anschließend die frameworkspezifische Konfiguration laden (Symfony, Zend, Laravel, …).

Annahmen prüfen – Die Assertion

Nun machen wir uns daran, zu prüfen, ob der Command auch das Richtige gemacht hat. Wir implementieren den dritten Sentence:

Then I expect the issue "PVKZU-123" to have the field "customfield_11600" with the content "---"

Das ist jetzt eigentlich trivial, die Implementierung wird im JiraContext angelegt, und es wird geprüft, ob das customField wirklich nach der Ausführung den gewünschten Inhalt hat.


/**
 * Check whether the field has the expected value
 *
 * @Then /^I expect the issue "(PVKZU\-\d+)" to have the field "([^"]+)" with the content "([^"]+)"$/
 * @param string $issueKey        The key for the issue
 * @param string $field           The field to check
 * @param string $expectedContent The expected content
 *
 * @return void
 */
public function iExpectTheIssueToHaveTheFieldWithTheContent($issueKey, $field, $expectedContent): void
{
    $issues        = $this->container->getIssues();
    $issue         = $issues->issues[$issueKey];
    $actualContent = $issue->fields->{$field};

    $this->assertEquals($expectedContent, $actualContent);
}

Damit assertEquals zur Verfügung steht, leiten wir auch diesen Context von TestCase ab.

Die Ausführung

Nun haben wir ganz viel entwickelt, jetzt müssen wir mal probieren, ob das auch alles so klappt. Dazu begeben wir uns in das Verzeichnis test/integration, und starten Behat:

> cd test/integration && ../../vendor/behat/behat/bin/behat --colors

This feeling when something just works!

Finale

Natürlich ist das jetzt nur der einfachste Test, und sozusagen der “Proof-Of-Concept”. Die weiteren Schritte sind aber, wenn man so weit gekommen ist, trivial. Das nächste Szenario ist schnell implementiert:

Wir testen nun erneut mit einem Ticket, aber nun gibt es einen PullRequest, allerdings noch ohne Reviewer. In diesem Fall soll im Ticket das Kürzel des Repository erscheinen, aber mit zwei Ausrufezeichen als Warn-Zeichen dafür, dass vergessen wurde Reviewer zu definieren.

Tests nutzen

Bevor es weiter geht, sind zwei weitere Schritte wichtig, um die Tests nicht nur einmal ausgeführt zu haben und sich darüber zu freuen, dass es funktioniert.
Einerseits muss es einfach sein, die Tests auszuführen, und idealerweise sollte das überall gleich funktionieren. Darum bauen wir die Ausführung der Tests in composer ein:

Somit werden bei composer test sowohl Unit-Tests als auch Integration-Tests ausgeführt.

Andererseits muss die Testausführung natürlich auch nicht nur manuell möglich sein, sondern auch automatisch regelmäßig passieren. Je nach Größe des Repository und Ausführzeit der Tests kann man das im Commit-Hook schon machen (bei größeren Projekten geht das recht bald nur noch mit Unittests). Spätestens im Buildprozess muss das aber passieren, egal ob man hierfür Bamboo, Jenkins oder einen anderen CI-Server verwendet.

Dadurch, dass wir alle Abhängigkeiten gemockt haben, laufen die Tests problemlos auch ohne große Konfigurationsorgien und SSH-Tunnel auf Testserver, man muss nach composer install in seinen Buildsteps nur ein composer test als weiteren Step einfügen.

Zusammenfassung

Vor allem bei komplexen Anforderungen sind Integrationstests mit Gherkin sehr viel einfacher zu lesen. DataProvider können durch Scenario outlines mit DataTables wesentlich lesbarer geschrieben werden. Somit dienen die Beschreibungen in den Features/Szenarien auch als Dokumentation der Anwendung, die jedoch im Gegensatz zu Confluence-Artikeln, Wikis oder sonstiger Dokumentation nie veraltet.

Zudem ist es durch die FeatureKontexte möglich, Testcode wieder zu verwenden. Gerade für Model-Klassen oder API-Clients ist es nicht erforderlich, immer wieder ähnliche Konstrukte neu zu bauen.

Durch die Trennung der Beschreibung des Tests von der Implementierung wird Testcode in kleinere, verständlichere Teile gespalten. Der Nachteil ergibt sich direkt daraus: Das Debuggen von Tests wird schwieriger, weil mehr Indirektion und Abhängigkeiten bestehen. Das Ändern eines FeatureContext um einen Test zu reparieren kann andere Tests beeinflussen.

Sicherlich ist das Konstrukt nicht der Weisheit letzter Schluss, aber ein großer Schritt nach vorne gegenüber reinen PhpUnit-Integrationstests bei denen oft nur der Methodenname ansatzweise darüber Aufschluss gibt, was der Test überhaupt abtestet. Wir haben in unserem Alt-Code dutzende, wenn nicht hunderte von Tests, bei denen kaum jemand noch weiß, was sie wirklich testen. Natürlich kann man das alles durch Codeanalyse oder Debugging heraus finden, aber Sicherheit über die Robustheit des Codes gibt mir das nicht.

Der Einwand, der sich Behat-bewanderten Anwendern sofort aufdrängt, ist die Laufzeit. Klassische Behattests, die per Browser oder Emulator oder gar über Browserstack Webanwendungen testen sind naturgemäß langsam. Aufgrund meiner Erfahrungen kann ich diesem jedoch entgegentreten: Die lange Laufzeit liegt hierbei daran, dass es sich um End-To-End-Tests handelt, die viele langsame Komponenten wie Netzwerk, Browser oder gar die Aktivierung kompletter virtuelle Maschinen beinhalten.

Der Overhead durch die Übersetzung von Gherkin in ausführbaren Code ist demgegenüber komplett vernachlässigbar, und durch die Vorkompilierung von Behat wird das auch nochmals beschleunigt.

Somit spricht aus meiner Sicht wenig dagegen, aber viel dafür, die Business-Logik von Anwendungen zukünftig so zu testen, dass die Tests dauerhaft wertvoll sind, und auch noch in 2 Jahren verstanden und gewartet werden können.

Ich würde mich über eine rege Diskussion freuen, was Ihr darüber denkt, und wo Ihr noch Verbesserungsmöglichkeiten seht!

About the author

People Enabler at CHECK24