Phase 5 · Professional ApplicationsModule 31~55 min read

Automated Testing with PHPUnit

Write fast unit tests, meaningful integration tests, and reliable feature tests with PHPUnit.

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.

ConceptWhat it protectsDecision rule
Unit testFast rule feedbackIsolate deterministic domain behavior.
Integration testBoundary confidenceUse the real database, filesystem, or adapter.
Test doubleControlled collaborationFake 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.

  1. Describe the automated test boundary: its inputs, outputs, invariants, and expected failures.
  2. Implement the smallest happy path behind an explicit contract.
  3. Add validation and translate low-level failures into language the caller understands.
  4. Exercise the boundary with realistic data, then inspect output, logs, and resource cleanup.
  5. Refactor only after behavior is protected by a repeatable check.

Make the boundary visible

Name inputs, outputs, side effects, and failure cases before adding framework or infrastructure code. That habit keeps advanced PHP understandable as the application grows.

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.

PercentageDiscountTest.php
<?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.

InMemoryCourses.php
<?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

Mocking every internal call freezes today's implementation. A harmless refactor then breaks tests even when public behavior is unchanged.

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

Run the happy path and at least two failure paths, explain one design tradeoff in a short README, and leave the code formatted and ready for review.

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.