使用 Symfony 测试 API

有关使用 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');
    }
}

查看专门的 Symfony 文档条目 

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注