Digitalagentur für Business Software aus Kiel

Test-Driven-Development (TDD) von Symfony 3 Json-API’s mit Tests im YML-Format

Veröffentlicht von Jannik Clausen am 22.06.2017

Jannik Clausen

Das Auslagern von Funktionalen Tests in YML-Definition spart Zeit und verbessert die Leserlichkeit. Wir zeigen, wie man Symfony 3-API‘s effizient durch YML-Tests testen kann.

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.

© 2017, Braune Digital GmbH, Niemannsweg 69, 24105 Kiel, +49 (0) 431 55 68 63 59