Lesson 45 • Advanced
PHPUnit Testing 🧪
By the end of this lesson you'll install PHPUnit with Composer and write real automated tests — assertions, data providers, setup/teardown, and mocks — so you can refactor and ship PHP with confidence instead of crossing your fingers.
What You'll Learn in This Lesson
- Install PHPUnit with Composer and run it from vendor/bin/phpunit
- Write a TestCase class with public test methods
- Assert outcomes with assertEquals, assertSame, assertTrue and expectException
- Share setup with setUp() and clean up with tearDown()
- Run one test against many inputs using a data provider
- Replace databases and mailers with createMock() test doubles
composer require --dev phpunit/phpunit, save the classes and tests, then run ./vendor/bin/phpunit tests/. The Output panel under each example shows exactly what a passing run looks like.1️⃣ Installing PHPUnit with Composer
PHPUnit is the standard testing framework for PHP — a tool that runs your tests and tells you which passed and which failed. You install it with Composer (PHP's package manager) as a dev dependency, meaning it's available while you build but never shipped to your live server. After installing, Composer places a runnable program at vendor/bin/phpunit.
# 1) From your project root, pull PHPUnit in as a DEV-only dependency.
# --dev keeps it out of production: tests never ship to your live server.
composer require --dev phpunit/phpunit
# 2) Composer drops the runner here. Check it works:
./vendor/bin/phpunit --version
# 3) A minimal phpunit.xml tells the runner where your tests live.
# Create it once, in the project root, next to composer.json.Using version ^11.0 for phpunit/phpunit
./composer.json has been updated
Running composer update phpunit/phpunit
...
Generating autoload files
PHPUnit 11.0.0 by Sebastian Bergmann and contributors.One tiny config file tells the runner where your tests live, so you can just type ./vendor/bin/phpunit with no arguments. By convention, source code goes in src/ and tests go in tests/.
<?xml version="1.0"?>
<!-- phpunit.xml — sits in your project root, read automatically by the runner -->
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<!-- Every *Test.php file under tests/ is collected and run -->
<testsuite name="app">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>phpunit.xml in your project root. The bootstrap line loads Composer's autoloader so your classes are found automatically.2️⃣ Your First TestCase
A test is just a class that extends TestCase, with one or more public methods whose names start with test. Inside each method you follow Arrange-Act-Assert: build what you need, call the one thing you're testing, then check the result with an assertion. An assertion is a line that says "this had better be true" — if it isn't, the test fails. To check that code throws, you call expectException() before the line that should throw.
<?php
// src/Calculator.php — the code under test (the "system under test", or SUT)
class Calculator
{
public function add(int $a, int $b): int { return $a + $b; }
public function divide(float $a, float $b): float
{
// Guard clause: dividing by zero is a programmer error, so throw.
if ($b === 0.0) {
throw new InvalidArgumentException("Division by zero");
}
return $a / $b;
}
}
// tests/CalculatorTest.php — every test class extends PHPUnit's TestCase
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase
{
// A test method is public and its name starts with "test".
public function testAddsTwoNumbers(): void
{
$calc = new Calculator(); // Arrange — build what you need
$result = $calc->add(2, 3); // Act — call the one thing under test
$this->assertSame(5, $result); // Assert — check the result is exactly 5
}
public function testThrowsWhenDividingByZero(): void
{
$calc = new Calculator();
// Tell PHPUnit you EXPECT this exception. The test fails if it never throws.
$this->expectException(InvalidArgumentException::class);
$calc->divide(10, 0); // this line must throw
}
}PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 00:00.008, Memory: 6.00 MB
OK (2 tests, 2 assertions)src/ and the test in tests/, then run ./vendor/bin/phpunit tests/. The two dots .. mean two passing tests.Read the output bottom-up: OK (2 tests, 2 assertions) means both methods ran and every assertion held. Each dot in .. is one passing test; a failure shows as F, an error as E.
3️⃣ The Assertions You'll Use Most
PHPUnit ships dozens of assertions, but a handful cover nearly everything. The key distinction is assertEquals versus assertSame: assertEquals compares values loosely (so 5 equals "5"), while assertSame compares value and type strictly (like ===). Reach for assertSame by default — it catches accidental string-vs-int bugs. The exception is floats, where rounding means you use assertEqualsWithDelta.
<?php
use PHPUnit\Framework\TestCase;
class AssertionTourTest extends TestCase
{
public function testTheFourAssertionsYouUseMost(): void
{
// assertEquals — equal VALUES, type is coerced ("5" == 5 passes)
$this->assertEquals(5, "5"); // ✅ passes (loose)
// assertSame — equal value AND type, no coercion (5 === 5)
$this->assertSame(5, 2 + 3); // ✅ passes (strict)
// $this->assertSame(5, "5"); // ❌ would FAIL: int vs string
// assertTrue / assertFalse — for boolean results
$this->assertTrue(is_int(42)); // ✅
$this->assertFalse(empty([1, 2])); // ✅
// assertCount — how many items in an array/Countable
$this->assertCount(3, [10, 20, 30]); // ✅
// For floats, never use assertSame — rounding makes 0.1+0.2 != 0.3.
$this->assertEqualsWithDelta(0.3, 0.1 + 0.2, 0.0001); // ✅
}
}PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.006, Memory: 6.00 MB
OK (1 test, 6 assertions)4️⃣ setUp() and tearDown(): Fresh State Every Test
Tests must be isolated — each one runs as if it were the only test in the world. PHPUnit helps by calling setUp() before every test method, so you can build a fresh object there instead of repeating yourself. The matching tearDown() runs after every test to undo side effects (delete temp files, roll back a database). Because setUp() re-runs each time, no test can leak state into the next.
<?php
use PHPUnit\Framework\TestCase;
class ShoppingCart
{
private array $items = [];
public function add(string $name, float $price): void { $this->items[] = $price; }
public function total(): float { return array_sum($this->items); }
public function count(): int { return count($this->items); }
}
class ShoppingCartTest extends TestCase
{
private ShoppingCart $cart;
// setUp() runs BEFORE every test method — your fresh "Arrange" step.
// A brand-new cart per test means tests can never leak state into each other.
protected function setUp(): void
{
$this->cart = new ShoppingCart();
}
// tearDown() runs AFTER every test — close files, drop temp data, etc.
protected function tearDown(): void
{
// Nothing to clean here, but this is where you'd undo side effects.
}
public function testStartsEmpty(): void
{
$this->assertSame(0, $this->cart->count()); // fresh cart
$this->assertSame(0.0, $this->cart->total());
}
public function testSumsPrices(): void
{
// This $cart is a DIFFERENT object from the one above — setUp re-ran.
$this->cart->add("Widget", 9.99);
$this->cart->add("Gadget", 5.01);
$this->assertSame(2, $this->cart->count());
$this->assertEqualsWithDelta(15.00, $this->cart->total(), 0.001);
}
}PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 00:00.007, Memory: 6.00 MB
OK (2 tests, 4 assertions)$this->cart because setUp() runs before each. Order never matters.Now you try. The test class below is almost complete — fill in each ___ with the right assertion name using the 👉 hint, then run it and check it against the Output panel.
<?php
use PHPUnit\Framework\TestCase;
class StringHelper
{
public static function shout(string $s): string { return strtoupper($s) . "!"; }
public static function words(string $s): array { return explode(" ", $s); }
}
class StringHelperTest extends TestCase
{
// 🎯 YOUR TURN — fill in each blank marked ___ , then run it.
public function testShoutUppercasesAndAddsBang(): void
{
$result = StringHelper::shout("hi");
// 1) shout("hi") returns the string "HI!". Assert the exact string.
$this->___("HI!", $result); // 👉 use the STRICT assertion (value + type)
}
public function testWordsSplitsOnSpaces(): void
{
$words = StringHelper::words("one two three");
// 2) There are three words — assert how many items are in the array.
$this->___(3, $words); // 👉 the assertion that counts array items
}
// ✅ Expected output:
// OK (2 tests, 2 assertions)
}PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
OK (2 tests, 2 assertions)OK (2 tests, 2 assertions).5️⃣ Data Providers: One Test, Many Inputs
When you want to test the same logic against lots of inputs — valid and invalid emails, edge-case numbers — don't copy-paste the test. A data provider is a static method that returns an array of rows; each row is the arguments for one run. You attach it with the #[DataProvider('methodName')] attribute, and PHPUnit runs the test once per row, reporting each as its own pass or fail. Adding a new case becomes a one-line change.
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
class Validator
{
public static function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
}
class ValidatorTest extends TestCase
{
// A data provider is a method that RETURNS rows of arguments.
// Each row => one run of the test below, so 4 rows = 4 separate test cases.
public static function emailCases(): array
{
// 'label' => [input, expectedResult]
return [
'plain address' => ['alice@example.com', true],
'subdomain' => ['bob@mail.example.co', true],
'missing @' => ['notanemail', false],
'empty string' => ['', false],
];
}
// The attribute links the provider to the test. The test's parameters
// receive each row's values in order: $email, then $expected.
#[DataProvider('emailCases')]
public function testValidatesEmails(string $email, bool $expected): void
{
$this->assertSame($expected, Validator::isValidEmail($email));
}
}PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
.... 4 / 4 (100%)
Time: 00:00.009, Memory: 6.00 MB
OK (4 tests, 4 assertions)testValidatesEmails method, but four rows means four tests. Naming the rows ('missing @') makes failures readable.Your turn again. Below, a plain test needs to become data-driven. Fill in the expected numbers and the missing attribute name.
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
class Tax
{
public static function withVat(float $net): float { return round($net * 1.2, 2); }
}
class TaxTest extends TestCase
{
// 🎯 YOUR TURN — turn this single test into a data-driven one.
// 1) Return three rows: [net, expectedGross]. 20% VAT, so 10 -> 12.0
public static function vatCases(): array
{
return [
'ten pounds' => [10.0, 12.0],
'twenty pounds' => [20.0, ___], // 👉 20 + 20% = ?
'zero' => [0.0, ___], // 👉 0 + 20% = ?
];
}
// 2) Attach the provider so this test runs once PER row above.
#[___('vatCases')] // 👉 the attribute that links a provider
public function testAddsVat(float $net, float $expected): void
{
$this->assertSame($expected, Tax::withVat($net));
}
// ✅ Expected output:
// OK (3 tests, 3 assertions)
}PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
... 3 / 3 (100%)
OK (3 tests, 3 assertions)20 + 20% and 0 + 20%, then name the attribute that links a provider to a test. Expect OK (3 tests, 3 assertions).6️⃣ Mocking: Faking the Database and the Mailer
Real dependencies — a database, an email server, a payment API — make tests slow, flaky, and dangerous (you don't want a test emailing real users). The fix is a test double: a stand-in object. A stub just returns canned values so your code has something to work with. A mock goes further and verifies interactions — that a method was called the right number of times with the right arguments. PHPUnit's createMock() builds one from a class or interface; expects($this->once()) asserts it's called exactly once.
<?php
use PHPUnit\Framework\TestCase;
// A collaborator the service depends on (defined as an interface).
interface UserRepository
{
public function findByEmail(string $email): ?array;
public function create(array $data): array;
}
interface EmailService
{
public function send(string $to, string $subject, string $body): bool;
}
// The system under test — it needs a database and a mailer to do its job.
class RegistrationService
{
public function __construct(
private UserRepository $users,
private EmailService $mailer
) {}
public function register(string $name, string $email): array
{
if ($this->users->findByEmail($email) !== null) {
throw new RuntimeException("Email already registered");
}
$user = $this->users->create(['name' => $name, 'email' => $email]);
$this->mailer->send($email, "Welcome!", "Thanks, {$name}!");
return $user;
}
}
class RegistrationServiceTest extends TestCase
{
public function testRegistersAndEmailsANewUser(): void
{
// STUB the repo: canned answers, no DB. We don't care HOW it's called.
$repo = $this->createMock(UserRepository::class);
$repo->method('findByEmail')->willReturn(null); // not taken
$repo->method('create')->willReturn(['id' => 1, 'name' => 'Al']);
// MOCK the mailer: we DO care that send() runs exactly once, with args.
$mailer = $this->createMock(EmailService::class);
$mailer->expects($this->once()) // called 1x
->method('send')
->with('al@example.com', 'Welcome!', $this->anything())
->willReturn(true);
$service = new RegistrationService($repo, $mailer);
$user = $service->register("Al", "al@example.com");
$this->assertSame(1, $user['id']);
}
public function testRejectsADuplicateEmail(): void
{
$repo = $this->createMock(UserRepository::class);
$repo->method('findByEmail')->willReturn(['id' => 99]); // already exists
$mailer = $this->createMock(EmailService::class);
// A duplicate must NOT send a welcome email — never() proves it.
$mailer->expects($this->never())->method('send');
$service = new RegistrationService($repo, $mailer);
$this->expectException(RuntimeException::class);
$service->register("Al", "al@example.com");
}
}PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 00:00.010, Memory: 6.00 MB
OK (2 tests, 3 assertions)expects() lines ARE assertions — that's why the count rises even though there's no explicit assert.Notice the second test uses expects($this->never()) to prove a negative: a duplicate signup must not send a welcome email. That's behaviour you simply can't check by inspecting a return value — only a mock can assert it.
7️⃣ Running the Suite and Reading a Failure
You run everything with ./vendor/bin/phpunit tests/. While debugging, narrow it down: ./vendor/bin/phpunit tests/CalculatorTest.php runs one file, and --filter testAddsTwoNumbers runs one method. When a test fails, PHPUnit prints the class, the method, what it expected vs what it got, and the exact file and line — read it carefully before touching code.
$ ./vendor/bin/phpunit tests/
PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
........F 9 / 9 (100%)
Time: 00:00.021, Memory: 8.00 MB
There was 1 failure:
1) ShoppingCartTest::testSumsPrices
Failed asserting that 14.0 matches expected 15.0.
/app/tests/ShoppingCartTest.php:41
FAILURES!
Tests: 9, Assertions: 13, Failures: 1.F in ........F is the failure. "Failed asserting that 14.0 matches expected 15.0" with a line number tells you exactly where to look.Common Errors (and the fix)
- Your tests pass, then a harmless refactor breaks dozens of them — you tested the implementation, not the behaviour. Asserting that a specific private method ran ties the test to how the code works today. Assert the public result (the return value, the thrown exception, the email sent) so you're free to rewrite the internals.
- "Failed asserting that null is true" — but only sometimes — your tests aren't isolated: one test leaves data behind and another depends on it. Build fresh state in
setUp()and clean up intearDown(); never let one test rely on another running first. - "Failed asserting that '5' is identical to 5" — you used
assertSamewhere the value is the right number but the wrong type (a string from a form, say). Either cast the value, or useassertEqualsif loose comparison is genuinely what you mean. For floats, switch toassertEqualsWithDelta— rounding makesassertSame(0.3, 0.1 + 0.2)fail. - The whole suite takes 40 seconds and everyone stops running it — your "unit" tests are hitting a real database or network. That's slow and flaky. Replace the dependency with a
createMock()double so the test runs in milliseconds, and keep the few genuine database tests in a separate, slower integration suite.
Pro Tips
- 💡 One behaviour per test. If a test name needs "and" in it (
testAddsAndRemoves), it's probably two tests. Small tests pinpoint failures. - 💡 Default to
assertSame. Strict comparison catches a whole class of type bugs thatassertEqualswaves through. - 💡 Follow the test pyramid: many fast unit tests, fewer integration tests, a handful of slow end-to-end tests. Run unit tests on every save.
📋 Quick Reference — PHPUnit
| Syntax | Example | What It Does |
|---|---|---|
| assertSame | $this->assertSame(5, $x); | Equal value AND type (strict) |
| assertEquals | $this->assertEquals(5, $x); | Equal value (loose, type-coerced) |
| assertTrue | $this->assertTrue($ok); | Value is exactly true |
| expectException | $this->expectException(X::class); | The next call must throw X |
| setUp / tearDown | protected function setUp() | Runs before / after each test |
| #[DataProvider] | #[DataProvider('cases')] | Run a test once per data row |
| createMock | $this->createMock(Repo::class); | Build a fake dependency |
| vendor/bin/phpunit | ./vendor/bin/phpunit tests/ | Run the test suite |
Frequently Asked Questions
Q: What is the difference between assertEquals and assertSame?
assertEquals checks that two values are equal after type coercion, so assertEquals(5, "5") passes because PHP treats the string "5" and the integer 5 as equal. assertSame is strict — it checks value AND type, like the === operator, so assertSame(5, "5") fails. Prefer assertSame whenever you know the exact type you expect; it catches accidental string-vs-int bugs that assertEquals silently lets through. The one place to avoid assertSame is floating-point numbers, where rounding means you should use assertEqualsWithDelta instead.
Q: What is the difference between a mock and a stub?
Both are test doubles — stand-in objects that replace a real dependency like a database or mailer. A stub just returns canned values so your code has something to work with; you never check how it was used. A mock additionally records and verifies interactions: with expects($this->once())->method('send') you are asserting that send() is called exactly once. Rule of thumb: use a stub for inputs your code reads, and a mock when the call itself is the behaviour you need to prove (like "a welcome email was sent"). In PHPUnit, createStub() makes a pure stub and createMock() makes a configurable double you can add expectations to.
Q: When should I use a data provider instead of multiple tests?
Use a data provider when you want to run the same assertion logic against many input/output pairs — validating emails, checking tax calculations, testing edge cases like empty strings and negative numbers. A provider is a static method returning an array of rows, each row being the arguments for one run, and you attach it with the #[DataProvider('methodName')] attribute. PHPUnit then runs the test once per row and reports each as its own pass or fail, so a single failing case is named precisely instead of hiding inside one giant test. It keeps tests DRY and makes adding a new case a one-line change.
Q: What is the Arrange-Act-Assert pattern?
Arrange-Act-Assert (AAA) is the standard three-part shape of a unit test. Arrange sets up the objects and inputs you need (often in setUp()). Act calls the single thing you are testing — ideally one method call. Assert checks the outcome with one or more assertions. Keeping these phases visually separate makes a test easy to read and signals when a test is doing too much: if you have two Act steps, you probably have two tests. Each test should verify one behaviour.
Q: How do I run only one test or one file?
Run the whole suite with ./vendor/bin/phpunit. To run a single file, pass its path: ./vendor/bin/phpunit tests/CalculatorTest.php. To run one method, use --filter with the method name: ./vendor/bin/phpunit --filter testAddsTwoNumbers. You can also pass a regex to --filter to run a group of related tests. This is invaluable while debugging a single failure, so you are not waiting for the entire suite each time you tweak the code.
Mini-Challenge: Test a BankAccount
No test code is filled in this time — just the class under test, a brief, and an outline. Write the test class yourself, run it with ./vendor/bin/phpunit, then check your result against the expected output in the comments. This is exactly the write-run-check loop you'll use on every real project.
<?php
use PHPUnit\Framework\TestCase;
// The class under test is given — write the TEST class below it.
class BankAccount
{
private float $balance = 0.0;
public function deposit(float $amount): void
{
if ($amount <= 0) throw new InvalidArgumentException("Amount must be positive");
$this->balance += $amount;
}
public function balance(): float { return $this->balance; }
}
// 🎯 MINI-CHALLENGE: Test the BankAccount class.
// No test code is filled in — work from the steps below, then run phpunit.
//
// 1. Make a class BankAccountTest that extends TestCase.
// 2. Use setUp() to create a fresh BankAccount in $this->account before each test.
// 3. test #1: a brand-new account has a balance of 0.0 (assertSame).
// 4. test #2: after deposit(50) then deposit(25), balance() is 75.0.
// 5. test #3: deposit(-5) throws InvalidArgumentException (expectException).
//
// ✅ Expected output:
// OK (3 tests, 4 assertions)
// your test class heresetUp() for a fresh account, assertSame for the balances, and expectException for the negative deposit. Aim for OK (3 tests, 4 assertions).🎉 Lesson Complete!
- ✅ Install PHPUnit with
composer require --dev phpunit/phpunitand run it fromvendor/bin/phpunit - ✅ A test is a class that
extends TestCasewith publictest*methods, each following Arrange-Act-Assert - ✅
assertSameis strict,assertEqualsis loose;expectExceptionchecks throws - ✅
setUp()andtearDown()give every test fresh, isolated state - ✅ A data provider runs one test against many input rows via
#[DataProvider] - ✅
createMock()replaces databases and mailers so tests stay fast and isolated - ✅ Next lesson: Composer Packages — bundle and publish reusable PHP libraries (now with a test suite behind them)
Sign up for free to track which lessons you've completed and get learning reminders.