Testing and Specifying the API

Now that you have a functional API, it might be interesting to write some tests to ensure your API have no potential bugs. A set of useful tools to specify and test your API are easily installable in the API Platform distribution. We recommend you and we will focus on two tools:

  • Alice, an expressive fixtures generator to write data fixtures, and its Symfony integration, AliceBundle;
  • PHPUnit, a testing framework to cover your classes with unit tests and to write functional tests thanks to its Symfony integration, PHPUnit Bridge.

Official Symfony recipes are provided for both tools.

Creating Data Fixtures

Before creating your functional tests, you will need a dataset to pre-populate your API and be able to test it.

First, install Alice and AliceBundle:

$ docker-compose exec php composer require --dev alice

Thanks to Symfony Flex, AliceBundle is ready to use and you can place your data fixtures files in a directory named fixtures/.

Then, create some fixtures for the bookstore API you created in the tutorial:

# api/fixtures/book.yaml

App\Entity\Book:
    book_{1..10}:
        isbn: <isbn13()>
        title: <sentence(4)>
        description: <text()>
        author: <name()>
        publicationDate: <dateTime()>
# api/fixtures/review.yaml

App\Entity\Review:
    review_{1..20}:
        rating: <numberBetween(0, 5)>
        body: <text()>
        author: <name()>
        publicationDate: <dateTime()>
        book: '@book_*'

You can now load your fixtures in the database with the following command:

$ docker-compose exec php bin/console hautelook:fixtures:load

To learn more about fixtures, take a look at the documentation of Alice and AliceBundle.

Writing Functional Tests

Now that you have some data fixtures for your API, you are ready to write functional tests with PHPUnit.

Install the Symfony test pack which includes PHPUnit Bridge:

$ docker-compose exec php composer require --dev test-pack

Your API is ready to be functionally tested. Create your test classes under the tests/ directory.

Here is an example of functional tests specifying the behavior of the bookstore API you created in the tutorial:

<?php
// api/tests/ApiTest.php

namespace App\Tests;

use App\Entity\Book;
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;

class ApiTest extends WebTestCase
{
    use RefreshDatabaseTrait;

    /** @var Client */
    protected $client;

    /**
     * Retrieves the book list.
     */
    public function testRetrieveTheBookList(): void
    {
        $response = $this->request('GET', '/books');
        $json = json_decode($response->getContent(), true);

        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('application/ld+json; charset=utf-8', $response->headers->get('Content-Type'));

        $this->assertArrayHasKey('hydra:totalItems', $json);
        $this->assertEquals(10, $json['hydra:totalItems']);

        $this->assertArrayHasKey('hydra:member', $json);
        $this->assertCount(10, $json['hydra:member']);
    }

    /**
     * Throws errors when data are invalid.
     */
    public function testThrowErrorsWhenDataAreInvalid(): void
    {
        $data = [
            'isbn' => '1312',
            'title' => '',
            'author' => 'Kévin Dunglas',
            'description' => 'This book is designed for PHP developers and architects who want to modernize their skills through better understanding of Persistence and ORM.',
            'publicationDate' => '2013-12-01',
        ];

        $response = $this->request('POST', '/books', $data);
        $json = json_decode($response->getContent(), true);

        $this->assertEquals(400, $response->getStatusCode());
        $this->assertEquals('application/ld+json; charset=utf-8', $response->headers->get('Content-Type'));

        $this->assertArrayHasKey('violations', $json);
        $this->assertCount(2, $json['violations']);

        $this->assertArrayHasKey('propertyPath', $json['violations'][0]);
        $this->assertEquals('isbn', $json['violations'][0]['propertyPath']);

        $this->assertArrayHasKey('propertyPath', $json['violations'][1]);
        $this->assertEquals('title', $json['violations'][1]['propertyPath']);
    }

    /**
     * Creates a book.
     */
    public function testCreateABook(): void
    {
        $data = [
            'isbn' => '9781782164104',
            'title' => 'Persistence in PHP with Doctrine ORM',
            'description' => 'This book is designed for PHP developers and architects who want to modernize their skills through better understanding of Persistence and ORM. You\'ll learn through explanations and code samples, all tied to the full development of a web application.',
            'author' => 'Kévin Dunglas',
            'publicationDate' => '2013-12-01',
        ];

        $response = $this->request('POST', '/books', $data);
        $json = json_decode($response->getContent(), true);

        $this->assertEquals(201, $response->getStatusCode());
        $this->assertEquals('application/ld+json; charset=utf-8', $response->headers->get('Content-Type'));

        $this->assertArrayHasKey('isbn', $json);
        $this->assertEquals('9781782164104', $json['isbn']);
    }

    /**
     * Updates a book.
     */
    public function testUpdateABook(): void
    {
        $data = [
            'isbn' => '9781234567897',
        ];

        $response = $this->request('PUT', $this->findOneIriBy(Book::class, ['isbn' => '9790456981541']), $data);
        $json = json_decode($response->getContent(), true);

        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('application/ld+json; charset=utf-8', $response->headers->get('Content-Type'));

        $this->assertArrayHasKey('isbn', $json);
        $this->assertEquals('9781234567897', $json['isbn']);
    }

    /**
     * Deletes a book.
     */
    public function testDeleteABook(): void
    {
        $response = $this->request('DELETE', $this->findOneIriBy(Book::class, ['isbn' => '9790456981541']));

        $this->assertEquals(204, $response->getStatusCode());

        $this->assertEmpty($response->getContent());
    }

    /**
     * Retrieves the documentation.
     */
    public function testRetrieveTheDocumentation(): void
    {
        $response = $this->request('GET', '/', null, ['Accept' => 'text/html']);

        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('text/html; charset=UTF-8', $response->headers->get('Content-Type'));

        $this->assertContains('Hello API Platform', $response->getContent());
    }

    protected function setUp()
    {
        parent::setUp();

        $this->client = static::createClient();
    }

    /**
     * @param string|array|null $content
     */
    protected function request(string $method, string $uri, $content = null, array $headers = []): Response
    {
        $server = ['CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'];
        foreach ($headers as $key => $value) {
            if (strtolower($key) === 'content-type') {
                $server['CONTENT_TYPE'] = $value;

                continue;
            }

            $server['HTTP_'.strtoupper(str_replace('-', '_', $key))] = $value;
        }

        if (is_array($content) && false !== preg_match('#^application/(?:.+\+)?json$#', $server['CONTENT_TYPE'])) {
            $content = json_encode($content);
        }

        $this->client->request($method, $uri, [], [], $server, $content);

        return $this->client->getResponse();
    }

    protected function findOneIriBy(string $resourceClass, array $criteria): string
    {
        $resource = static::$container->get('doctrine')->getRepository($resourceClass)->findOneBy($criteria);

        return static::$container->get('api_platform.iri_converter')->getIriFromitem($resource);
    }
}

As you can see, the example uses the trait RefreshDatabaseTrait from AliceBundle which will, at the beginning of each test, purge the database, load fixtures, begin a transaction, and, at the end of each test, roll back the transaction previously begun. Because of this, you can run your tests without worrying about fixtures.

All you have to do now is to run your tests:

$ docker-compose exec php bin/phpunit

If everything is working properly, you should see OK (6 tests, 27 assertions). Your Linked Data API is now specified and tested thanks to PHPUnit!

Additional and Alternative Testing Tools

You may also be interested in these alternative testing tools (not included in the API Platform distribution):

Writing Unit Tests

Take a look at the Symfony documentation about testing to learn how to write unit tests with PHPUnit in your API Platform project.