What you'll learn
Use PHPUnit to build a fast feedback system, not a pile of implementation-coupled assertions. Unit tests protect pure rules; integration tests prove boundaries; feature tests verify the application's public behavior.
By the end of this lesson, you'll be able to:
- Write expressive PHPUnit tests
- Choose unit, integration, or feature scope
- Use data providers and test doubles without over-mocking
Core mental model
Professional PHP is less about memorizing APIs and more about choosing a clear boundary for each responsibility. Use this table as a decision guide while reading the examples.
| Concept | What it protects | Decision rule |
|---|---|---|
| Unit test | Fast rule feedback | Isolate deterministic domain behavior. |
| Integration test | Boundary confidence | Use the real database, filesystem, or adapter. |
| Test double | Controlled collaboration | Fake a boundary you own; do not mock value objects. |
Professional workflow
Build the feature in small, verifiable steps. Each step leaves the system in a state you can test.
- Describe the automated test boundary: its inputs, outputs, invariants, and expected failures.
- Implement the smallest happy path behind an explicit contract.
- Add validation and translate low-level failures into language the caller understands.
- Exercise the boundary with realistic data, then inspect output, logs, and resource cleanup.
- Refactor only after behavior is protected by a repeatable check.
Make the boundary visible
Guided code lab
Test behavior, not private structure
The test describes a price rule through its public contract and covers cases with a data provider.
<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class PercentageDiscountTest extends TestCase
{
#[DataProvider('prices')]
public function test_it_applies_the_discount(int $subtotal, int $expected): void
{
self::assertSame($expected, (new PercentageDiscount(20))->total($subtotal));
}
public static function prices(): array
{
return [[1000, 800], [0, 0], [999, 799]];
}
}Use a purpose-built fake
An in-memory adapter keeps the use-case test fast while preserving repository semantics.
<?php
final class InMemoryCourses implements CourseRepository
{
/** @var array<int, Course> */
public array $items = [];
public function get(int $id): Course
{
return $this->items[$id] ?? throw new CourseNotFound($id);
}
public function save(Course $course): void
{
$this->items[$course->id] = $course;
}
}Production practice
Contract
Name tests as observable rules and arrange only the state required to exercise that rule.
Verification
Run fast tests on every edit and the full database/feature suite in CI using an isolated environment.
Operations
Track flaky tests as defects, make fixtures deterministic, and publish failure artifacts when CI breaks.
Common failure mode
Independent workshop
Test an enrollment use case across capacity, duplicate enrollment, notification, and database rollback scenarios.
Your finished workshop must include:
- Focused domain unit tests
- Repository integration tests
- One full HTTP feature test and a coverage rationale
Definition of done
Recap & quick check
Key takeaways
- Tests are executable behavior specifications.
- Scope should match the risk being tested.
- Fakes work best at owned boundaries.
- Deterministic tests earn trust.
Quick check
1. Which test should use the real SQL engine?
2. What makes a test refactor-friendly?
3. What should happen to flaky tests?
Keep the workshop: later phases deliberately build on these boundaries, so today's small example can become part of your portfolio architecture.