| name | api-test |
| description | Writes functional tests for API Platform endpoints with ApiTestCase. Use whenever the user asks to test an API resource, write or fix a functional test, verify validation, security or multi-tenant isolation, set up authentication or database fixtures for tests, or reproduce an endpoint bug with a test — including plain 'add tests for X' requests. |
Testing API Platform Endpoints
Use ApiPlatform\Symfony\Bundle\Test\ApiTestCase for functional API tests.
Base Test Class
Create a base class that handles database setup and authentication:
<?php
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase as SymfonyApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
class ApiTestCase extends SymfonyApiTestCase
{
protected function setUp(): void
{
parent::setUp();
$manager = static::getContainer()->get('doctrine')->getManager();
}
public function createLoggedInClient(): Client
{
$manager = static::getContainer()->get('doctrine')->getManager();
$user = new User();
$manager->persist($user);
$manager->flush();
return static::createClient([], [
'headers' => ['x-api-key' => 'test_token'],
]);
}
}
Basic CRUD Tests
class OrderTest extends ApiTestCase
{
public function testGetCollection(): void
{
$client = $this->createLoggedInClient();
$client->request('GET', '/orders');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
}
public function testCreate(): void
{
$client = $this->createLoggedInClient();
$client->request('POST', '/orders', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Test Order', 'total' => 99.99],
]);
$this->assertResponseStatusCodeSame(201);
$data = $client->getResponse()->toArray();
$this->assertSame('Test Order', $data['name']);
}
public function testUpdate(): void
{
$client = $this->createLoggedInClient();
$client->request('POST', '/orders', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Original'],
]);
$order = $client->getResponse()->toArray();
$client->request('PATCH', '/orders/' . $order['id'], [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Updated'],
]);
$this->assertResponseIsSuccessful();
$this->assertSame('Updated', $client->getResponse()->toArray()['name']);
}
public function testDelete(): void
{
$client = $this->createLoggedInClient();
$client->request('DELETE', '/orders/' . $id);
$this->assertResponseStatusCodeSame(204);
}
}
Testing Validation
public function testCreateWithInvalidData(): void
{
$client = $this->createLoggedInClient();
$client->request('POST', '/orders', [
'json' => ['email' => 'not-valid'],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['propertyPath' => 'email', 'message' => 'This value is not a valid email address.'],
],
]);
}
Testing Authentication
public function testRequiresAuthentication(): void
{
$client = static::createClient();
$client->request('GET', '/orders');
$this->assertResponseStatusCodeSame(401);
}
Multi-Tenant Isolation Tests
Test that users can only access their own resources:
public function testIsolationBetweenUsers(): void
{
$client1 = $this->createLoggedInClient();
$client2 = $this->createSecondUserClient();
$client1->request('POST', '/accounts', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['address' => 'user1@example.com', 'password' => 'Pass123!'],
]);
$account = $client1->getResponse()->toArray();
$client2->request('GET', '/accounts/' . $account['id']);
$this->assertResponseStatusCodeSame(404);
$client2->request('PATCH', '/accounts/' . $account['id'], [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isActive' => false],
]);
$this->assertResponseStatusCodeSame(404);
$client2->request('DELETE', '/accounts/' . $account['id']);
$this->assertResponseStatusCodeSame(404);
$client2->request('GET', '/accounts');
$collection = $client2->getResponse()->toArray();
$ids = array_column($collection['member'], 'id');
$this->assertNotContains($account['id'], $ids);
}
Testing Filters
public function testFilterByStatus(): void
{
$client = $this->createLoggedInClient();
$client->request('GET', '/orders?isActive=true');
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
foreach ($data['member'] as $order) {
$this->assertTrue($order['isActive']);
}
}
Common Assertions
$this->assertResponseIsSuccessful();
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Expected']);
$this->assertMatchesResourceItemJsonSchema(Order::class);
$this->assertMatchesResourceCollectionJsonSchema(Order::class);
Resetting the Schema Between Tests (ORM)
For a clean database per test, drop and recreate the schema in setUp(). API
Platform's own suite uses an internal ApiPlatform\Tests\RecreateSchemaTrait —
that namespace is not autoloaded in your app, so copy the mechanism rather than
importing it:
use Doctrine\ORM\Tools\SchemaTool;
protected function setUp(): void
{
parent::setUp();
$manager = static::getContainer()->get('doctrine')->getManager();
$metadata = $manager->getMetadataFactory()->getAllMetadata();
$schemaTool = new SchemaTool($manager);
$schemaTool->dropDatabase();
$schemaTool->createSchema($metadata);
}
For MongoDB ODM, drop the collections via
$manager->getSchemaManager()->dropDocumentCollection(...) instead.
Test File Location
Place tests in tests/Functional/ following the naming convention {ResourceName}Test.php.
Laravel
Laravel uses its own HTTP test client (PHPUnit Tests\TestCase or Pest), not
ApiTestCase. API Platform ships ApiPlatform\Laravel\Test\ApiTestAssertionsTrait
(adds assertJsonContains(), assertArraySubset(), getIriFromResource()). Use
RefreshDatabase for isolation and model factories for fixtures (not Doctrine
SchemaTool/fixtures).
<?php
namespace Tests\Feature;
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
use App\Models\Book;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BooksTest extends TestCase
{
use RefreshDatabase, ApiTestAssertionsTrait;
public function testCreate(): void
{
$response = $this->postJson('/api/books', ['isbn' => '0099740915', 'title' => 'X']);
$response->assertStatus(201);
$this->assertJsonContains(['title' => 'X'], $response->json());
$book = Book::factory()->create();
$this->getJson($this->getIriFromResource($book))->assertStatus(200);
}
}
Key deltas: request helpers are getJson/postJson/patchJson/deleteJson;
assertions are $response->assertStatus(...), $response->assertHeader(...),
$this->assertDatabaseMissing(...). assertJsonContains() takes the decoded body
($response->json()). Isolation/validation(422)/auth tests use the same status-code
assertions; auth uses Sanctum/actingAs(), not custom headers. Pest is the default
and wraps the same trait via uses(RefreshDatabase::class, ApiTestAssertionsTrait::class).