定制 tyloo/atc 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

tyloo/atc

最新稳定版本:0.1.0

Composer 安装命令:

composer require --dev tyloo/atc

包简介

Fluent API testing for Symfony with JSON Schema validation, container mocking, and in-memory infrastructure swaps.

README 文档

README

ATC - APITestCase

ATC - APITestCase

A fluent, batteries-included testing layer for Symfony JSON APIs.

Latest version Downloads CI Coverage License PHP version Symfony 6.4 | 7 | 8

Fluent API testing for Symfony, with zero boilerplate. ATC is a batteries-included testing layer on top of WebTestCase: chained HTTP+JSON assertions, JSON Schema and JMESPath, container-aware mocking, profiler-backed N+1 detection, and ready-to-use in-memory swaps for Messenger, Mailer, Notifier, HTTP client, and Cache.

Contents

Installation

composer require --dev tyloo/atc

That is it. No bundle to register, no YAML to write. Extend Tyloo\Atc\ApiTestCase in any functional test and you have the full surface. Sensible defaults out of the box:

  • tests/Schemas/ for JSON Schema files
  • No default headers, no default mocks
  • In-memory transports auto-discovered from your framework.messenger config
  • HTTP client mock is strict (unmatched outbound requests fail the test)

Each default has a protected override hook on the test case (see Customization).

Quick start

A realistic scenario:

  • Spin up an authenticated admin with Zenstruck Foundry
  • Validate the response against a JSON Schema
  • Assert the welcome email got queued
  • Confirm the row landed in the database
final class CreateUserTest extends ApiTestCase
{
    use Factories;
    use ResetDatabase;
    use InteractsWithDatabase;
    use InteractsWithMessenger;

    #[Test]
    public function admin_creates_a_user_and_queues_welcome_email(): void
    {
        $admin = AdminFactory::createOne();

        $this->actingAs($admin)
            ->post('/api/users', json: [
                'email' => 'jean@bond.com',
                'name'  => 'Jean Bond',
            ])
            ->assertStatus(201)
            ->assertMatchesJsonSchema('users/create.json')
            ->assertJsonPath('data.email', 'jean@bond.com')
            ->assertHeader('Location', '/api/users/42');

        $this->assertDatabaseHas(User::class, ['email' => 'jean@bond.com']);
        $this->assertMessageDispatched(
            SendWelcomeEmail::class,
            fn (SendWelcomeEmail $m) => $m->email === 'jean@bond.com',
        );
    }
}

One method, no setup boilerplate. The rest of this README walks through every feature in detail.

Issuing requests

All HTTP verbs are available on the test case via InteractsWithApi (auto-loaded in ApiTestCase):

$this->get('/api/users');
$this->post('/api/users', json: ['name' => 'Alice']);
$this->patch('/api/users/1', json: ['name' => 'Bob']);
$this->put('/api/users/1', json: [...]);
$this->delete('/api/users/1');

Headers and query strings are first-class arguments:

$this->get('/api/users',
    headers: ['X-Tenant' => 'acme'],
    query:   ['filter' => 'active', 'page' => 2],
);

Form payloads and file uploads:

$this->post('/api/login', formData: ['username' => 'a', 'password' => 'b']);
$this->post('/api/uploads', files: ['file' => $uploadedFile]);

When json: is provided it takes precedence; formData is ignored. Content-Type: application/json is set automatically.

Persist headers across multiple requests in the same test:

$this->withHeaders(['Accept-Language' => 'fr'])
    ->get('/api/users')
    ->assertJsonContains(['greeting' => 'Bonjour']);

$this->get('/api/products')->assertStatusOk(); // still sends Accept-Language: fr

Asserting on responses

Every verb call returns an ApiResponse that you can chain assertions on:

$this->get('/api/users/42')
    ->assertStatusOk()                          // 200
    ->assertHeader('Content-Type', 'application/json')
    ->assertJsonContains(['id' => 42, 'name' => 'Alice'])
    ->assertJsonPath('roles[0]', 'admin');

Status

$response->assertStatus(200); // exact match
$response->assertStatusOk();  // shorthand for the common case

Headers

$response->assertHeader('Content-Type', 'application/json');
$response->assertHeaderHas('ETag');         // present, value irrelevant
$response->assertHeaderMissing('X-Debug-Token');

JSON body, exact match

$response->assertJson([
    'id'   => 1,
    'name' => 'Alice',
]);

Order and types must match. Use assertJsonContains when you only care about a subset.

JSON body, subset match

$response->assertJsonContains([
    'data' => ['email' => 'alice@example.com'],
]);

Recursive: nested arrays only need to contain the listed keys.

Raw access

When the assertion helpers aren't enough, grab the decoded body directly:

$body = $response->json();                  // decoded array
$email = $response->json('data.email');     // JMESPath expression

$status = $response->statusCode();          // int
$body   = $response->content();             // raw string
$ms     = $response->responseTimeMs();      // float
$raw    = $response->raw();                 // Symfony Response

$response = $this->lastResponse();          // last response from this test

JMESPath assertions

ATC uses JMESPath for navigating JSON responses (powered by mtdowling/jmespath.php):

$response
    ->assertJsonPath('user.email', 'alice@example.com')
    ->assertJsonPath('data[0].active', true)
    ->assertJsonPath('roles | length(@)', 3);

Pass a callable to assert with a predicate (truthy = pass):

$response->assertJsonPath('id', fn ($v) => is_int($v) && $v > 0);

Assert that a path does not resolve to a value, or count items at a path:

$response->assertJsonMissingPath('deleted_at');
$response->assertJsonCount(3, 'data');
$response->assertJsonCount(2);              // root must be an array of 2

JSON Schema validation

Drop a JSON Schema file under tests/Schemas/ (configurable; see Customization) and validate the response shape against it:

// tests/Schemas/users/create.json
{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "required": ["data"],
    "properties": {
        "data": {
            "type": "object",
            "required": ["id", "email", "name"],
            "properties": {
                "id":    { "type": "integer" },
                "email": { "type": "string", "format": "email" },
                "name":  { "type": "string" }
            }
        }
    }
}
$this->post('/api/users', json: [...])
    ->assertStatus(201)
    ->assertMatchesJsonSchema('users/create.json');

Powered by justinrainbow/json-schema. Failures include the schema path and a human-readable list of validation errors.

Performance assertions

Wall-clock duration of each request is measured automatically:

$this->get('/api/heavy-report')
    ->assertStatusOk()
    ->assertResponseTimeLessThan(500)       // < 500 ms
    ->assertResponseTimeBetween(50, 500);   // sanity bounds (catch suspiciously-fast cached responses)

Tip: use generous bounds in CI to avoid flakiness.

Authentication

ATC targets stateless / token-based APIs, so authentication is just "attach the right header to the next request".

Raw tokens

$this->withToken('eyJhbGciOi...')->get('/api/me')->assertStatusOk();

// custom scheme
$this->withToken('xxx', scheme: 'Basic')->get('/api/me');

actingAs($user) — paired with Foundry

By default, actingAs($user) looks for a getApiToken(): string method on the user object and attaches the result as a Bearer token. Perfect when your User entity already exposes an API token.

The recommended pattern: build the user with Zenstruck Foundry, then pass it straight to actingAs():

use App\Factory\UserFactory;
use Tyloo\Atc\ApiTestCase;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;

final class ProfileTest extends ApiTestCase
{
    use Factories;
    use ResetDatabase;

    #[Test]
    public function returns_authenticated_users_profile(): void
    {
        $alice = UserFactory::createOne(['email' => 'alice@example.com']);

        $this->actingAs($alice)
            ->get('/api/me')
            ->assertStatusOk()
            ->assertJsonPath('email', 'alice@example.com');
    }
}

Your User entity (or its Foundry factory) just needs a getApiToken(): string accessor. Foundry handles persistence, actingAs() handles the Bearer header, ATC handles the rest.

Custom auth strategy

Override authenticate() in your base test case to plug in any strategy (JWT, HMAC signature, opaque token, anything that maps user → request credentials):

use Tyloo\Atc\ApiTestCase;
use Tyloo\Atc\Http\ApiClient;

abstract class BaseApiTestCase extends ApiTestCase
{
    #[\Override]
    protected function authenticate(object $user, ApiClient $client): ApiClient
    {
        $jwt = static::getContainer()->get(JWTTokenManagerInterface::class);

        return $client->withToken($jwt->create($user));
    }
}

actingAs($user) will then route through your override across every test that extends BaseApiTestCase. No registry, no $using: argument, no bundle config — one method, one strategy per test suite.

Container mocking

InteractsWithContainer lets you swap services for test doubles without touching services.yaml.

Full mock

$shopify = $this->mockService(ShopifyService::class);
$shopify->expects(self::once())
    ->method('createCustomer')
    ->willReturn('cust_123');

$this->post('/api/customers', json: ['email' => 'a@b.c'])->assertStatus(201);

Partial mock (real behavior on un-listed methods)

$notifier = $this->partialMockService(NotifierService::class, ['send']);
$notifier->method('send')->willReturn(null);
// every other method on NotifierService keeps its real implementation.

Partial mocks require a concrete class.

Inject any object as a service

$this->setService('app.feature_flags', new InMemoryFeatureFlags(['beta' => true]));

Default mocks for the whole test suite

Centralize "always-mocked-in-tests" services in a base test case:

abstract class BaseApiTestCase extends ApiTestCase
{
    protected function defaultMocks(): array
    {
        return [
            ShopifyService::class => fn () => $this->createMock(ShopifyService::class),
        ];
    }
}

Database

Add the trait. ATC does not manage database lifecycle, so pair it with Zenstruck Foundry or DAMA/DoctrineTestBundle:

final class CreateUserTest extends ApiTestCase
{
    use Factories;
    use ResetDatabase;
    use InteractsWithDatabase;

    #[Test]
    public function admin_creates_user_persists_row(): void
    {
        $admin = UserFactory::createOne(['role' => 'admin']);

        $this->actingAs($admin)
            ->post('/api/users', json: ['email' => 'new@example.com'])
            ->assertStatus(201);

        $this->assertDatabaseHas(User::class, ['email' => 'new@example.com']);
        $this->assertDatabaseMissing(User::class, ['email' => 'deleted@example.com']);
        $this->assertDatabaseCount(User::class, 2);
    }
}

Messenger

InteractsWithMessenger discovers in-memory:// transports and lets you inspect dispatched messages. Handlers do not auto-execute, so you assert on the dispatch itself:

use Tyloo\Atc\Trait\InteractsWithMessenger;

final class BulkImportTest extends ApiTestCase
{
    use InteractsWithMessenger;

    #[\PHPUnit\Framework\Attributes\Test]
    public function csv_upload_queues_one_message_per_row(): void
    {
        $this->post('/api/imports', files: ['csv' => $this->uploadCsv('100-rows.csv')])
            ->assertStatus(202);

        $this->assertMessagesDispatchedCount(100, ImportRow::class);
        $this->assertMessageDispatched(
            ImportRow::class,
            fn (ImportRow $row) => $row->email === 'first@example.com',
        );
    }
}

Other helpers:

$this->assertNoMessagesDispatched();                    // any class
$this->assertNoMessagesDispatched(SendEmail::class);    // class-specific

$all     = $this->dispatchedMessages();                 // list<object>
$welcomes = $this->dispatchedMessages(SendWelcomeEmail::class);

Mailer

InteractsWithMailer swaps the real Mailer for an in-memory capture and exposes assertions on sent emails:

use Tyloo\Atc\Trait\InteractsWithMailer;

final class PasswordResetTest extends ApiTestCase
{
    use InteractsWithMailer;

    #[\PHPUnit\Framework\Attributes\Test]
    public function reset_request_sends_a_one_time_link(): void
    {
        $this->post('/api/password/reset', json: ['email' => 'alice@example.com'])
            ->assertStatusOk();

        $this->assertEmailSent();
        $this->assertEmailSentTo(
            'alice@example.com',
            fn (Email $email) => str_contains((string) $email->getSubject(), 'Reset your password'),
        );
        $this->assertNoEmailsSent(); // for negative paths
    }
}

Notifier

InteractsWithNotifier captures Symfony Notifier sends:

use Tyloo\Atc\Trait\InteractsWithNotifier;

final class OutageAlertTest extends ApiTestCase
{
    use InteractsWithNotifier;

    #[\PHPUnit\Framework\Attributes\Test]
    public function downstream_error_pages_the_oncall(): void
    {
        $this->mockService(StatusPageClient::class)
            ->method('latest')
            ->willThrowException(new \RuntimeException('upstream down'));

        $this->get('/api/health')->assertStatus(503);

        $this->assertNotificationSent();
        $this->assertSame(1, $this->sentNotifications()->count());
    }
}

HTTP client

InteractsWithHttpClient swaps the Symfony HTTP client for a MockHttpClient you control, and records every outbound request:

use Symfony\Component\HttpClient\Response\MockResponse;
use Tyloo\Atc\Trait\InteractsWithHttpClient;

final class GeocodingTest extends ApiTestCase
{
    use InteractsWithHttpClient;

    #[\PHPUnit\Framework\Attributes\Test]
    public function address_lookup_calls_geocoder_with_signed_query(): void
    {
        $this->mockHttpClient([
            new MockResponse(json_encode(['lat' => 48.85, 'lng' => 2.35]), ['http_code' => 200]),
        ]);

        $this->post('/api/addresses', json: ['street' => '1 rue de Rivoli'])
            ->assertStatus(201)
            ->assertJsonPath('lat', 48.85);

        $this->assertHttpRequestSent('GET', 'https://api.example.com/geocode');
    }
}

By default the mock is strict: an unmatched request fails the test. Override resolveHttpClientStrict() in your test case to return false if you'd rather let unmatched requests pass through.

Cache

InteractsWithCache swaps every cache pool for an ArrayAdapter:

use Tyloo\Atc\Trait\InteractsWithCache;

final class RateLimitTest extends ApiTestCase
{
    use InteractsWithCache;

    #[\PHPUnit\Framework\Attributes\Test]
    public function fourth_request_in_a_minute_is_throttled(): void
    {
        for ($i = 0; $i < 3; $i++) {
            $this->get('/api/search?q=foo')->assertStatusOk();
        }

        $this->get('/api/search?q=foo')->assertStatus(429);

        $this->clearCache(); // reset between sub-scenarios
        $this->get('/api/search?q=foo')->assertStatusOk();
    }
}

Profiler & N+1 detection

InteractsWithProfiler enables Symfony's Profiler per-request and exposes the captured Profile. Useful for catching N+1 query regressions:

use Tyloo\Atc\Trait\InteractsWithProfiler;

final class ListUsersTest extends ApiTestCase
{
    use InteractsWithProfiler;

    #[\PHPUnit\Framework\Attributes\Test]
    public function list_endpoint_uses_a_single_query_regardless_of_user_count(): void
    {
        UserFactory::createMany(50);

        $this->withProfiling();

        $this->get('/api/users')
            ->assertStatusOk()
            ->assertJsonCount(50, 'data');

        $this->assertQueryCount(1);            // exactly one SELECT
        $this->assertQueryCountLessThan(3);    // looser bound

        $profile = $this->profile();           // raw Symfony Profile for deeper introspection
    }
}

Requires framework.profiler enabled in the test kernel and doctrine/doctrine-bundle (for the db collector).

Customization

There is no bundle, no YAML config, no DI extension. Every default lives on a protected method that you override in a base test case. Define one base class for your project and inherit everywhere:

use Tyloo\Atc\ApiTestCase;
use Tyloo\Atc\Http\ApiClient;

abstract class BaseApiTestCase extends ApiTestCase
{
    /** Default headers sent with every request. */
    #[\Override]
    protected function resolveDefaultHeaders(): array
    {
        return ['Accept' => 'application/json', 'X-Tenant' => 'acme'];
    }

    /** Where JSON Schema files live (`<project_dir>/tests/Schemas` by default). */
    #[\Override]
    protected function resolveJsonSchemaBaseDir(): string
    {
        return static::$kernel->getProjectDir() . '/tests/api-schemas';
    }

    /** Services replaced with default doubles for every test in this suite. */
    #[\Override]
    protected function defaultMocks(): array
    {
        return [
            ShopifyService::class => fn () => $this->createMock(ShopifyService::class),
        ];
    }

    /** Auth strategy: map a user to an authenticated client. */
    #[\Override]
    protected function authenticate(object $user, ApiClient $client): ApiClient
    {
        return $client->withToken($this->jwt()->create($user));
    }

    /** Pin the in-memory messenger transports instead of auto-discovering. */
    #[\Override]
    protected function resolveMessengerTransports(): array
    {
        return ['async', 'failed'];
    }

    /** Let unmatched outbound HTTP requests pass through instead of failing. */
    #[\Override]
    protected function resolveHttpClientStrict(): bool
    {
        return false;
    }

    /** Swap additional cache pools (default: just `cache.app`). */
    #[\Override]
    protected function cachePoolIds(): array
    {
        return ['cache.app', 'cache.system'];
    }
}

Cherry-pick the overrides you need. The defaults handle the common case.

Compatibility

Requirement Versions
PHP 8.3 / 8.4 / 8.5
Symfony 6.4 (LTS) / 7.x / 8.x
PHPUnit 12 / 13
Doctrine ORM (optional) ^3.6
DoctrineBundle (optional) ^2.18 || ^3.2

CI runs the full matrix on each push.

Recommended companions

Inspirations

ATC borrows ideas from:

Contributing

See CONTRIBUTING.md and our Code of Conduct.

Security

Report vulnerabilities via GitHub Security Advisories. See SECURITY.md.

License

MIT © Julien Bonvarlet

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 3
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-05-08

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固