Testing with PHPUnit

Writing test classes, assertions, setUp/tearDown, and mocking a dependency with createMock.

Installing PHPUnit

PHPUnit is PHP's de facto standard testing framework, installed as a dev-only Composer dependency (it's a tool for development, never needed at runtime in production):

Bash
composer require --dev phpunit/phpunit
JSON
{
    "require-dev": {
        "phpunit/phpunit": "^11.0"
    }
}

Running the test suite goes through Composer's installed binary:

Bash
./vendor/bin/phpunit tests

Writing your first test

A PHPUnit test is an ordinary class extending PHPUnit\Framework\TestCase, with each test a public method whose name starts with test (or is annotated #[Test] in modern PHPUnit):

PHP
<?php

namespace App\Tests;

use App\InvoiceCalculator;
use PHPUnit\Framework\TestCase;

class InvoiceCalculatorTest extends TestCase {
    public function testCalculatesSubtotalWithoutTax(): void {
        $calculator = new InvoiceCalculator();

        $result = $calculator->calculateTotal(100.0, 2, 0.0);

        $this->assertSame(200.0, $result);
    }

    public function testAppliesTaxRateCorrectly(): void {
        $calculator = new InvoiceCalculator();

        $result = $calculator->calculateTotal(100.0, 1, 0.1);

        $this->assertSame(110.0, $result);
    }
}
Bash
./vendor/bin/phpunit tests/InvoiceCalculatorTest.php
Text
PHPUnit 11.0.0

..                                                                  2 / 2 (100%)

OK (2 tests, 2 assertions)

Each . represents one passing test; a failure prints an F instead along with a diff showing exactly what was expected versus what was actually returned.

Assertions

Assertion Checks
assertSame($expected, $actual) Strict equality (===) — same value and type
assertEquals($expected, $actual) Loose equality (==) — values match after type coercion
assertTrue($value) / assertFalse($value) Value is exactly true / false
assertNull($value) Value is null
assertCount($count, $iterable) An array or Countable has exactly $count elements
assertInstanceOf(SomeClass::class, $value) Value is an instance of the given class/interface
assertStringContainsString($needle, $haystack) A string contains a given substring

assertSame is almost always the right default over assertEquals — the same reasoning as preferring === over == in ordinary PHP code (covered on the interview-questions page): loose comparison can hide a real bug where a test passes despite the actual and expected values being subtly different types.

Testing that an exception is actually thrown uses a dedicated method rather than a bare assert:

PHP
<?php

public function testThrowsWhenQuantityIsNegative(): void {
    $calculator = new InvoiceCalculator();

    $this->expectException(InvalidArgumentException::class);
    $this->expectExceptionMessage('Quantity cannot be negative');

    $calculator->calculateTotal(100.0, -1, 0.0);
}

expectException must be called before the line that's actually expected to throw — PHPUnit arms the expectation, then verifies the exception matches once the throwing code runs.

Setup and teardown

setUp() runs before every single test method in the class, and tearDown() runs after every one — the standard place to construct shared fixtures without repeating the same setup code in every test method:

PHP
<?php

namespace App\Tests;

use App\ShoppingCart;
use PHPUnit\Framework\TestCase;

class ShoppingCartTest extends TestCase {
    private ShoppingCart $cart;

    protected function setUp(): void {
        $this->cart = new ShoppingCart(); // fresh instance before EACH test — no shared state leaks between tests
    }

    public function testStartsEmpty(): void {
        $this->assertCount(0, $this->cart->items());
    }

    public function testAddingAnItemIncreasesCount(): void {
        $this->cart->add('Widget', 9.99);
        $this->assertCount(1, $this->cart->items());
    }
}

Because setUp() runs fresh before each test method, testStartsEmpty and testAddingAnItemIncreasesCount above each get their own independent ShoppingCart — one test's mutations can never leak into and affect another test's result, which is essential for tests that can be trusted to run in any order.

Mocking a dependency

A mock stands in for a real dependency — typically one that's slow, has side effects (sends an email, calls a real payment API), or is simply hard to construct in a test — letting a test verify how the class under test uses that dependency, without the real thing ever running.

PHP
<?php

namespace App;

interface PaymentGateway {
    public function charge(float $amount): bool;
}

class OrderProcessor {
    public function __construct(private PaymentGateway $gateway) {}

    public function completeOrder(float $amount): string {
        if (!$this->gateway->charge($amount)) {
            throw new RuntimeException('Payment failed');
        }
        return 'Order completed';
    }
}
PHP
<?php

namespace App\Tests;

use App\OrderProcessor;
use App\PaymentGateway;
use PHPUnit\Framework\TestCase;

class OrderProcessorTest extends TestCase {
    public function testCompletesOrderWhenPaymentSucceeds(): void {
        $gateway = $this->createMock(PaymentGateway::class);
        $gateway->method('charge')
                ->with(49.99)
                ->willReturn(true); // stub the mock's return value — the REAL gateway never runs

        $processor = new OrderProcessor($gateway);

        $this->assertSame('Order completed', $processor->completeOrder(49.99));
    }

    public function testThrowsWhenPaymentFails(): void {
        $gateway = $this->createMock(PaymentGateway::class);
        $gateway->method('charge')->willReturn(false);

        $processor = new OrderProcessor($gateway);

        $this->expectException(RuntimeException::class);
        $processor->completeOrder(49.99);
    }

    public function testChargeIsCalledExactlyOnce(): void {
        $gateway = $this->createMock(PaymentGateway::class);
        $gateway->expects($this->once())   // asserts charge() is called EXACTLY once, not zero or twice
                ->method('charge')
                ->willReturn(true);

        $processor = new OrderProcessor($gateway);
        $processor->completeOrder(49.99);
    }
}

createMock(PaymentGateway::class) builds a fake object implementing the PaymentGateway interface with every method returning null by default, until you tell it otherwise with ->method(...)->willReturn(...). ->expects($this->once()) goes a step further than stubbing a return value — it's an actual assertion about how the mock was used, failing the test if charge() was called zero times or more than once, which is exactly the kind of check that's impossible to express by only asserting on completeOrder's return value.

This is precisely why OrderProcessor takes a PaymentGateway interface in its constructor rather than a concrete gateway class directly (the same dependency-inversion principle from the functions-and-oop page) — the test substitutes a mock in place of the real dependency with zero changes needed to OrderProcessor itself.

Common mistakes

  • Reaching for assertEquals out of habit when assertSame is what's actually intended — loose comparison can make a test pass despite a genuine type mismatch that would matter in production.
  • Sharing mutable state between tests (a static property, a fixture built once outside setUp()) instead of rebuilding it fresh in setUp() for every test — this makes test results depend on execution order, which should never be true.
  • Mocking a concrete class directly instead of depending on (and mocking) an interface — concrete classes often have real constructors, side effects, or final methods that make mocking them fragile or outright impossible.
  • Writing a test that only checks a method's return value when the real behavior worth verifying is how a dependency was used (call count, arguments passed) — that's exactly what expects($this->once())-style mock assertions are for.

Interview questions

Q: What's the difference between assertSame and assertEquals, and which should you default to? assertSame uses strict comparison (===) — both value and type must match exactly. assertEquals uses loose comparison (==), allowing type coercion, so assertEquals(1, "1") passes despite comparing an int to a string. Default to assertSame for the same reason === is generally preferred in ordinary PHP code: it catches a class of subtle type-related bugs that loose comparison would silently let a test pass through.

Q: Why does setUp() running before every individual test method matter for test reliability? It guarantees each test method starts from an identical, freshly constructed state, with no way for one test's mutations to leak into and silently affect another test that happens to run afterward. Without that guarantee, a test suite's results could depend on execution order — a test that passes in isolation might fail only when run after a different test that left some shared state mutated, which is exactly the kind of flaky, hard-to-diagnose failure a well-isolated test suite is designed to prevent.