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.
{
"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.
<?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.
<?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.
<?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.
<?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.
<?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.
<?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.
<?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.
<?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.
<?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.
<?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.
<?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.
<?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.
vendor/bin/phpunitruns the whole suite defined inphpunit.xml.vendor/bin/phpunit tests/CalculatorTest.phpruns one file.--filter testAddruns only tests whose name matches the pattern.--testdoxprints a readable list of tests.--stop-on-failurestops at the first failing test.--coverage-textprints code coverage; it needs the Xdebug or PCOV extension.
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.
"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.
- Test one behaviour per test method and give the method a name that describes it.
- Keep tests independent; never rely on the order in which tests run.
- Prefer
assertSametoassertEquals. - Test the public behaviour of a class, not its private implementation.
- Inject dependencies through constructors, so they can be replaced by doubles.
- Do not mock what you do not own, such as the PDO class; test against a real in-memory database instead.
- Run the suite on every commit in your CI pipeline.
Source
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
List all PHP tutorials.