Was wir erreichen wollen
Dieser Artikel zeigt, wie wir schnell und effizient das Verhalten einer Route durch in YML geschriebene Tests definieren können.
Entwickelt man wie wir nach dem Test-Driven-Development Konzept, sind eine Menge von Tests nötig, um das Verhalten der entwickelten Applikation effizient zu definieren.
Ein Test für einen Endpunkt, der uns ein Kochrezept liefern soll, könnte wie folgt aussehen. (Beispiel mit PHPUnit im Symfony-Framework)
public function testReadRecipe()
{
$client = static::createClient();
$client->request('GET', '/recipes/1');
$response = $client->getResponse();
// assert StatusCode and Headers
$this->assertEquals(200, $response->getStatusCode());
$this->assertTrue(
$response->headers->contains('Content-Type','application/json')
);
// assert Response-Content
$this->assertEquals($response->getContent(), json_encode([
'id' => 1,
'title' => 'Leckere Gemüsesuppe'
]));
}
415 Zeichen - Beispiel für einen Test mit Symfony3
Der gleiche Test sähe in unserem YML-Format folgendermaßen aus:
testReadRecipe:
request:
method: get
path: /recipes/1
response:
status: 200
headers: { Content-Type: application/json }
content: { id: 1, title: 'Leckere Gemüsesuppe' }
147 Zeichen - Der gleiche Test wie vorher in YML
Es fällt sofort auf, dass die Testdefinition deutlich kürzer ist – wir haben fast zwei Drittel der Zeichen eingespart. Auch die Leserlichkeit hat sich erheblich verbessert.
Konfiguration von Symfony für YML-Tests
Zunächst Erstellen wir eine Basis-Klasse für unsere Tests.
abstract class BaseJsonApiTestCase extends WebTestCase
{
/**
* @var Client
*/
protected $client;
/**
* @var Finder
*/
private $testFiles;
/**
* @var EntityManager
*/
protected $defaultEm;
/**
* @var Container
*/
private $container;
protected function createUnauthenticatedClient() {
$this->client = static::createClient(array(), array('HTTP_ACCEPT' => self::CONTENT_TYPE_JSON));
}
/**
* Set up the test-client
*/
public function setUp()
{
$this->createUnauthenticatedClient();
$this->testFiles = new Finder();
$this->container = static::$kernel->getContainer();
$this->defaultEm = $this->container->get('doctrine.orm.default_entity_manager');
}
/**
* Assert the status code.
*/
private function assertResponseCode(Response $actualResponse, $expectedResponse, $filename)
{
$actualStatusCode = $actualResponse->getStatusCode();
$expectedStatusCode = $expectedResponse['status'];
self::assertEquals($expectedStatusCode, $actualStatusCode, $filename . " - StatusCodes did not match. ReponseContent:\n" . $actualResponse->getContent());
}
/**
* Assert the header.
*/
private function assertHeader(Response $actualResponse, $expectedResponse, $filename)
{
$actualHeaders = $actualResponse->headers;
foreach ($expectedResponse['headers'] as $key => $value) {
$errorMessage = $filename . " - Header\n"
. "Expected " . $key . " to contain >>" . $value
. "<< but got >>" . $actualHeaders->get($key) . "<< Response-Content:\n"
. $actualResponse->getContent();
self::assertTrue($actualHeaders->contains($key, $value), $errorMessage);
}
}
/**
* Assert the response-content.
*/
private function assertResponseContent($actualResponse, $expectedResponse, $filename)
{
$actualContent = $actualResponse->getContent();
$expectedContent = json_encode($expectedResponse['content']);
$matcher = (new SimpleFactory())->createMatcher();
$result = $matcher->match($actualContent, $expectedContent);
if (!$result) {
$diff = new \Diff(explode(PHP_EOL, $expectedContent), explode(PHP_EOL, $actualContent), []);
self::fail($filename . PHP_EOL . $matcher->getError() . PHP_EOL . $diff->render(new \Diff_Renderer_Text_Unified()));
}
}
/**
* Executes the test.
*/
protected function baseTest() {
// Get all the JSON-files in the current directory
$dir = dirname((new \ReflectionClass(get_class($this)))->getFileName());
$this->testFiles->files()->in($dir)->name('*.yml');
// Execute the tests configured in each of the configuration-files found
foreach ($this->testFiles as $testFile) {
$filename = $testFile->getBasename('.json');
$testConfigurations = Yaml::parse($testFile->getContents());
// empty yml-files return null
if(null === $testConfigurations) {
$testConfigurations = [];
}
// Execute the tests configured in each of the configuration-files found
foreach ($this->testFiles as $testFile) {
$filename = $testFile->getBasename('.json');
$testConfigurations = Yaml::parse($testFile->getContents());
// Replace null with [] for empty yml-files
if(null === $testConfigurations) {
$testConfigurations = [];
}
// Execute the tests given by the configurations
foreach ($testConfigurations as $testname => $testConfiguration) {
$request = $testConfiguration['request'];
$expectedResponse = $testConfiguration['response'];
$currentTest = $filename . " - " . $testname;
// Replace optional and not defined request-parameters
if(!isset($request['parameters'])) $request['parameters'] = [];
if(!isset($request['files'])) $request['files'] = [];
if(!isset($request['headers'])) $request['headers'] = [];
// Make the api-call
$this->client->request(
$request['method'],
$request['path'],
$request['parameters'],
$request['files'],
$request['headers']
);
// Extract the response
$actualResponse = $this->client->getResponse();
// Assert status-code and, if defined, headers and content
$this->assertResponseCode($actualResponse, $expectedResponse, $currentTest);
if(isset($expectedResponse['headers']))
$this->assertHeader($actualResponse, $expectedResponse, $currentTest);
if(isset($expectedResponse['content']))
$this->assertResponseContent($actualResponse, $expectedResponse, $currentTest);
}
}
}
// Has to be implemented by the inherited tests
abstract public function test();
}
Basis-Klasse für die YML-Tests
In der setUp()-Methode instanziieren wir den TestClient, den FileFinder und EntityManager.
Es folgen drei Methoden, die jeweils den Statuscode, den Header und den Content der erwarteten und tatsächlichen Antwort der API miteinander vergleicht. Als dritten Parameter übergeben wir der Methode den Dateinamen der momentan ausgeführten YML-Definition, um im Fehlerfall den Test identifizieren zu können. Für das Testen des Contents nutzen wir die PHPMatcher-Bibliothek von Coduo. Dies ist insbesondere dann wichtig, wenn man mit dynamischen Daten testet und nur die Struktur aber nicht die echten Werte einer API-Antwort kennt.
Die baseTest()-Methode schließlich lädt die entsprechenden YML-Definitionen und führt die einzelnen Tests aus.
Schreiben eines Tests
Nehmen wir an, wir möchten unseren src/AppBundle/Controller/RecipeController.php
unserer API mit YML-Tests definieren. Zunächst legen wir dazu in unserer Symfony-Installation einen neuen Ordner tests/AppBundle/Controller/RecipeController
an. In diesem Ordner erstellen wir die Klasse RecipeControllerTest.php. Sie erweitert unsere vorher implementiert BasisKlasse.
class RecipeControllerTest extends \Tests\BaseJsonApiTestCase
{
public function test()
{
// Insert code to be executed before the test
parent::baseTest();
// Insert code to be executed after the test
}
}
Testklasse für den RecipeController
Nun können wir alle weiteren YML-Tests für den RecipeController bequem in den Ordner tests/AppBundle/Controller/RecipeController
legen. Zum Beispiel readAction.yml
.
should_return_valid_recipe_1:
request:
method: get
path: /api/v1/recipes/1
response:
status: 200
content: { id: 1, title: "Leckere Gemüsesuppe" }
should_return_valid_recipe_2:
request:
method: get
path: /api/v1/recipes/2
response:
status: 200
content: { id: 1, title: "@string@" }
should_return_not_found_response:
request:
method: get
path: /api/v1/recipes/10
response:
status: 404
content: { error: "Not found." }
readAction.yml zum Testen der readAction()-Methode des RecipeControllers.