← Back to list

api-testing
by dairectiv
⭐ 0🍴 0📅 Jan 3, 2026
SKILL.md
name: api-testing description: Guide for writing integration tests for API endpoints. Use when creating endpoint tests, testing validation, or asserting JSON responses. allowed-tools: Read, Write, Edit, Glob, Grep
API Testing Guide
This Skill provides patterns for writing integration tests for HTTP API endpoints.
When to Use
- Testing a new API endpoint
- Writing validation tests with DataProviders
- Asserting JSON responses
- Testing domain event dispatching from controllers
Test Class Structure
namespace Dairectiv\Tests\Integration\{Context}\UserInterface\Http\Api\{Aggregate};
use Dairectiv\Tests\Framework\IntegrationTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\HttpFoundation\Response;
#[Group('integration')]
#[Group('{bounded-context}')]
#[Group('api')]
final class {Action}{Aggregate}Test extends IntegrationTestCase
{
// Tests...
}
Required Test Groups
All API tests must have these three groups:
#[Group('integration')] // Integration test category
#[Group('authoring')] // Bounded context (authoring, shared-kernel, etc.)
#[Group('api')] // API endpoint test
HTTP Helper Methods
IntegrationTestCase provides these helpers:
// POST request with JSON body
$this->postJson('/api/authoring/rules', ['name' => 'My Rule', ...]);
// GET request
$this->getJson('/api/authoring/rules/my-rule');
// PUT request with JSON body (full replacement)
$this->putJson('/api/authoring/rules/my-rule', ['name' => 'Updated', ...]);
// PATCH request with JSON body (partial update)
$this->patchJson('/api/authoring/rules/my-rule', ['name' => 'Updated']);
// DELETE request
$this->deleteJson('/api/authoring/rules/my-rule');
Response Assertions
Status Code
self::assertResponseIsSuccessful(); // 2xx
self::assertResponseStatusCodeSame(Response::HTTP_CREATED); // 201
self::assertResponseStatusCodeSame(Response::HTTP_OK); // 200
self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT); // 204
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); // 404
self::assertResponseStatusCodeSame(Response::HTTP_CONFLICT); // 409
self::assertResponseIsUnprocessable(); // 422
JSON Content
// Exact match with PHPMatcher patterns
self::assertResponseReturnsJson([
'id' => 'my-rule',
'name' => 'My Rule',
'description' => '@string@', // Any string
'examples' => '@array@', // Any array
'content' => '@null@', // Null value
'createdAt' => '@string@.isDateTime()', // DateTime string
'state' => '@string@',
]);
Validation Errors (422)
self::assertUnprocessableResponse([
['propertyPath' => 'name', 'title' => 'This value should not be blank.'],
['propertyPath' => 'description', 'title' => 'This value is too long. It should have 500 characters or less.'],
]);
Domain Event Assertions
CRITICAL: All domain events dispatched during a test MUST be asserted.
// Assert event was dispatched exactly once
self::assertDomainEventHasBeenDispatched(DirectiveDrafted::class);
// Assert event was dispatched multiple times
self::assertDomainEventHasBeenDispatched(ExampleAdded::class, times: 2);
If you don't assert all dispatched events, the test will fail in assertPostConditions().
DataProvider Pattern for Validation
/**
* @return iterable<string, array{payload: array<string, mixed>, expectedViolations: list<array{propertyPath: string, title: string}>}>
*/
public static function provideInvalidPayloads(): iterable
{
yield 'empty payload' => [
'payload' => [],
'expectedViolations' => [
['propertyPath' => 'name', 'title' => 'This value should be of type string.'],
['propertyPath' => 'description', 'title' => 'This value should be of type string.'],
],
];
yield 'blank name' => [
'payload' => ['name' => '', 'description' => 'Valid'],
'expectedViolations' => [
['propertyPath' => 'name', 'title' => 'This value should not be blank.'],
],
];
yield 'name too long' => [
'payload' => ['name' => str_repeat('a', 256), 'description' => 'Valid'],
'expectedViolations' => [
['propertyPath' => 'name', 'title' => 'This value is too long. It should have 255 characters or less.'],
],
];
}
/**
* @param array<string, mixed> $payload
* @param array<array{propertyPath: string, title: string}> $expectedViolations
*/
#[DataProvider('provideInvalidPayloads')]
public function testItShouldBeUnprocessable(array $payload, array $expectedViolations): void
{
$this->postJson('/api/authoring/rules', $payload);
self::assertUnprocessableResponse($expectedViolations);
}
Test Naming Convention
Test methods MUST start with testItShould:
// Good
public function testItShouldDraftRule(): void
public function testItShouldReturn404WhenNotFound(): void
public function testItShouldBeUnprocessable(array $payload, ...): void
public function testItShouldBeInConflictDueToRuleAlreadyExists(): void
// Bad
public function testDraftRule(): void
public function test_it_should_draft_rule(): void
Complete Test Example
#[Group('integration')]
#[Group('authoring')]
#[Group('api')]
final class DraftRuleTest extends IntegrationTestCase
{
public function testItShouldDraftRule(): void
{
// Arrange - nothing to set up
// Act
$this->postJson('/api/authoring/rules', [
'name' => 'My Rule',
'description' => 'Description',
]);
// Assert - Response
self::assertResponseIsSuccessful();
self::assertResponseStatusCodeSame(Response::HTTP_CREATED);
self::assertResponseReturnsJson([
'id' => '@string@',
'name' => 'My Rule',
'description' => 'Description',
'state' => 'draft',
'createdAt' => '@string@.isDateTime()',
'updatedAt' => '@string@.isDateTime()',
]);
// Assert - Domain Event
self::assertDomainEventHasBeenDispatched(DirectiveDrafted::class);
}
public function testItShouldReturn404WhenRuleNotFound(): void
{
$this->getJson('/api/authoring/rules/non-existent');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
public function testItShouldReturn409WhenRuleAlreadyExists(): void
{
// Arrange
$rule = self::draftRuleEntity('my-rule');
$this->persistEntity($rule);
// Act
$this->postJson('/api/authoring/rules', [
'name' => 'My Rule',
'description' => 'Description',
]);
// Assert
self::assertResponseStatusCodeSame(Response::HTTP_CONFLICT);
}
}
Entity Helpers
Use persistEntity() to set up test data:
// Create and persist entity
$rule = self::draftRuleEntity('my-rule');
$this->persistEntity($rule);
// Find persisted entity
$persisted = $this->findEntity(Rule::class, ['id' => $id], strict: true);
Override Helper for Partial Payloads
private static function validPayload(): array
{
return [
'name' => 'My Rule',
'description' => 'Description',
];
}
public function testItShouldAcceptMinimalPayload(): void
{
$this->postJson('/api/authoring/rules', self::override(
self::validPayload(),
['name' => 'Different Name'],
));
self::assertResponseIsSuccessful();
}
Testing Priorities
- Use Case Tests (most important) - Test business logic via CommandBus/QueryBus
- API Endpoint Tests - Test HTTP layer, validation, response format
- Repository Tests - Test data persistence
API tests should focus on:
- HTTP status codes
- JSON response structure
- Validation errors
- Domain event dispatching
- Edge cases (not found, conflict)
Checklist
When writing API tests:
- Three test groups:
integration,{bounded-context},api - Test method starts with
testItShould - Happy path tests response status, JSON, and domain events
- Validation tests use DataProvider pattern
- 404 test for entity not found
- 409 test for conflict scenarios (if applicable)
- All dispatched domain events are asserted
Reference Files
api/tests/Framework/IntegrationTestCase.php- Base test class with all helpersapi/tests/Integration/Authoring/UserInterface/Http/Api/Rule/DraftRuleTest.php- POST exampleapi/tests/Integration/Authoring/UserInterface/Http/Api/Rule/GetRuleTest.php- GET exampleapi/tests/Integration/Authoring/UserInterface/Http/Api/Rule/UpdateRuleTest.php- PUT example
Score
Total Score
50/100
Based on repository quality metrics
✓SKILL.md
SKILL.mdファイルが含まれている
+20
○LICENSE
ライセンスが設定されている
0/10
○説明文
100文字以上の説明がある
0/10
○人気
GitHub Stars 100以上
0/15
○最近の活動
3ヶ月以内に更新がある
0/10
○フォーク
10回以上フォークされている
0/5
✓Issue管理
オープンIssueが50未満
+5
✓言語
プログラミング言語が設定されている
+5
○タグ
1つ以上のタグが設定されている
0/5
Reviews
💬
Reviews coming soon