diff --git a/core/testing.md b/core/testing.md new file mode 100644 index 00000000000..84705507c39 --- /dev/null +++ b/core/testing.md @@ -0,0 +1,105 @@ +# Testing Utilities + +API Platform Core provides a set of useful utilities dedicated to API testing. +For an overview of how to test an API Platform app, be sure to read [the testing cookbook first](../distribution/testing.md). + +## The Test HttpClient + +API Platform provides its own implementation of the [Symfony HttpClient](https://symfony.com/doc/current/components/http_client.html)'s interfaces, tailored to be used directly in [PHPUnit](https://phpunit.de/) test classes. + +While all the convenient features of Symfony HttpClient are available and usable directly, under the hood the API Platform implementation manipulates [the Symfony HttpKernel](https://symfony.com/doc/current/components/http_kernel.html) directly to simulate HTTP requests and responses. +This approach results in a huge performance boost compared to triggering real network requests. +It also allows access to the [Symfony HttpKernel](https://symfony.com/doc/current/components/http_kernel.html and to all your services via the [Dependency Injection Container](https://symfony.com/doc/current/testing.html#accessing-the-container). +Reuse them to run, for instance, SQL queries or requests to external APIs directly from your tests. + +Install the `symfony/http-client` and `symfony/browser-kit` packages to enabled the API Platform test client: + + $ docker-compose exec php composer require symfony/http-client symfony/browser-kit + +To use the testing client, your test class must extend the `ApiTestCase` class: + +```php +request('GET', '/books'); + // your assertions here... + } +} +``` + +Refer to [the Symfony HttpClient documentation](https://symfony.com/doc/current/components/http_client.html) to discover all the features of the client (custom headers, JSON encoding and decoding, HTTP Basic and Bearer authentication and cookies support, among other things). + + +## API Test Assertions + +In addition to [the built-in ones](https://phpunit.readthedocs.io/fr/latest/assertions.html), API Platform provides convenient PHPUnit assertions dedicated to API testing: + +```php +request(...); + + // Asserts that the returned JSON is equal to the passed one + $this->assertJsonEquals(/* a JSON document as an array or as a string */); + + // Asserts that the returned JSON is a superset of the passed one + $this->assertJsonContains(/* a JSON document as an array or as a string */); + + // justinrainbow/json-schema must be installed to use the following assertions + + // Asserts that the returned JSON matches the passed JSON Schema + $this->assertMatchesJsonSchema(/* a JSON Schema as an array or as a string */); + + // Asserts that the returned JSON is validated by the JSON Schema generated for this resource by API Platform + + // For collections + $this->assertMatchesResourceCollectionJsonSchema(YourApiResource::class); + // And for items + $this->assertMatchesResourceItemJsonSchema(YourApiResource::class); + } +} +``` + +## HTTP Test Assertions + +All tests assertions provided by Symfony (assertions for status codes, headers, cookies, XML documents...) can be used out of the box with the API Platform test client: + +```php +request('GET', '/books'); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + } +} +``` + +[Check out the dedicated Symfony documentation entry](https://symfony.com/doc/current/testing/functional_tests_assertions.html). diff --git a/distribution/index.md b/distribution/index.md index 319f4526e7d..3dec03201a0 100644 --- a/distribution/index.md +++ b/distribution/index.md @@ -38,9 +38,9 @@ API Platform uses these model classes to expose a web API having a bunch of buil * filtering * sorting * hypermedia/[HATEOAS](https://en.wikipedia.org/wiki/HATEOAS) and content negotiation support ([JSON-LD](http://json-ld.org), - [HAL](http://blog.stateless.co/post/13296666138/json-linking-with-hal), [JSON API](http://jsonapi.org/)) + [HAL](http://blog.stateless.co/post/13296666138/json-linking-with-hal), [JSON API](http://jsonapi.org/)...) * [GraphQL support](http://graphql.org/) -* Nice UI and machine-readable documentations ([Swagger/OpenAPI](https://swagger.io), [Hydra](http://hydra-cg.com)) +* Nice UI and machine-readable documentations ([Swagger UI/OpenAPI](https://swagger.io), [Hydra](http://hydra-cg.com)) * authentication ([Basic HTTP](https://en.wikipedia.org/wiki/Basic_access_authentication), cookies as well as [JWT](https://jwt.io/) and [OAuth](https://oauth.net/) through extensions) * [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) @@ -49,7 +49,7 @@ API Platform uses these model classes to expose a web API having a bunch of buil * and basically everything needed to build modern APIs. One more thing, before we start: as the API Platform distribution includes [the Symfony framework](https://symfony.com), -it is compatible with most [Symfony bundles](https://symfony.com/blog/the-30-most-useful-symfony-bundles-and-making-them-even-better) +it is compatible with most [Symfony bundles](https://flex.symfony.com) (plugins) and benefits from the numerous extensions points provided by this rock-solid foundation (events, DIC...). Adding features like custom, service-oriented, API endpoints, JWT or OAuth authentication, HTTP caching, mail sending or asynchronous jobs to your APIs is straightforward. diff --git a/distribution/testing.md b/distribution/testing.md index fe24c9a54b2..b43ac629edf 100644 --- a/distribution/testing.md +++ b/distribution/testing.md @@ -1,34 +1,36 @@ -# Testing and Specifying the API +# Testing 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: +Now that you have a functional API, you should write tests to ensure it has no bugs, and to prevent future regressions. +Some would argue that it's even better to [write tests first](https://martinfowler.com/bliki/TestDrivenDevelopment.html). + +API Platform provides a set of helpful testing utilities to write unit tests, functional tests, and to create [test fixtures](https://en.wikipedia.org/wiki/Test_fixture#Software). + +Let's learn how to use them! In this article you'll learn how to use: -* [Alice](https://github.com/nelmio/alice), an expressive fixtures generator to write data fixtures, and its Symfony -integration, [AliceBundle](https://github.com/hautelook/AliceBundle#database-testing); * [PHPUnit](https://phpunit.de/index.html), a testing framework to cover your classes with unit tests and to write -functional tests thanks to its Symfony integration, [PHPUnit Bridge](https://symfony.com/doc/current/components/phpunit_bridge.html). +API-oriented functional tests thanks to its API Platform and [Symfony](https://symfony.com/doc/current/testing.html) integrations. +* [Alice](https://github.com/nelmio/alice) and [its Symfony +integration](https://github.com/hautelook/AliceBundle#database-testing), an expressive fixtures generator to write data fixtures. -Official Symfony recipes are provided for both tools. +Official [Symfony recipes](https://flex.symfony.com/) 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](https://github.com/nelmio/alice) and [AliceBundle](https://github.com/hautelook/AliceBundle): +First, install [Alice](https://github.com/nelmio/alice): $ docker-compose exec php composer require --dev alice -Thanks to Symfony Flex, [AliceBundle](https://github.com/hautelook/AliceBundle/blob/master/README.md) is ready to use -and you can place your data fixtures files in a directory named `fixtures/`. +Thanks to Symfony Flex, Alice (and [AliceBundle](https://github.com/hautelook/AliceBundle)) are ready to use! +Place your data fixtures files in a directory named `fixtures/`. Then, create some fixtures for [the bookstore API you created in the tutorial](index.md): ```yaml -# api/fixtures/book.yaml - +# api/fixtures/books.yaml App\Entity\Book: - book_{1..10}: + book_{1..100}: isbn: title: description: @@ -37,10 +39,9 @@ App\Entity\Book: ``` ```yaml -# api/fixtures/review.yaml - +# api/fixtures/reviews.yaml App\Entity\Review: - review_{1..20}: + review_{1..200}: rating: body: author: @@ -52,16 +53,17 @@ 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](https://github.com/nelmio/alice/blob/master/README.md#table-of-contents) -and [AliceBundle](https://github.com/hautelook/AliceBundle/blob/master/README.md). +To learn more about fixtures, take a look at the documentation of [Alice](https://github.com/nelmio/alice) +and [AliceBundle](https://github.com/hautelook/AliceBundle). +The list of available generators as well as a cookbook explaining how to create custom generators can be found in the documentation of [Faker](https://github.com/fzaninotto/Faker), the library used by Alice under the hood. ## Writing Functional Tests Now that you have some data fixtures for your API, you are ready to write functional tests with [PHPUnit](https://phpunit.de/index.html). -Install the Symfony test pack which includes [PHPUnit Bridge](https://symfony.com/doc/current/components/phpunit_bridge.html): +Install the Symfony test pack (which includes PHPUnit and [PHPUnit Bridge](https://symfony.com/doc/current/components/phpunit_bridge.html)), [Symfony HttpClient](https://symfony.com/doc/current/components/http_client.html) (the API Platform test client is built on top of Symfony HttpClient, and allows to leverage all its features) and [JSON Schema for PHP](https://github.com/justinrainbow/json-schema) (used by API Platform to provide [JSON Schema](https://json-schema.org/) test assertions): - $ docker-compose exec php composer require --dev test-pack + $ docker-compose exec php composer require --dev test-pack http-client justinrainbow/json-schema Your API is ready to be functionally tested. Create your test classes under the `tests/` directory. @@ -69,174 +71,130 @@ Here is an example of functional tests specifying the behavior of [the bookstore ```php 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 + public function testGetCollection(): 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']); + // The client implements Symfony HttpClient's `HttpClientInterface`, and the response `ResponseInterface` + $response = static::createClient()->request('GET', '/books'); + + $this->assertResponseIsSuccessful(); + // Asserts that the returned content type is JSON-LD (the default) + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + + // Asserts that the returned JSON is a superset of this one + $this->assertJsonContains([ + '@context' => '/contexts/Book', + '@id' => '/books', + '@type' => 'hydra:Collection', + 'hydra:totalItems' => 100, + 'hydra:view' => [ + '@id' => '/books?page=1', + '@type' => 'hydra:PartialCollectionView', + 'hydra:first' => '/books?page=1', + 'hydra:last' => '/books?page=4', + 'hydra:next' => '/books?page=2', + ], + ]); + + // Because test fixtures are automatically loaded between each test, you can assert on them + $this->assertCount(30, $response->toArray()['hydra:member']); + + // Asserts that the returned JSON is validated by the JSON Schema generated for this resource by API Platform + // This generated JSON Schema is also used in the OpenAPI spec! + $this->assertMatchesResourceCollectionJsonSchema(Book::class); } - /** - * Deletes a book. - */ - public function testDeleteABook(): void + public function testCreateBook(): void { - $response = $this->request('DELETE', $this->findOneIriBy(Book::class, ['isbn' => '9790456981541'])); - - $this->assertEquals(204, $response->getStatusCode()); - - $this->assertEmpty($response->getContent()); + $response = static::createClient()->request('POST', '/books', ['json' => [ + 'isbn' => '0099740915', + 'title' => 'The Handmaid\'s Tale', + 'description' => 'Brilliantly conceived and executed, this powerful evocation of twenty-first century America gives full rein to Margaret Atwood\'s devastating irony, wit and astute perception.', + 'author' => 'Margaret Atwood', + 'publicationDate' => '1985-07-31T00:00:00+00:00', + ]]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Book', + '@type' => 'Book', + 'isbn' => '0099740915', + 'title' => 'The Handmaid\'s Tale', + 'description' => 'Brilliantly conceived and executed, this powerful evocation of twenty-first century America gives full rein to Margaret Atwood\'s devastating irony, wit and astute perception.', + 'author' => 'Margaret Atwood', + 'publicationDate' => '1985-07-31T00:00:00+00:00', + 'reviews' => [], + ]); + $this->assertRegExp('~^/books/\d+$~', $response->toArray()['@id']); + $this->assertMatchesResourceItemJsonSchema(Book::class); } - /** - * Retrieves the documentation. - */ - public function testRetrieveTheDocumentation(): void + public function testCreateInvalidBook(): 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()); + static::createClient()->request('POST', '/books', ['json' => [ + 'isbn' => 'invalid', + ]]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + + $this->assertJsonContains([ + '@context' => '/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'isbn: This value is neither a valid ISBN-10 nor a valid ISBN-13. +title: This value should not be blank. +description: This value should not be blank. +author: This value should not be blank. +publicationDate: This value should not be null.', + ]); } - protected function setUp() + public function testUpdateBook(): void { - parent::setUp(); - - $this->client = static::createClient(); + $client = static::createClient(); + // findIriBy allows to retrieve the IRI of an item by searching for some of its properties. + // ISBN 9786644879585 has been generated by Alice when loading test fixtures. + // Because Alice use a seeded pseudo-random number generator, we're sure that this ISBN will always be generated. + $iri = static::findIriBy(Book::class, ['isbn' => '9781344037075']); + + $client->request('PUT', $iri, ['json' => [ + 'title' => 'updated title', + ]]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@id' => $iri, + 'isbn' => '9781344037075', + 'title' => 'updated title', + ]); } - /** - * @param string|array|null $content - */ - protected function request(string $method, string $uri, $content = null, array $headers = []): Response + public function testDeleteBook(): void { - $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); - } + $client = static::createClient(); + $iri = static::findIriBy(Book::class, ['isbn' => '9781344037075']); - $this->client->request($method, $uri, [], [], $server, $content); + $client->request('DELETE', $iri); - 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); + $this->assertResponseStatusCodeSame(204); + $this->assertNull( + // Through the container, you can access all your services from the tests, including the ORM, the mailer, remote API clients... + static::$container->get('doctrine')->getRepository(Book::class)->findOneBy(['isbn' => '9781344037075']) + ); } } ``` @@ -248,17 +206,22 @@ transaction previously begun. Because of this, you can run your tests without wo All you have to do now is to run your tests: - $ docker-compose exec php bin/phpunit + $ docker-compose exec php vendor/bin/simple-phpunit + +If everything is working properly, you should see `OK (5 tests, 17 assertions)`. +Your REST API is now properly tested! -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](https://phpunit.de/index.html)! +Check out the [testing documentation](../core/testing.md) to discover the full range of assertions and other features provided by API Platform's test utilities. -### Additional and Alternative Testing Tools +## Writing Unit Tests + +In addition to integration tests written using the helpers provided by `ApiTestCase`, all the classes of your project should be covered by [unit tests](https://en.wikipedia.org/wiki/Unit_testing). +To do so, learn how to write unit tests with [PHPUnit](https://phpunit.de/index.html) and [its Symfony/API Platform integration](https://symfony.com/doc/current/testing.html). + +## Additional and Alternative Testing Tools You may also be interested in these alternative testing tools (not included in the API Platform distribution): -* [ApiTestCase](https://github.com/lchrusciel/ApiTestCase), a handy [PHPUnit](https://phpunit.de/index.html) test case - for going further by testing JSON and XML APIs in your Symfony applications; * [Behat](http://behat.org/en/latest/) and its [Behatch extension](https://github.com/Behatch/contexts), a [Behavior-Driven development](https://en.wikipedia.org/wiki/Behavior-driven_development) framework to write the API specification as user stories and in natural language then execute these scenarios against the application to validate @@ -269,8 +232,3 @@ You may also be interested in these alternative testing tools (not included in t Platform project using a nice UI, benefit from [the Swagger integration](https://www.getpostman.com/docs/importing_swagger) and run tests in the CI using [newman](https://github.com/postmanlabs/newman); * [PHP Matcher](https://github.com/coduo/php-matcher), the Swiss Army knife of JSON document testing. - -## Writing Unit Tests - -Take a look at [the Symfony documentation about testing](https://symfony.com/doc/current/testing.html) to learn how to -write unit tests with [PHPUnit](https://phpunit.de/index.html) in your API Platform project.