PHPUnit Testing

Reviewed & published by Brayan K

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.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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.

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/.

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
    }
}

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); // ✅
    }
}

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);
    }
}

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)
}

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'  => ['[email protected]', true],
            'subdomain'      => ['[email protected]', 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));
    }
}

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)
}

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.

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.

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — PHPUnit

SyntaxExampleWhat 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 / tearDownprotected 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

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 here

🎉 Lesson Complete!

Practice quiz

How do you install PHPUnit so it never ships to production?

  • composer require phpunit/phpunit
  • pecl install phpunit
  • composer require --dev phpunit/phpunit
  • apt install phpunit

Answer: composer require --dev phpunit/phpunit. The --dev flag adds PHPUnit as a dev-only dependency, available while you build but kept out of your live server.

What must a PHPUnit test class do, and how are test methods named?

  • Extend TestCase; public methods whose names start with 'test'
  • Implement Testable; methods end in Test
  • Extend Assert; methods named check*
  • Use a #[Test] return type

Answer: Extend TestCase; public methods whose names start with 'test'. A test class extends PHPUnit's TestCase and has public methods whose names start with 'test'.

What is the difference between assertEquals and assertSame?

  • assertEquals is strict; assertSame is loose
  • They are identical
  • assertSame only works on arrays
  • assertEquals compares values with coercion; assertSame compares value AND type strictly

Answer: assertEquals compares values with coercion; assertSame compares value AND type strictly. assertEquals(5, "5") passes (loose), but assertSame(5, "5") fails because assertSame checks value and type like ===.

Which assertion should you use for floating-point comparisons?

  • assertSame
  • assertEqualsWithDelta
  • assertTrue
  • assertCount

Answer: assertEqualsWithDelta. Rounding makes assertSame(0.3, 0.1 + 0.2) fail, so use assertEqualsWithDelta with a small delta for floats.

How do you assert that code throws an exception?

  • Call expectException(X::class) before the line that should throw
  • Wrap it in try/catch and call fail()
  • Use assertThrows()
  • Return the exception from the test

Answer: Call expectException(X::class) before the line that should throw. Call $this->expectException(InvalidArgumentException::class) before the call; the test fails if it never throws.

When does setUp() run?

  • Once before the whole test class
  • After every test method
  • Before every test method, giving each a fresh state
  • Only when a test fails

Answer: Before every test method, giving each a fresh state. setUp() runs before every test method, so building a fresh object there means no test can leak state into the next.

What is a data provider used for?

  • Connecting to a database in tests
  • Running the same test once per row of input/expected arguments
  • Mocking a mailer
  • Cleaning up after a test

Answer: Running the same test once per row of input/expected arguments. A data provider is a static method returning rows of arguments; #[DataProvider('name')] runs the test once per row.

What is the difference between a stub and a mock?

  • A stub verifies calls; a mock returns canned values
  • They are the same; only the name differs
  • A mock can only replace databases
  • A stub returns canned values; a mock also verifies interactions (how it was called)

Answer: A stub returns canned values; a mock also verifies interactions (how it was called). A stub just returns canned values; a mock additionally asserts interactions, e.g. expects($this->once())->method('send').

Which call builds a configurable test double you can add expectations to?

  • $this->createStub()
  • $this->createMock()
  • new RealClass()
  • $this->fake()

Answer: $this->createMock(). createMock() builds a double you can configure with willReturn and verify with expects(); createStub() makes a pure stub.

How do you run a single test file with PHPUnit?

  • ./vendor/bin/phpunit --only tests/CalculatorTest.php
  • phpunit run CalculatorTest
  • ./vendor/bin/phpunit tests/CalculatorTest.php
  • ./vendor/bin/phpunit --file=CalculatorTest

Answer: ./vendor/bin/phpunit tests/CalculatorTest.php. Pass the file path to run one file; use --filter testName to run a single method.

Continue this course

Frequently asked questions

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.

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.

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.

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.

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.

Related lessons