有关使用 API Platform 进行测试的简介,请参阅核心测试文档 ,或访问 Laravel 测试指南 。
让我们学习如何使用 Symfony 的测试!
在本文中,您将学习如何使用:
- PHPUnit,一个测试框架,通过其 API 平台和 Symfony 集成,用单元测试覆盖您的类并编写面向 API 的功能测试。
- DoctrineFixturesBundle,一个用于在数据库中加载数据夹具的捆绑包。
- Foundry,一个富有表现力的夹具生成器,用于编写数据夹具。
创建数据夹具
在创建功能测试之前,您需要一个数据集来预填充 API 并能够对其进行测试。
首先,安装 Foundry 和 Doctrine/DoctrineFixturesBundle:
composer require --dev foundry orm-fixtures
多亏了 Symfony Flex,DoctrineFixturesBundle 和 Foundry 就可以使用了!
然后,为您在教程中创建的书店 API 创建一些工厂:
bin/console make:factory 'App\Entity\Book'
bin/console make:factory 'App\Entity\Review'
改进默认值:
// src/Factory/BookFactory.php
// ...
protected function getDefaults(): array
{
return [
'author' => self::faker()->name(),
'description' => self::faker()->text(),
'isbn' => self::faker()->isbn13(),
'publication_date' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
'title' => self::faker()->sentence(4),
];
}
// src/Factory/ReviewFactory.php
// ...
use function Zenstruck\Foundry\lazy;
// ...
protected function getDefaults(): array
{
return [
'author' => self::faker()->name(),
'body' => self::faker()->text(),
'book' => lazy(fn() => BookFactory::randomOrCreate()),
'publicationDate' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
'rating' => self::faker()->numberBetween(0, 5),
];
}
创建一些故事:
bin/console make:story 'DefaultBooks'
bin/console make:story 'DefaultReviews'
// src/Story/DefaultBooksStory.php
namespace App\Story;
use App\Factory\BookFactory;
use Zenstruck\Foundry\Story;
final class DefaultBooksStory extends Story
{
public function build(): void
{
BookFactory::createMany(100);
}
}
// src/Story/DefaultReviewsStory.php
namespace App\Story;
use App\Factory\ReviewFactory;
use Zenstruck\Foundry\Story;
final class DefaultReviewsStory extends Story
{
public function build(): void
{
ReviewFactory::createMany(200);
}
}
编辑您的灯具:
//src/DataFixtures/AppFixtures.php
namespace App\DataFixtures;
use App\Story\DefaultBooksStory;
use App\Story\DefaultReviewsStory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
DefaultBooksStory::load();
DefaultReviewsStory::load();
}
}
现在,您可以使用以下命令在数据库中加载灯具:
bin/console doctrine:fixtures:load
要了解有关夹具的更多信息,请查看 Foundry 的文档。可用生成器的列表以及解释如何创建自定义生成器的食谱可以在 Faker 的文档中找到,Faker 是 Foundry 在后台使用的库。
# 编写功能测试
现在您已经为 API 提供了一些数据夹具,您可以使用 PHPUnit 编写功能测试了。
API 平台测试客户端实现了 Symfony HttpClient 的接口。HttpClient 随 API Platform 发行版一起提供。还包括 Symfony 测试包 ,其中包括 PHPUnit 以及对测试有用的 Symfony 组件。
如果您不使用发行版,请运行 composer require --dev symfony/test-pack symfony/http-client
以安装它们。
安装 DAMADoctrineTestBundle 以在每次测试前自动重置数据库:
composer require --dev dama/doctrine-test-bundle
并在 phpunit.xml.dist
文件中激活它:
<!-- api/phpunit.xml.dist -->
<phpunit>
<!-- ... -->
<extensions>
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
</phpunit>
或者,如果您想使用 API 平台提供的 JSON Schema 测试断言:
composer require --dev justinrainbow/json-schema
您的 API 现在已准备好进行功能测试。在 tests/
目录下创建测试类。
下面是指定您在教程中创建的书店 API 行为的功能测试示例:
<?php
// api/tests/BooksTest.php
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Book;
use App\Factory\BookFactory;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class BooksTest extends ApiTestCase
{
// This trait provided by Foundry will take care of refreshing the database content to a known state before each test
use ResetDatabase, Factories;
public function testGetCollection(): void
{
// Create 100 books using our factory
BookFactory::createMany(100);
// 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' => 'Collection',
'totalItems' => 100,
'view' => [
'@id' => '/books?page=1',
'@type' => 'PartialCollectionView',
'first' => '/books?page=1',
'last' => '/books?page=4',
'next' => '/books?page=2',
],
]);
// Because test fixtures are automatically loaded between each test, you can assert on them
$this->assertCount(30, $response->toArray()['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);
}
public function testCreateBook(): void
{
$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->assertMatchesRegularExpression('~^/books/\d+$~', $response->toArray()['@id']);
$this->assertMatchesResourceItemJsonSchema(Book::class);
}
public function testCreateInvalidBook(): void
{
static::createClient()->request('POST', '/books', ['json' => [
'isbn' => 'invalid',
]]);
$this->assertResponseStatusCodeSame(422);
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains([
'@context' => '/contexts/ConstraintViolationList',
'@type' => 'ConstraintViolationList',
'title' => 'An error occurred',
'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.',
]);
}
public function testUpdateBook(): void
{
// Only create the book we need with a given ISBN
BookFactory::createOne(['isbn' => '9781344037075']);
$client = static::createClient();
// findIriBy allows to retrieve the IRI of an item by searching for some of its properties.
$iri = $this->findIriBy(Book::class, ['isbn' => '9781344037075']);
// Use the PATCH method here to do a partial update
$client->request('PATCH', $iri, [
'json' => [
'title' => 'updated title',
],
'headers' => [
'Content-Type' => 'application/merge-patch+json',
]
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'@id' => $iri,
'isbn' => '9781344037075',
'title' => 'updated title',
]);
}
public function testDeleteBook(): void
{
// Only create the book we need with a given ISBN
BookFactory::createOne(['isbn' => '9781344037075']);
$client = static::createClient();
$iri = $this->findIriBy(Book::class, ['isbn' => '9781344037075']);
$client->request('DELETE', $iri);
$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::getContainer()->get('doctrine')->getRepository(Book::class)->findOneBy(['isbn' => '9781344037075'])
);
}
}
如您所见,该示例使用特征 ResetDatabase
来自 Foundry,它将在每个测试开始时清除数据库,开始一个事务,并在每个测试结束时回滚之前开始的事务。因此,您可以运行测试而不必担心夹具。
不过有一个警告:在某些测试中,有必要在一次测试中执行多个请求,例如,在通过 API 创建用户并检查使用相同密码的后续登录是否有效时。但是,默认情况下,客户端将重新启动内核,这将重置数据库。您可以通过向此类测试添加 $client->disableReboot();
来防止这种情况。
您现在要做的就是运行测试:
bin/phpunit
如果一切正常,您应该会看到 “正常”(5 个测试,17 个断言)。
您的 REST API 现在已经过正确测试!
查看 API 测试断言部分 ,了解 API Platform 的测试实用程序提供的全部断言和其他功能。
# 编写单元测试
除了使用 ApiTestCase
提供的帮助程序编写的集成测试外, 单元测试还应涵盖项目的所有类。为此,请学习如何使用 PHPUnit 及其 Symfony/API 平台集成编写单元测试。
# 持续集成、持续交付和持续部署
在 CI/CD 管道中运行测试套件对于确保良好的质量和交付时间非常重要。
API Platform 发行版附带了一个 GitHub Actions 工作流,该工作流构建 Docker 映像,执行冒烟测试以检查应用程序的入口点是否可访问,并运行 PHPUnit。
API 平台演示包含一个 CD 工作流,该工作流使用发行版提供的 Helm 图表在 Kubernetes 集群上部署应用程序。
# 其他和替代测试工具
您可能还对以下替代测试工具感兴趣(未包含在 API 平台发行版中):
- Hoppscotch,为您的 API 创建功能测试
- Hoppscotch,使用漂亮的 UI 为您的 API 平台项目创建功能测试,受益于其 Swagger 集成,并使用命令行工具在 CI 中运行测试;
- Behat,一个行为驱动开发 (BDD) 框架,用于将 API 规范编写为用户故事,并以自然语言编写,然后针对应用程序执行这些场景以验证其行为;
- Blackfire Player,一个不错的 DSL,用于抓取 HTTP 服务、断言响应以及从 HTML/XML/JSON 响应中提取数据;
- PHP Matcher,JSON 文档测试的瑞士军刀。
# 使用 API 平台分发进行端到端测试
如果您想验证您的堆栈(包括 DBMS、Web 服务器、Varnish 等服务)是否正常工作,则需要进行端到端测试 。为此,如果您使用大量 PWA/JavaScript 的应用程序,我们建议使用 Playwright,如果您主要使用 Twig,我们建议使用 Symfony Panther。
通常,端到端测试应使用类似生产的设置来完成。为了您的方便,您可以在本地运行我们的 Docker Compose 设置以进行生产 。
# 测试 Symfony 的实用程序
API Platform 提供了一组专用于 API 测试的有用实用程序。有关如何测试 API Platform 应用程序的概述,请务必先阅读测试部分 。
# 测试 HttpClient
API 平台提供了自己的 Symfony HttpClient 接口实现,专为直接在 PHPUnit 测试类中使用而定制。
虽然 Symfony HttpClient 的所有便捷功能都可以直接使用,但在后台,API 平台实现直接纵 Symfony HttpKernel 来模拟 HTTP 请求和响应。与触发真实网络请求相比,这种方法可以显着提高性能。它还允许通过依赖注入容器访问 Symfony HttpKernel 和您的所有服务。例如,重用它们来运行 SQL 查询或直接从测试中对外部 API 的请求。
安装 symfony/http-client
和 symfony/browser-kit
软件包以启用 API 平台测试客户端:
composer require symfony/browser-kit symfony/http-client
要使用测试客户端,您的测试类必须扩展 ApiTestCase
类:
<?php
// api/tests/BooksTest.php
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
class BooksTest extends ApiTestCase
{
public function testGetCollection(): void
{
$response = static::createClient()->request('GET', '/books');
// your assertions here...
}
}
参考 Symfony HttpClient 文档以发现客户端的所有功能(自定义标头、JSON 编码和解码、HTTP Basic 和 Bearer 身份验证以及 cookie 支持等)。
请注意,您可以创建自己的测试用例类来扩展 ApiTestCase。例如,要设置 Json Web 令牌身份验证:
<?php
// api/tests/AbstractTest.php
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
abstract class AbstractTest extends ApiTestCase
{
private ?string $token = null;
use RefreshDatabaseTrait;
public function setUp(): void
{
self::bootKernel();
}
protected function createClientWithCredentials($token = null): Client
{
$token = $token ?: $this->getToken();
return static::createClient([], ['headers' => ['authorization' => 'Bearer '.$token]]);
}
/**
* Use other credentials if needed.
*/
protected function getToken($body = []): string
{
if ($this->token) {
return $this->token;
}
$response = static::createClient()->request('POST', '/login', ['json' => $body ?: [
'username' => 'admin@example.com',
'password' => '$3cr3t',
]]);
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->token = $data['token'];
return $data['token'];
}
}
通过扩展 AbstractTest
类来使用它。例如,此类测试 /users
资源可访问性,其中只有管理员可以检索集合:
<?php
namespace App\Tests;
final class UsersTest extends AbstractTest
{
public function testAdminResource()
{
$response = $this->createClientWithCredentials()->request('GET', '/users');
$this->assertResponseIsSuccessful();
}
public function testLoginAsUser()
{
$token = $this->getToken([
'username' => 'user@example.com',
'password' => '$3cr3t',
]);
$response = $this->createClientWithCredentials($token)->request('GET', '/users');
$this->assertJsonContains(['description' => 'Access Denied.']);
$this->assertResponseStatusCodeSame(403);
}
}
# 使用 Symfony 进行 API 测试断言
除了内置的断言外,API Platform 还提供了专用于 API 测试的便捷 PHPUnit 断言:
<?php
// api/tests/MyTest.php
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
class MyTest extends ApiTestCase
{
public function testSomething(): void
{
// static::createClient()->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);
}
}
还有一种方法可以查找与给定资源和一些条件匹配的 IRI:
<?php
// api/tests/BooksTest.php
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
class BooksTest extends ApiTestCase
{
public function testFindBook(): void
{
// Asserts that the returned JSON is equal to the passed one
$iri = $this->findIriBy(Book::class, ['isbn' => '9780451524935']);
static::createClient()->request('GET', $iri);
$this->assertResponseIsSuccessful();
}
}
# HTTP 测试断言
Symfony 提供的所有测试断言(状态代码、标头、cookie、XML 文档的断言等)都可以在 API 平台测试客户端中开箱即用:
<?php
// api/tests/BooksTest.php
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
class BooksTest extends ApiTestCase
{
public function testGetCollection(): void
{
static::createClient()->request('GET', '/books');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
}
}