Skip to content
Back to skills

Tdd Workflow

ASecurity

Enforces test-driven development: writes tests before code, uses fakes over mocks, maintains 80%+ coverage. Activates when writing new features, fixing bugs, or refactoring PHP code.

  • 5 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 29, 2026
developmentgophpbashtestingrefactoringgitapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 29, 2026

npx -y skills add AppVerk/av-marketplace --skill tdd-workflow --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Tdd Workflow?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Tdd Workflow
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/appverk-tdd-workflow-cf6cf36f/badge)](https://www.skillsdirectory.com/skills/appverk-tdd-workflow-cf6cf36f)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: "php-developer:tdd-workflow"
description: Enforces test-driven development: writes tests before code, uses fakes over mocks, maintains 80%+ coverage. Activates when writing new features, fixing bugs, or refactoring PHP code.
allowed-tools: Read, Grep, Glob, Bash(php:*), Bash(composer:*), Bash(vendor/bin/*), Bash(bin/*), Bash(make:*), Bash(git:*)
---

# Test-Driven Development Workflow

<HARD-RULES>
These rules are NON-NEGOTIABLE. Violating any of them is a bug.

- ALWAYS write tests BEFORE implementation code — no exceptions
- ALWAYS use Fake implementations (in-memory, simplified) for internal dependencies — NEVER use mocks for internal services
- ONLY use mocks for external I/O: 3rd party APIs, real database calls in unit tests, network requests
- NEVER write tests that depend on other tests — each test sets up its own data
- ALWAYS run the full test suite after changes, not just specific tests
- ALWAYS verify 80%+ coverage before considering work complete
- ALWAYS use `#[DataProvider]` for similar test cases instead of duplicating tests
- ALWAYS name tests: `testMethodNameScenarioExpectedBehavior`
- NEVER put imports or class instantiation logic inside test methods — use setUp() or test fixtures
- ALWAYS use `/** @internal */` docblock on test classes
</HARD-RULES>

This skill ensures all code development follows TDD principles with comprehensive test coverage.

## When to Activate

- Writing new features or functionality
- Fixing bugs or issues
- Refactoring existing code
- Adding API endpoints
- Creating new services or command handlers

## Core Principles

### 1. Tests BEFORE Code

ALWAYS write tests first, then implement code to make tests pass.

### 2. Coverage Requirements

- Minimum 80% coverage (unit + functional + integration)
- All edge cases covered
- Error scenarios tested
- Boundary conditions verified

### 3. Test Types

#### Unit Tests
- Individual functions and methods
- Service/handler logic
- Pure functions
- Domain model behavior

#### Functional Tests
- Black-box testing of the API endpoints
- Full request/response cycle with faked dependencies
- Extends `WebTestCase` or custom `JwtApiTestCase`

#### Integration Tests
- Database operations via Doctrine
- Service interactions with real dependencies
- Message bus dispatching

## TDD Workflow Steps

### Step 1: Write Test Cases

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Order\Application\Command;

use App\Order\Application\Command\CreateOrderCommand;
use App\Order\Application\Command\CreateOrderCommandHandler;
use App\Order\Domain\Order;
use App\Order\Domain\OrderRepositoryInterface;
use App\Tests\Fakes\FakeOrderRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\Uuid;

/**
 * @internal
 */
class CreateOrderCommandHandlerTest extends TestCase
{
    private FakeOrderRepository $repository;
    private CreateOrderCommandHandler $handler;

    protected function setUp(): void
    {
        $this->repository = new FakeOrderRepository();
        $this->handler = new CreateOrderCommandHandler($this->repository);
    }

    public function testCreateOrderValidDataOrderCreated(): void
    {
        $command = new CreateOrderCommand(
            name: 'Test Order',
            customerId: Uuid::v4(),
        );

        ($this->handler)($command);

        $orders = $this->repository->findAll();
        self::assertCount(1, $orders);
        self::assertSame('Test Order', $orders[0]->getName());
    }

    public function testCreateOrderDuplicateNameThrowsException(): void
    {
        $this->repository->addExisting(
            Order::create(id: Uuid::v4(), name: 'Existing Order', customerId: Uuid::v4())
        );

        $this->expectException(OrderAlreadyExistsException::class);

        ($this->handler)(new CreateOrderCommand(
            name: 'Existing Order',
            customerId: Uuid::v4(),
        ));
    }
}
```

### Step 2: Run Tests (They Should Fail)

```bash
vendor/bin/phpunit
# Tests should fail — we haven't implemented yet
```

### Step 3: Implement Code

Write minimal code to make tests pass.

### Step 4: Run Tests Again

```bash
vendor/bin/phpunit
# Tests should now pass
```

### Step 5: Refactor

Improve code quality while keeping tests green.

### Step 6: Verify Coverage

```bash
vendor/bin/phpunit --coverage-text --coverage-html=var/coverage
# Verify 80%+ coverage achieved
```

## Test File Organization

```
tests/
├── Unit/
│   ├── Order/
│   │   ├── Application/
│   │   │   └── Command/
│   │   │       └── CreateOrderCommandHandlerTest.php
│   │   ├── Domain/
│   │   │   └── OrderTest.php
│   │   └── Infrastructure/
│   │       └── OrderNumberGeneratorTest.php
│   ├── [OtherContext]/
│   └── Shared/
├── Functional/
│   ├── Order/
│   │   └── Controller/
│   │       └── OrderControllerTest.php
│   └── [OtherContext]/
├── Integration/
│   ├── Order/
│   │   └── Repository/
│   │       └── DoctrineOrderRepositoryTest.php
│   └── [OtherContext]/
├── Fakes/
│   ├── FakeOrderRepository.php
│   ├── FakeEventBus.php
│   └── FakeEmailService.php
├── Cases/
│   ├── JwtApiTestCase.php
│   └── DatabaseTestCase.php
└── bootstrap.php
```

**Mirroring rule:** `src/Order/Application/Command/CreateOrderCommandHandler.php` → `tests/Unit/Order/Application/Command/CreateOrderCommandHandlerTest.php`

## Test Doubles: Fakes vs Mocks

### Prefer Fakes Over Mocks

```php
<?php

declare(strict_types=1);

namespace App\Tests\Fakes;

use App\Order\Domain\Order;
use App\Order\Domain\OrderRepositoryInterface;
use Symfony\Component\Uid\Uuid;

class FakeOrderRepository implements OrderRepositoryInterface
{
    /** @var array<string, Order> */
    private array $orders = [];

    public function save(Order $order): void
    {
        $this->orders[$order->getId()->toRfc4122()] = $order;
    }

    public function findById(Uuid $id): ?Order
    {
        return $this->orders[$id->toRfc4122()] ?? null;
    }

    public function findByName(string $name): ?Order
    {
        foreach ($this->orders as $order) {
            if ($order->getName() === $name) {
                return $order;
            }
        }

        return null;
    }

    /** @return list<Order> */
    public function findAll(): array
    {
        return array_values($this->orders);
    }

    public function addExisting(Order $order): void
    {
        $this->orders[$order->getId()->toRfc4122()] = $order;
    }
}
```

### When to Use Each

| Scenario | Use |
|----------|-----|
| Repository/data access | Fake (in-memory implementation) |
| Business logic dependencies | Fake (simplified implementation) |
| Event bus / message bus | Fake (records dispatched messages) |
| 3rd party API calls (payment gateway, email) | Mock |
| Database operations in integration tests | Test database with rollback |
| External HTTP requests | Mock or fake HTTP client |

### Mocks Only for External I/O

```php
public function testSendsEmailOnOrderCreation(): void
{
    $mailer = $this->createMock(MailerInterface::class);
    $mailer->expects(self::once())
        ->method('send')
        ->with(self::callback(fn (Email $email) =>
            $email->getTo()[0]->getAddress() === 'customer@example.com'
        ));

    $handler = new CreateOrderCommandHandler($this->repository, $mailer);
    ($handler)($this->createCommand());
}
```

## DataProvider Pattern

```php
/**
 * @internal
 */
class OrderValidationTest extends TestCase
{
    #[DataProvider('invalidOrderDataProvider')]
    public function testCreateOrderInvalidDataThrowsException(
        string $name,
        ?Uuid $customerId,
        string $expectedMessage,
    ): void {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage($expectedMessage);

        Order::create(id: Uuid::v4(), name: $name, customerId: $customerId);
    }

    /** @return \Generator<string, array{string, ?Uuid, string}> */
    public static function invalidOrderDataProvider(): \Generator
    {
        yield 'empty name' => ['', Uuid::v4(), 'Name cannot be empty'];
        yield 'name too long' => [str_repeat('a', 256), Uuid::v4(), 'Name too long'];
        yield 'null customer' => ['Valid Name', null, 'Customer ID required'];
    }
}
```

## Functional Test Pattern

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional\Order\Controller;

use App\Tests\Cases\JwtApiTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * @internal
 */
class OrderControllerTest extends JwtApiTestCase
{
    private string $baseUrl = '/api/orders';

    public function testCreateOrderValidDataReturnsNoContent(): void
    {
        $response = $this->makeAuthorizedRequest(
            Request::METHOD_POST,
            $this->baseUrl,
            'admin@example.com',
            [
                'name' => 'Test Order',
                'customer_id' => '9c36e9e6-427e-4e88-976b-976e23ed508f',
            ],
        );

        self::assertResponseCode($response, Response::HTTP_NO_CONTENT);
    }

    public function testCreateOrderUnauthorizedReturnsForbidden(): void
    {
        $response = $this->makeAuthorizedRequest(
            Request::METHOD_POST,
            $this->baseUrl,
            'viewer@example.com',
            ['name' => 'Test Order'],
        );

        self::assertResponseCode($response, Response::HTTP_FORBIDDEN);
    }

    public function testListOrdersReturnsOrders(): void
    {
        $response = $this->makeAuthorizedRequest(
            Request::METHOD_GET,
            $this->baseUrl,
            'admin@example.com',
        );

        self::assertResponseCode($response, Response::HTTP_OK);
        $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
        self::assertIsArray($data);
    }
}
```

## Best Practices

1. **Write Tests First** — Always TDD
2. **One Assertion Per Concept** — Focus on single behavior
3. **Descriptive Test Names** — `testMethodNameScenarioExpectedBehavior`
4. **Arrange-Act-Assert** — Clear test structure
5. **Use Fakes for Dependencies** — Prefer fakes over mocks
6. **Mock Only External I/O** — 3rd party APIs, network calls
7. **Test Edge Cases** — Null, empty, boundary values
8. **Test Error Paths** — Not just happy paths
9. **Keep Tests Fast** — Unit tests < 50ms each
10. **Clean Up After Tests** — No side effects between tests

## Test Code Standards

- Use `self::assert*` instead of `$this->assert*` (static methods).
- Use `/** @internal */` docblock on all test classes.
- Use setUp() for shared test fixtures, not in-test construction.
- Use `#[DataProvider]` attribute (not `@dataProvider` annotation).
- Test method visibility: public for test methods, private for helpers.
- Prefer `self::assertSame()` over `self::assertEquals()` for strict comparison.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.