Agent Skill
2/7/2026tdd-php
Test-Driven Development for PHP with PHPUnit and Behat. Use this skill when writing tests, implementing features using TDD, creating unit tests, integration tests, or acceptance tests for the backend.
J
jakubciszak
0GitHub Stars
2Views
npx skills add jakubciszak/family-plan
SKILL.md
| Name | tdd-php |
| Description | Test-Driven Development for PHP with PHPUnit and Behat. Use this skill when writing tests, implementing features using TDD, creating unit tests, integration tests, or acceptance tests for the backend. |
name: tdd-php description: Test-Driven Development for PHP with PHPUnit and Behat. Use this skill when writing tests, implementing features using TDD, creating unit tests, integration tests, or acceptance tests for the backend.
TDD PHP Skill - Test Driven Development
This skill enforces Test-Driven Development practices for the Family Plan PHP backend using PHPUnit and Behat.
TDD Cycle: Red-Green-Refactor
┌─────────────────────────────────────────┐
│ 1. RED: Write a failing test │
│ ↓ │
│ 2. GREEN: Write minimal code to pass │
│ ↓ │
│ 3. REFACTOR: Improve code quality │
│ ↓ │
│ (Repeat) │
└─────────────────────────────────────────┘
CRITICAL: Always write the test FIRST, before any production code.
Test Types and Locations
| Type | Location | Purpose | Tools |
|---|---|---|---|
| Unit | tests/Unit/ | Test single class in isolation | PHPUnit |
| Integration | tests/Integration/ | Test component interactions | PHPUnit + Doctrine |
| API | tests/Api/ | Test REST endpoints | PHPUnit + WebTestCase |
| Acceptance | features/ + tests/Acceptance/ | BDD user scenarios | Behat |
PHPUnit Test Patterns
Unit Test Example
declare(strict_types=1);
namespace App\Tests\Unit\TaskManagement\Domain\Entity;
use App\Shared\Domain\ValueObject\Uuid;
use App\TaskManagement\Domain\Entity\Task;
use PHPUnit\Framework\TestCase;
final class TaskTest extends TestCase
{
public function testCreateTaskWithValidData(): void
{
// Arrange
$id = Uuid::generate();
$name = 'Clean the room';
// Act
$task = Task::create($id, $name);
// Assert
$this->assertTrue($id->equals($task->id()));
$this->assertSame($name, $task->name());
}
public function testCannotCreateTaskWithEmptyName(): void
{
// Arrange
$id = Uuid::generate();
// Assert
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Task name cannot be empty');
// Act
Task::create($id, '');
}
}
Test Data Builders (Mother Pattern)
Located in tests/*/Mother/:
declare(strict_types=1);
namespace App\Tests\Unit\TaskManagement\Mother;
use App\Shared\Domain\ValueObject\Uuid;
use App\TaskManagement\Domain\Entity\Task;
final class TaskMother
{
public static function create(
?Uuid $id = null,
?string $name = null
): Task {
return Task::create(
$id ?? Uuid::generate(),
$name ?? 'Default Task Name'
);
}
public static function withName(string $name): Task
{
return self::create(name: $name);
}
}
Integration Test Example
declare(strict_types=1);
namespace App\Tests\Integration\TaskManagement\Infrastructure;
use App\Shared\Domain\ValueObject\Uuid;
use App\TaskManagement\Domain\Entity\Task;
use App\TaskManagement\Infrastructure\Doctrine\DoctrineTaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class DoctrineTaskRepositoryTest extends KernelTestCase
{
private DoctrineTaskRepository $repository;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$container = static::getContainer();
$this->entityManager = $container->get(EntityManagerInterface::class);
$this->repository = $container->get(DoctrineTaskRepository::class);
$this->entityManager->beginTransaction();
}
protected function tearDown(): void
{
$this->entityManager->rollback();
parent::tearDown();
}
public function testSaveAndRetrieveTask(): void
{
// Arrange
$task = Task::create(Uuid::generate(), 'Test Task');
// Act
$this->repository->save($task);
$this->entityManager->clear();
$retrieved = $this->repository->findById($task->id());
// Assert
$this->assertNotNull($retrieved);
$this->assertTrue($task->id()->equals($retrieved->id()));
}
}
API Test Example
declare(strict_types=1);
namespace App\Tests\Api\TaskManagement;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
final class TaskApiTest extends WebTestCase
{
public function testCreateTaskReturns201(): void
{
// Arrange
$client = static::createClient();
$this->authenticateClient($client);
// Act
$client->request('POST', '/api/tasks', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'name' => 'New Task',
'teamId' => 'valid-team-uuid'
]));
// Assert
$this->assertResponseStatusCodeSame(Response::HTTP_CREATED);
}
public function testCreateTaskWithoutAuthReturns401(): void
{
// Arrange
$client = static::createClient();
// Act
$client->request('POST', '/api/tasks');
// Assert
$this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
}
private function authenticateClient($client): void
{
// Add authentication logic
}
}
Behat Acceptance Tests
Feature File (features/task_management/create_task.feature)
Feature: Create Task
As a team member
I want to create a task
So that I can track work to be done
Background:
Given I am authenticated as a team member
Scenario: Successfully create a task
When I create a task with name "Clean the kitchen"
Then the task should be created successfully
And the task should appear in my task list
Scenario: Cannot create task without name
When I try to create a task without a name
Then I should see an error "Task name is required"
Scenario: Cannot create task for another team
Given there is another team "Neighbors"
When I try to create a task for team "Neighbors"
Then I should see an error "Access denied"
Context Class (tests/Acceptance/TaskManagement/CreateTaskContext.php)
declare(strict_types=1);
namespace App\Tests\Acceptance\TaskManagement;
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Symfony\Component\HttpFoundation\Response;
final class CreateTaskContext implements Context
{
private ?Response $response = null;
public function __construct(
private readonly ApiClient $apiClient
) {}
/**
* @When I create a task with name :name
*/
public function iCreateATaskWithName(string $name): void
{
$this->response = $this->apiClient->post('/api/tasks', [
'name' => $name
]);
}
/**
* @Then the task should be created successfully
*/
public function theTaskShouldBeCreatedSuccessfully(): void
{
Assert::assertEquals(201, $this->response->getStatusCode());
}
}
Running Tests
# All PHPUnit tests
make phpunit
# or: docker compose exec php vendor/bin/phpunit
# Specific test file
docker compose exec php vendor/bin/phpunit --filter=TaskTest
# Specific test method
docker compose exec php vendor/bin/phpunit --filter=testCreateTaskWithValidData
# All Behat tests
make behat
# or: docker compose exec php vendor/bin/behat
# Specific Behat suite
docker compose exec php vendor/bin/behat --suite=task_management
# All backend tests
make backend-test
Test Naming Conventions
- Test classes:
{ClassName}Test.php - Test methods:
test{WhatIsBeingTested}{ExpectedBehavior} - Mother classes:
{ClassName}Mother.php - Feature files:
snake_case.feature
Examples:
testCreateTaskWithValidData- success scenariotestCannotCreateTaskWithEmptyName- failure scenariotestReturns404WhenTaskNotFound- specific return behavior
Assertions Best Practices
// Preferred: specific assertions
$this->assertSame('expected', $actual);
$this->assertTrue($condition);
$this->assertInstanceOf(Task::class, $result);
$this->assertCount(3, $items);
// For exceptions
$this->expectException(TaskNotFoundException::class);
$this->expectExceptionMessage('Task not found');
// For JSON responses
$this->assertJsonStringEqualsJsonString($expected, $actual);
TDD Workflow Checklist
- Write a failing test (RED)
- Run the test - confirm it fails
- Write minimal code to pass (GREEN)
- Run the test - confirm it passes
- Refactor code (REFACTOR)
- Run all tests - confirm nothing broke
- Repeat for next feature
Important Rules
- Never skip the RED phase - if your test passes immediately, something is wrong
- One assertion per test (when practical) - makes failures clear
- Test behavior, not implementation - tests should survive refactoring
- Use descriptive test names - they serve as documentation
- Keep tests independent - no shared state between tests
- Fast tests - unit tests should run in milliseconds
Skills Info
Original Name:tdd-phpAuthor:jakubciszak
Download