Testing With PHPUnit

Course 3 · Ch 4
Testing With PHPUnit: Unit Tests, Mocking
Automatically verifying code behaves correctly, and isolating it from real dependencies like a database while doing so

Every challenge across this entire series has been verified by reading expected output and reasoning about the code. PHPUnit automates that verification — writing assertions once, then re-running them in seconds, every time the code changes, to catch regressions immediately rather than discovering them later.

Installing PHPUnit

$ composer require --dev phpunit/phpunit # installed under "require-dev" (Chapter 1) — a testing tool, not a production dependency

A First Unit Test

src/Calculator.php
<?php class Calculator { public function add(float $a, float $b): float { return $a + $b; } } ?>
tests/CalculatorTest.php
<?php use PHPUnit\Framework\TestCase; class CalculatorTest extends TestCase { public function testAddReturnsCorrectSum() { $calculator = new Calculator(); $result = $calculator->add(2, 3); $this->assertEquals(5, $result); } } ?>
$ ./vendor/bin/phpunit tests/CalculatorTest.php PHPUnit 10.x . 1 / 1 (100%) OK (1 test, 1 assertion)

A test class extends TestCase (Intermediate Chapter 2's inheritance, applied here). Each method starting with test is run automatically, and $this->assertEquals(expected, actual) checks the result matches — exactly the same exercise as every challenge's "expected output" comment throughout this course, just executed by PHPUnit instead of read by eye.

Common Assertions

AssertionChecks
assertEquals($expected, $actual)Values are equal (loose comparison)
assertSame($expected, $actual)Values AND types match (strict, like ===)
assertTrue() / assertFalse()A value is exactly true / false
assertCount($count, $array)An array has exactly $count elements
assertInstanceOf($class, $obj)An object is an instance of a given class
expectException($class)The tested code throws a specific exception

Testing That an Exception Is Thrown

<?php public function testDivideByZeroThrowsException() { $calculator = new Calculator(); $this->expectException(DivisionByZeroError::class); $calculator->divide(10, 0); // the test PASSES if this line throws; FAILS if it doesn't } ?>

This directly tests the kind of behaviour built in Intermediate Chapter 3 — confirming the right exception type is genuinely thrown under the right conditions, automatically, rather than manually re-checking by eye every time the code changes.

The Problem Mocking Solves

The Intermediate capstone's Post class depends on a real PDO connection. Testing it directly would mean hitting an actual database — slow, and dependent on test data that could change. A mock is a fake stand-in object that behaves exactly as instructed, without needing the real dependency at all.

Mocking a Dependency

<?php use PHPUnit\Framework\TestCase; class PostControllerTest extends TestCase { public function testGreetUserUsesRepositoryCorrectly() { // Create a fake UserRepository (Chapter 2's interface), no real DB involved at all $mockRepo = $this->createMock(UserRepository::class); $mockRepo->method('find') ->willReturn(['name' => 'Philip']); // tell the mock exactly what to return ob_start(); greetUser($mockRepo, 1); $output = ob_get_clean(); $this->assertEquals("Hello, Philip", $output); } } ?>

createMock(UserRepository::class) generates a fake object implementing that interface. ->method('find')->willReturn([...]) programs exactly what calling find() on it should produce — no real database, no real network call, completely predictable and fast. greetUser() from Chapter 2 never needed to be written knowing it would be tested this way — it already depended only on the UserRepository interface, which is precisely what makes mocking it possible at all.

This is exactly why Chapter 2's Repository pattern mattered
Code written against a concrete PDO object directly is much harder to mock cleanly. Code written against an interface (UserRepository) can have any implementation substituted in — a real one in production, a fake/mock one in tests — without the calling code needing to change or even know the difference.

Running the Whole Test Suite

$ ./vendor/bin/phpunit tests/ # or, using a configured composer script (Chapter 1): $ composer test

Coding Challenges

Challenge 1

Write a class StringHelper with a method reverse(string $s): string (using strrev()) and a method isPalindrome(string $s): bool. Write a PHPUnit test class with at least three test methods covering: a normal reverse, a true palindrome case, and a false (non-palindrome) case.

📄 View solution
Challenge 2

Using the Chapter 3 InsufficientFundsException-style BankAccount class from Intermediate Chapter 3, write a PHPUnit test that uses expectException() to confirm withdrawing more than the balance throws InsufficientFundsException, and a second test confirming a valid withdrawal correctly reduces the balance.

📄 View solution
Challenge 3

Using the Strategy pattern's ShoppingCart from Chapter 2, write a test that uses createMock() to create a fake DiscountStrategy whose apply() method is programmed (via willReturn) to always return 50, then asserts that ShoppingCart's checkout() correctly returns 50 regardless of the price passed in. Explain in a comment why this test never touches PercentageDiscount or FixedAmountDiscount at all.

📄 View solution

Chapter 4 Quick Reference

  • composer require --dev phpunit/phpunit — install as a dev dependency
  • extends TestCase, methods starting with "test" are run automatically
  • assertEquals / assertSame / assertTrue / assertCount / assertInstanceOf — common assertions
  • expectException(ClassName::class) — confirms specific code throws the expected exception
  • createMock(InterfaceName::class) — a fake stand-in object, no real dependency needed
  • ->method('name')->willReturn(value) — programs a mock's behaviour
  • Code written against interfaces is far easier to mock than code tied directly to a concrete class
  • Next chapter: security in depth — CSRF, XSS prevention, password hashing