ZetCode

PHPUnit

last modified November 14, 2026

In this article, we show how to test PHP code with PHPUnit. We write test cases, use assertions, test exceptions, feed tests from data providers, share fixtures, and replace collaborators with test doubles.

A unit test is a small program that runs a piece of code, such as a class or a function, and checks that the result is what we expect. A suite of such tests lets us change code with confidence, because a regression shows up immediately.

PHPUnit is the de facto standard testing framework for PHP. This article uses PHPUnit 13, which requires PHP 8.4 or newer. The examples were run with PHP 8.5.

Installation and project layout

PHPUnit is installed with Composer as a development dependency. If you are not familiar with Composer and PSR-4 autoloading, read the Composer and autoloading tutorial first.

$ mkdir phpunit-demo && cd phpunit-demo
$ composer init --no-interaction --name=demo/phpunit-demo
$ composer require --dev phpunit/phpunit

The classes we test live in the src directory and the tests in the tests directory. We map both namespaces in composer.json.

composer.json
{
    "require-dev": {
        "phpunit/phpunit": "^13.4"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    }
}

The autoload-dev section registers the test classes, so they are not loaded in production. After editing the file, run composer dump-autoload.

The PHPUnit executable is installed in vendor/bin. We check that it works.

$ vendor/bin/phpunit --version
PHPUnit 13.4.7 by Sebastian Bergmann and contributors.

Configuration

The phpunit.xml file in the project root tells PHPUnit where the tests are and how strict it should be.

phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
         colors="false"
         cacheDirectory=".phpunit.cache"
         failOnWarning="true"
         failOnRisky="true">
    <testsuites>
        <testsuite name="app">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

The bootstrap attribute loads the Composer autoloader. The testsuite element says that all tests are in the tests directory. By default, PHPUnit picks files whose names end in Test.php.

With failOnWarning and failOnRisky the run fails when a test triggers a warning or does not assert anything. This catches a lot of mistakes early. The source element is used by code coverage reports.

The class under test

We need something to test. The Calculator class has three small methods, one of which throws an exception.

src/Calculator.php
<?php

declare(strict_types=1);

namespace App;

use InvalidArgumentException;

class Calculator
{
    public function add(int|float $a, int|float $b): int|float
    {
        return $a + $b;
    }

    public function divide(int|float $a, int|float $b): int|float
    {
        if ($b == 0) {
            throw new InvalidArgumentException('Division by zero');
        }

        return $a / $b;
    }

    public function isEven(int $n): bool
    {
        return $n % 2 === 0;
    }
}

The class is intentionally trivial. The point is to show the mechanics of testing, not complex logic. The class lives in the App namespace, which Composer maps to src.

The first test

A test case is a class that extends PHPUnit\Framework\TestCase. Every public method whose name starts with test is a test. Instead of the prefix, we can mark a method with the #[Test] attribute.

tests/CalculatorTest.php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Calculator;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    public function testAdd(): void
    {
        $calc = new Calculator();

        $this->assertSame(5, $calc->add(2, 3));
    }

    #[Test]
    public function it_reports_even_numbers(): void
    {
        $calc = new Calculator();

        $this->assertTrue($calc->isEven(4));
        $this->assertFalse($calc->isEven(7));
    }
}

The testAdd method follows the naming convention, while the second method uses the #[Test] attribute, which allows more descriptive names. Each test creates the object, calls it, and compares the result with the expected value through an assertion.

assertSame checks that the values are identical (same value and same type), as the === operator does. We run the tests.

In the output, each dot is a passing test. At the end, PHPUnit reports the number of tests and assertions. The exit code is zero when everything passes, which is what a CI server looks at.

$ vendor/bin/phpunit tests/CalculatorTest.php
PHPUnit 13.4.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /home/user/project/phpunit.xml

..                                                                  2 / 2 (100%)

Time: 00:00, Memory: 20.00 MB

OK (2 tests, 3 assertions)

Assertions

Assertions are the heart of every test. PHPUnit has over a hundred of them; the following example shows the ones we use most often.

tests/AssertionsTest.php
<?php

declare(strict_types=1);

namespace App\Tests;

use ArrayObject;
use PHPUnit\Framework\TestCase;

class AssertionsTest extends TestCase
{
    public function testEqualityAssertions(): void
    {
        $this->assertEquals(1, '1');        // loose comparison
        $this->assertNotSame(1, '1');       // strict comparison
        $this->assertEqualsWithDelta(0.3, 0.1 + 0.2, 0.0001);
    }

    public function testStringAssertions(): void
    {
        $text = 'PHPUnit is a testing framework';

        $this->assertStringContainsString('testing', $text);
        $this->assertStringStartsWith('PHPUnit', $text);
        $this->assertMatchesRegularExpression('/^\w+ is a/', $text);
    }

    public function testArrayAssertions(): void
    {
        $user = ['name' => 'Alice', 'roles' => ['admin', 'editor']];

        $this->assertArrayHasKey('name', $user);
        $this->assertContains('admin', $user['roles']);
        $this->assertCount(2, $user['roles']);
        $this->assertNotEmpty($user);
    }

    public function testTypeAssertions(): void
    {
        $this->assertNull(null);
        $this->assertInstanceOf(ArrayObject::class, new ArrayObject([1, 2]));
        $this->assertIsString('hello');
        $this->assertIsInt(42);
    }
}

assertEquals compares with the loose == comparison, so 1 equals '1'. assertSame is strict. Prefer assertSame unless you deliberately want type juggling.

Floating-point numbers cannot be compared exactly, because 0.1 + 0.2 is not exactly 0.3. That is why we use assertEqualsWithDelta with a tolerance. The other methods check strings, arrays, and types.

When all assertions pass, nothing is printed except the summary.

$ vendor/bin/phpunit tests/AssertionsTest.php
PHPUnit 13.4.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /home/user/project/phpunit.xml

....                                                                4 / 4 (100%)

Time: 00:00, Memory: 20.00 MB

OK (4 tests, 14 assertions)

Testing exceptions

A test can also verify that code fails in the expected way. We declare the expected exception before the call that throws it.

tests/ExceptionTest.php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Calculator;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

class ExceptionTest extends TestCase
{
    public function testDivide(): void
    {
        $this->assertSame(2, (new Calculator())->divide(10, 5));
    }

    public function testDivisionByZero(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Division by zero');

        (new Calculator())->divide(1, 0);
    }
}

expectException and expectExceptionMessage must be called before the code that throws. If no exception is thrown, or a different one, the test fails. There are also expectExceptionCode and expectExceptionMessageMatches.

Note that the code after the throwing statement in the test method never runs.

$ vendor/bin/phpunit tests/ExceptionTest.php
PHPUnit 13.4.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /home/user/project/phpunit.xml

..                                                                  2 / 2 (100%)

Time: 00:00, Memory: 20.00 MB

OK (2 tests, 3 assertions)

Data providers

Often we want to run the same test with many inputs. Instead of copying the test, we use a data provider: a static method that returns a list of argument sets.

tests/DataProviderTest.php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Calculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class DataProviderTest extends TestCase
{
    public static function additionProvider(): array
    {
        return [
            'positive numbers' => [1, 2, 3],
            'negative numbers' => [-1, -2, -3],
            'mixed signs'      => [-5, 5, 0],
            'floats'           => [0.5, 0.25, 0.75],
        ];
    }

    #[DataProvider('additionProvider')]
    public function testAdd(int|float $a, int|float $b, int|float $expected): void
    {
        $calc = new Calculator();

        $this->assertSame($expected, $calc->add($a, $b));
    }

    public static function evenProvider(): iterable
    {
        yield 'zero' => [0, true];
        yield 'one' => [1, false];
        yield 'large even' => [1000000, true];
    }

    #[DataProvider('evenProvider')]
    public function testIsEven(int $n, bool $expected): void
    {
        $this->assertSame($expected, (new Calculator())->isEven($n));
    }
}

The #[DataProvider] attribute names the provider method. PHPUnit calls the test once for each data set and passes the values as arguments. Providers must be static and can return an array or an iterable, such as a generator.

String keys in the provider become the names of the data sets, which makes failures easy to read. The --testdox option prints the results as a readable list.

$ vendor/bin/phpunit --testdox tests/DataProviderTest.php
PHPUnit 13.4.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /home/user/project/phpunit.xml

.......                                                             7 / 7 (100%)

Time: 00:00.001, Memory: 20.00 MB

Data Provider (App\Tests\DataProvider)
 ✔ Add with data set "positive numbers"
 ✔ Add with data set "negative numbers"
 ✔ Add with data set "mixed signs"
 ✔ Add with data set "floats"
 ✔ Is even with data set "zero"
 ✔ Is even with data set "one"
 ✔ Is even with data set "large even"

OK (7 tests, 7 assertions)

Fixtures: setUp and tearDown

When several tests need the same objects, we create them in the setUp method. PHPUnit calls it before each test, so every test starts with a fresh state and the tests stay independent of each other.

src/ShoppingCart.php
<?php

declare(strict_types=1);

namespace App;

class ShoppingCart
{
    private array $items = [];

    public function add(string $name, float $price, int $quantity = 1): void
    {
        $this->items[$name] = ['price' => $price, 'quantity' => $quantity];
    }

    public function count(): int
    {
        return count($this->items);
    }

    public function isEmpty(): bool
    {
        return $this->items === [];
    }

    public function total(): float
    {
        $total = 0.0;

        foreach ($this->items as $item) {
            $total += $item['price'] * $item['quantity'];
        }

        return $total;
    }
}

This is the class we test next. It keeps a list of items and computes the total price.

tests/ShoppingCartTest.php
<?php

declare(strict_types=1);

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();
        $this->cart->add('book', 12.50, 2);
        $this->cart->add('pen', 1.20, 5);
    }

    protected function tearDown(): void
    {
        // release resources here, e.g. close files or connections
    }

    public function testCount(): void
    {
        $this->assertSame(2, $this->cart->count());
    }

    public function testTotal(): void
    {
        $this->assertEqualsWithDelta(31.0, $this->cart->total(), 0.001);
    }

    public function testNewCartIsEmpty(): void
    {
        $this->assertTrue((new ShoppingCart())->isEmpty());
        $this->assertFalse($this->cart->isEmpty());
    }
}

The setUp method builds a cart with two items before each test. The tearDown method runs after each test and is the place to free resources such as files or database connections. There are also the static setUpBeforeClass and tearDownAfterClass methods that run once per test class.

The total is 2 × 12.50 + 5 × 1.20 = 31.00.

$ vendor/bin/phpunit tests/ShoppingCartTest.php
PHPUnit 13.4.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /home/user/project/phpunit.xml

...                                                                 3 / 3 (100%)

Time: 00:00, Memory: 20.00 MB

OK (3 tests, 4 assertions)

Test doubles: stubs and mocks

A unit should be tested in isolation. When a class depends on something slow or with side effects, such as a mail server, a database, or an HTTP API, we replace the dependency with a test double. First, the dependency is an interface and the service receives it through its constructor.

src/Mailer.php
<?php

declare(strict_types=1);

namespace App;

interface Mailer
{
    public function send(string $to, string $subject, string $body): bool;
}

The Mailer interface describes what the service needs. The real implementation would talk to an SMTP server; in tests, we never want to send real mail.

src/UserRegistration.php
<?php

declare(strict_types=1);

namespace App;

class UserRegistration
{
    public function __construct(private Mailer $mailer)
    {
    }

    public function register(string $email): bool
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return false;
        }

        return $this->mailer->send($email, 'Welcome', "Hello, $email!");
    }
}

The UserRegistration class validates an e-mail address and sends a welcome message. Because the mailer is injected, a test can pass in a double.

tests/UserRegistrationTest.php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Mailer;
use App\UserRegistration;
use PHPUnit\Framework\TestCase;

class UserRegistrationTest extends TestCase
{
    public function testRegisterWithStub(): void
    {
        $mailer = $this->createStub(Mailer::class);
        $mailer->method('send')->willReturn(true);

        $service = new UserRegistration($mailer);

        $this->assertTrue($service->register('alice@example.com'));
    }

    public function testWelcomeMailIsSentOnce(): void
    {
        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->once())
            ->method('send')
            ->with('alice@example.com', 'Welcome', $this->stringContains('alice'))
            ->willReturn(true);

        (new UserRegistration($mailer))->register('alice@example.com');
    }

    public function testInvalidEmailSendsNothing(): void
    {
        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->never())->method('send');

        $this->assertFalse((new UserRegistration($mailer))->register('not-an-email'));
    }
}

A stub, created with createStub, only supplies canned answers: here send always returns true. We do not verify how it was called.

A mock, created with createMock, is used to verify interactions. In the second test expects($this->once()) demands that send is called exactly once, with the given arguments, and stringContains is a constraint matching a part of the body. In the third test, never() verifies that no mail is sent for an invalid address.

Use a stub when you need a value and a mock when the call itself is the behaviour you test. In recent PHPUnit versions, a mock without any expectations is reported, so stubs should be created with createStub.

$ vendor/bin/phpunit tests/UserRegistrationTest.php
PHPUnit 13.4.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /home/user/project/phpunit.xml

...                                                                 3 / 3 (100%)

Time: 00:00.001, Memory: 20.00 MB

OK (3 tests, 7 assertions)

Reading failures

A test suite is most useful when it fails, so it matters that the failure messages are clear. The following test case contains deliberate mistakes.

failing/FailingTest.php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Calculator;
use PHPUnit\Framework\TestCase;

class FailingTest extends TestCase
{
    public function testAddIsWrong(): void
    {
        $this->assertSame(6, (new Calculator())->add(2, 3));
    }

    public function testArraysDiffer(): void
    {
        $this->assertEquals(['a' => 1, 'b' => 2], ['a' => 1, 'b' => 3]);
    }

    public function testNoAssertions(): void
    {
        $x = 1 + 1;
    }
}

The first test expects the wrong sum, the second compares two arrays that differ in one element, and the third does not assert anything.

For a failing assertion PHPUnit prints the test name, a description, and a diff for arrays and strings, plus the file and line. The test without assertions is marked risky.

$ vendor/bin/phpunit failing/FailingTest.php
PHPUnit 13.4.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /home/user/project/phpunit.xml

FFR                                                                 3 / 3 (100%)

Time: 00:00.001, Memory: 20.00 MB

There were 2 failures:

1) App\Tests\FailingTest::testAddIsWrong
Failed asserting that 5 is identical to 6.

/home/user/project/failing/FailingTest.php:14

2) App\Tests\FailingTest::testArraysDiffer
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
     'a' => 1
-    'b' => 2
+    'b' => 3
 )

/home/user/project/failing/FailingTest.php:19

--

There was 1 risky test:

1) App\Tests\FailingTest::testNoAssertions
This test did not perform any assertions

/home/user/project/failing/FailingTest.php:22

FAILURES!
Tests: 3, Assertions: 2, Failures: 2, Risky: 1.

Running tests

The command line offers several useful options.

Here we run only the tests whose name contains Even.

$ vendor/bin/phpunit --filter Even
PHPUnit 13.4.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /home/user/project/phpunit.xml

....                                                                4 / 4 (100%)

Time: 00:00.001, Memory: 20.00 MB

OK (4 tests, 5 assertions)

Composer script

It is convenient to add a script to composer.json, so the suite runs with composer test.

composer.json (excerpt)
"scripts": {
    "test": "phpunit"
}

Composer puts vendor/bin on the path while running scripts, so the short phpunit name is enough.

Good practice

A few rules keep tests useful over time.

Source

PHPUnit documentation

PHPUnit assertions

In this article, we have written PHPUnit tests with assertions, data providers, fixtures and test doubles. See also the Composer and autoloading and PHP Exceptions tutorials.

Author

My name is Jan Bodnar and I am a passionate programmer with many years of programming experience. I have been writing programming articles since 2007. So far, I have written over 1400 articles and 8 e-books. I have over eight years of experience in teaching programming.

List all PHP tutorials.