Contents
- 1 PHP unit testing explained
- 2 Why bother with unit tests in PHP?
- 3 Setting up PHPUnit: Your first green light
- 4 The AAA pattern: Structure your tests like a story
- 5 Assertions: PHPUnit's truth serum
- 6 Leveling up: Data providers and fixtures
- 7 Best practices: FIRST principles and beyond
- 8 Mocks, stubs, and test doubles
- 9 Running and organizing: CI-ready suites
- 10 Common pitfalls and how to dodge them
- 11 Real-world PHP: Laravel, Symfony synergy
- 12 Wrapping the journey
PHP unit testing explained
Fellow developers, picture this: it's 2 AM, the office is dead quiet except for your keyboard clacking and the hum of your cooling fan. You've just pushed a feature that's been eating your weekends, but then production crashes. A tiny method, one you swore worked in dev, fails spectacularly under load. Heart sinks. Fingers itch to rewrite everything. Sound familiar?
That's the moment unit testing saves you—or wishes you had it. PHP unit testing, powered mostly by PHPUnit, isn't some checkbox for the resume. It's your quiet guardian, catching bugs before they bite, letting you refactor fearlessly, and turning "it works on my machine" into a relic. Today, we're diving deep: from first steps to pro habits that stick. Grab coffee. Let's build code that lasts.
Why bother with unit tests in PHP?
You know the drill. PHP powers 77% of websites—e-commerce giants, blogs, enterprise backends. But without tests, every change is Russian roulette. Unit tests isolate tiny code chunks (methods, functions) and verify they do exactly what you expect. No database flakiness. No network calls. Just pure logic.
Benefits hit hard:
- Catch bugs early: Failures scream during dev, not in prod alerts at midnight.
- Refactor confidently: Tweak that legacy class? Tests ensure nothing breaks.
- Document behavior: New dev joins? Tests read like living specs.
- Faster feedback: Run suite in seconds, not hours.
I remember shipping a payment gateway without tests. One edge case—negative amounts—and refunds went haywire. Clients furious. Lesson learned the expensive way. Now, every PHP project starts with tests. PHPUnit dominates here: mature, feature-rich, integrates with Composer, CI like GitHub Actions. It's the gold standard.
Setting up PHPUnit: Your first green light
Installation's a breeze with Composer. In your project root:
composer require --dev phpunit/phpunit
That's it. PHPUnit lands in vendor/bin. Create a simple class to test, say src/Calculator.php:
<?php
declare(strict_types=1);
class Calculator {
public function add(int $a, int $b): int {
return $a + $b;
}
public function subtract(int $a, int $b): int {
return $a - $b;
}
}
Now, the test: tests/Unit/CalculatorTest.php. Extend PHPUnit\Framework\TestCase. Boom, you're in business.
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class CalculatorTest extends TestCase {
public function testAddition(): void {
$calc = new Calculator();
$result = $calc->add(2, 3);
$this->assertSame(5, $result);
}
public function testSubtraction(): void {
$calc = new Calculator();
$result = $calc->subtract(5, 3);
$this->assertSame(2, $result);
}
}
Run it: ./vendor/bin/phpunit tests/Unit. Green dots flood your terminal. First win. Feels good, right? But we're just warming up.
The AAA pattern: Structure your tests like a story
Every solid test follows AAA: Arrange, Act, Assert. It's rhythm, not ritual. Keeps tests readable, debuggable.
- Arrange: Set the stage. Mock data, instantiate SUT (System Under Test).
- Act: Fire the method. Capture output.
- Assert: Verify results. Fail loud if wrong.
From that YouTube deep-dive: test a calculator's add(2, 3). Arrange: $calc = new Calculator();. Act: $result = $calc->add(2, 3);. Assert: $this->assertEquals(5, $result);. Simple. Scalable.
Blank lines separate phases. No spaghetti. Here's a real-world twist—testing exceptions:
public function testSubtractThrowsOnNegativeResult(): void {
// Arrange
$calc = new Calculator();
// Act & Assert (PHPUnit expects exception first)
$this->expectException(InvalidArgumentException::class);
$calc->subtract(1, 5);
}
Order flexes for exceptions: Arrange, Expect, Act. Clean.
Have you ever stared at a failing test, wondering where it broke? AAA ends that nightmare.
Assertions: PHPUnit's truth serum
PHPUnit packs 100+ assertions. Pick the right one—precision matters.
Key players:
$this->assertSame(5, $result): Strict equality (types too). Better thanassertEqualsfor PHP's quirks.$this->assertTrue($bool),$this->assertFalse($not): Boolean checks.$this->assertNull($var),$this->assertCount(3, $array): Nulls, counts.$this->assertContains('needle', $haystack): Arrays, strings.$this->assertInstanceOf(User::class, $obj): Object types.
For arrays: $this->assertSame($expectedArray, $actual);. Deep comparison, no fluff.
Pro tip: assertSame over assertEquals. Catches type coercion bugs, like '5' == 5 passing falsely. Declare strict_types=1 everywhere—tests included.
Leveling up: Data providers and fixtures
Tests repeating? DRY it with data providers. One method, many inputs. Magic for edge cases.
/**
* @dataProvider additionProvider
*/
public function testAddition(int $a, int $b, int $expected): void {
$calc = new Calculator();
$this->assertSame($expected, $calc->add($a, $b));
}
public static function additionProvider(): array {
return [
'basic' => [2, 3, 5],
'zeroes' => [0, 0, 0],
'negative' => [-1, 1, 0],
];
}
PHPUnit spins three instances—one per row. Isolation guaranteed.
Fixtures? setUp() and tearDown(). Run before/after each test. Perfect for shared setup.
protected Calculator $calc;
protected function setUp(): void {
$this->calc = new Calculator();
}
protected function tearDown(): void {
unset($this->calc);
}
Factory methods shine for complex SUTs. Private createSutWithConfig(array $config): Calculator centralizes logic. No drift.
Best practices: FIRST principles and beyond
Great tests aren't born; they're forged. Follow FIRST:
- Fast: Under 100ms per test. Mock externalities. No DB hits in units.
- Isolated: One failure doesn't cascade. Fresh instance per test.
- Repeatable: No random seeds, globals, or time(). Seed mocks predictably.
- Self-validating: Assertions only. No manual checks.
- Timely: Test as you code. TDD if you're brave.
More gems:
- Final classes: Tests don't extend.
final class MyTest extends TestCase. - Attributes:
#[CoversClass(Calculator::class)]pins scope.#[Small],#[Medium]categorize speed. - Snake_case methods:
test_it_adds_two_positives. With#[TestDox], reads like prose. - No logic in tests: Loops? Data providers. Ifs? Split tests.
- Mock aggressively:
$mock = $this->createMock(DB::class); $mock->expects($this->once())->method('save');. Isolate.
Descriptive names: test_it_accepts_null_for_optional_field. Self-explanatory.
Avoid globals like $_SESSION. Dependency injection rules: inject SessionInterface.
Mocks, stubs, and test doubles
Real power: isolating. PHPUnit's mocking framework fakes dependencies.
Stub for values: $stub->method('getUser')->willReturn(new User());.
Mock for behavior: Verify calls.
public function testUserServiceSavesToRepo(): void {
// Arrange
$mockRepo = $this->createMock(UserRepository::class);
$mockRepo->expects($this->once())
->method('save')
->with($this->user);
$service = new UserService($mockRepo);
// Act
$service->createUser($this->user);
// Assert (implicit via mock)
}
Test interactions, not just outputs. "Did it call save once?" Gold for controllers, services.
Running and organizing: CI-ready suites
PHPUnit shines in suites. phpunit.xml:
<phpunit>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<php>
<ini name="memory_limit" value="256M"/>
</php>
</phpunit>
Commands:
./vendor/bin/phpunit --testsuite unit: Units only.--stop-on-failure: Halt on first red.--testdox: Human-readable output.
In dev: --filter calculator for quick loops. CI? Full suite.
Coverage: --coverage-html report/. Aim 80%+. Tools flag gaps.
Common pitfalls and how to dodge them
- Slow tests: Smells like integration. Mock DB, APIs.
- Flaky tests: Rand, time, files. Fix or delete.
- Over-testing: Implementation details? Skip. Behavior only.
- No strict types: Coercion hides bugs.
Refactor tests too. Ugly suite? Tech debt.
Real-world PHP: Laravel, Symfony synergy
PHPUnit plugs into frameworks seamlessly. Laravel's phpunit.xml auto-discovers. Symfony's Maker bundle spits tests. Same principles.
In WooCommerce extensions: Group tests, filter by keyword. WordPress auto-rollbacks DB—no tearDown pain.
Flight PHP? Ditch statics for DI. Testable bliss.
Wrapping the journey
We've covered setup, AAA, assertions, providers, mocks, best practices. But testing's heart? Discipline. Start small: one class per feature. Watch confidence grow.
Late nights debugging? Fewer now. That sinking prod feeling? Gone. Unit tests don't just verify code—they verify your sanity.
Next project, write the test first. Feel the shift. Your future self—and team—will thank you in the quiet glow of a green terminal.