Writing Tests with PHPUnit

A PHPUnit test is a public method on a class that extends TestCase, shaped as arrange, act, then one assertion.

Syntaxfinal class SubjectTest extends TestCase { public function test_behaviour(): void { self::assertSame($expected, $actual); } }

PHPUnit discovers tests by looking at classes. A test class extends PHPUnit\Framework\TestCase, sits in a file whose name ends in Test.php, and holds one public method per behaviour you are checking.

Marking a method as a test

There are two forms, and they are the same thing. The explicit form is the #[Test] attribute. The shorthand is a method whose name begins with test, which needs no attribute at all:

use PHPUnit\Framework\Attributes\Test;

#[Test]
public function it_rejects_an_empty_name(): void {}

public function test_it_rejects_an_empty_name(): void {}

Both are found and run. Pick one style per project. PHPUnit 12 removed the older doc-comment form @test, so an attribute or the name prefix is all that still works.

Assertions

An assertion is a comparison that fails the test when it is false. Expected value first, actual second.

  • assertSame($expected, $actual) compares with ===. Make this your default.
  • assertEquals($expected, $actual) compares loosely, so '5' passes against 5.
  • assertTrue, assertFalse, assertNull, assertCount, assertInstanceOf.
  • expectException(InvalidArgumentException::class), written before the line that should throw.

Arrange, act, assert

Give every test the same three parts: build the input, make one call, compare the result. The shape is a check on scope. If a test has two acts, it is two tests.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Pin down a string-formatting helper so a later regex tweak cannot change its output unnoticed.
  • Prove that a validator throws on bad input rather than quietly returning a wrong value.
  • Describe a class's public contract in test method names, so a new colleague can read what it promises.
  • Narrow a run to one class while fixing it, with vendor/bin/phpunit --filter SlugTest.

More examples

The same tests under real PHPUnit

Everything the hand-built base class did is now inherited. Note self::assertSame rather than $this->assertSame: the assertions are static methods on PHPUnit\Framework\Assert, so either call works and self:: is the honest one. This file needs Composer, so it is not runnable in the sandbox.

Example · php
<?php
declare(strict_types=1);

namespace App\Tests;

use App\Slug;
use PHPUnit\Framework\TestCase;

final class SlugTest extends TestCase
{
    public function test_it_lowercases_and_joins_with_dashes(): void
    {
        self::assertSame('hello-world', Slug::fromTitle('  Hello World  '));
    }

    public function test_it_collapses_punctuation_into_one_dash(): void
    {
        self::assertSame('php-8-5-release-notes', Slug::fromTitle('PHP 8.5 -- Release Notes'));
    }
}

Asserting that something throws

Reach for this when the behaviour under test is the refusal, not the result. The expectation is declared before the act, because the act is the line that throws and nothing after it will run. Put nothing after that call: a second assertion there is dead code that will never execute.

Example · php
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class BasketTest extends TestCase
{
    public function test_it_rejects_a_quantity_below_one(): void
    {
        $basket = new Basket();

        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Quantity must be at least 1.');

        $basket->add('pen', 0);
    }
}

One test class, several assertions about one fact

Four assertions, one behaviour: they describe different fields of the same returned object. That is the acceptable version of multiple assertions. The version to avoid is four assertions about four unrelated rules, because PHPUnit stops at the first failure and hides the other three.

Example · php
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class OrderTest extends TestCase
{
    public function test_it_summarises_a_completed_order(): void
    {
        $order = Order::fromLines([
            ['sku' => 'pen', 'price' => 250, 'qty' => 2],
        ]);

        $summary = $order->summary();

        self::assertInstanceOf(OrderSummary::class, $summary);
        self::assertSame(500, $summary->total);
        self::assertCount(1, $summary->lines);
        self::assertFalse($summary->isEmpty);
    }
}

Discussion

  • Be the first to comment on this lesson.