PHPUnit Guide: Complete Tutorial for PHP Unit Testing

PHPUnit Guide: Complete Tutorial for PHP Unit Testing

PHPUnit is the standard testing framework for PHP. Every major PHP framework — Laravel, Symfony, WordPress — uses or supports PHPUnit. This guide covers installation, writing test classes, assertions, test doubles (mocks, stubs, spies), data providers, exception testing, code coverage, and CI integration.

Key Takeaways

PHPUnit test classes extend TestCase and methods start with test. Or use the #[Test] attribute (PHPUnit 10+). Both work. The test prefix is the traditional style, attributes are the modern style.

setUp() and tearDown() run before and after each test. Use them for shared test infrastructure like database connections, mocked services, and temporary files.

$this->assertX() is the assertion API. assertEquals, assertTrue, assertInstanceOf, assertArrayHasKey, assertStringContainsString — there are 60+ assertions. Failure messages include the expected/actual values automatically.

Data providers let one test cover many inputs. Return an array of arrays from a method annotated #[DataProvider]. PHPUnit runs the test once per row.

$this->createMock(Interface::class) creates test doubles. Use ->method()->willReturn() to stub return values, ->expects($this->once()) to assert calls.

What Is PHPUnit?

PHPUnit is the de-facto standard testing framework for PHP. Created by Sebastian Bergmann in 2001, it's the PHP equivalent of JUnit. PHPUnit 11 runs on PHP 8.2+ and is actively maintained.

It's used by:

  • Laravel — ships with PHPUnit configured out of the box
  • Symfony — official testing layer built on PHPUnit
  • WordPress — WP_UnitTestCase extends PHPUnit TestCase
  • Composer packages — almost every package uses PHPUnit for CI

Installing PHPUnit

# Install as a dev dependency via Composer
composer require --dev phpunit/phpunit ^11

<span class="hljs-comment"># Run tests
./vendor/bin/phpunit

Or install globally:

composer global require phpunit/phpunit
phpunit

Configuration

Create phpunit.xml in your project root:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

Writing Your First Test

<?php

use PHPUnit\Framework\TestCase;

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

        $result = $calculator->add(2, 3);

        $this->assertEquals(5, $result);
    }
}

Or with the #[Test] attribute (PHPUnit 10+):

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

class CalculatorTest extends TestCase
{
    #[Test]
    public function add_two_positive_numbers_returns_sum(): void
    {
        $calculator = new Calculator();
        $this->assertEquals(5, $calculator->add(2, 3));
    }
}

Run it:

./vendor/bin/phpunit

Test Class Structure

class UserServiceTest extends TestCase
{
    private UserService $service;
    private MockObject $mockRepository;

    protected function setUp(): void
    {
        parent::setUp();
        // Runs before EACH test
        $this->mockRepository = $this->createMock(UserRepository::class);
        $this->service = new UserService($this->mockRepository);
    }

    protected function tearDown(): void
    {
        parent::tearDown();
        // Runs after EACH test — clean up resources
    }

    public static function setUpBeforeClass(): void
    {
        parent::setUpBeforeClass();
        // Runs ONCE before all tests in this class
    }

    public static function tearDownAfterClass(): void
    {
        parent::tearDownAfterClass();
        // Runs ONCE after all tests complete
    }
}

Assertions

PHPUnit has 60+ assertion methods. The most common:

Equality

$this->assertEquals(5, $result);          // loose equality (==)
$this->assertSame(5, $result);            // strict equality (===)
$this->assertNotEquals(0, $result);
$this->assertNotSame(0, $result);
$this->assertEqualsWithDelta(3.14, $pi, 0.01); // float with tolerance

Boolean and Null

$this->assertTrue($condition);
$this->assertFalse($condition);
$this->assertNull($value);
$this->assertNotNull($value);

String

$this->assertStringContainsString('error', $message);
$this->assertStringStartsWith('Hello', $greeting);
$this->assertStringEndsWith('.pdf', $filename);
$this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $date);
$this->assertStringNotContainsString('password', $log);

Array

$this->assertCount(3, $list);
$this->assertEmpty($list);
$this->assertNotEmpty($list);
$this->assertArrayHasKey('email', $user);
$this->assertArrayNotHasKey('password', $response);
$this->assertContains('apple', $fruits);
$this->assertNotContains('banana', $fruits);

Types

$this->assertInstanceOf(User::class, $user);
$this->assertIsString($value);
$this->assertIsInt($count);
$this->assertIsArray($list);
$this->assertIsFloat($price);
$this->assertIsBool($flag);

Exceptions

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Email cannot be empty');
$this->expectExceptionCode(400);

// The exception must be thrown by the code that follows
$this->service->createUser(['email' => '']);

Or use closures (PHPUnit 9+):

$this->assertThrows(
    \InvalidArgumentException::class,
    fn() => $this->service->createUser(['email' => ''])
);

Data Providers

Data providers let you run one test with multiple inputs:

use PHPUnit\Framework\Attributes\DataProvider;

class EmailValidatorTest extends TestCase
{
    public static function validEmails(): array
    {
        return [
            'standard email'     => ['user@example.com', true],
            'subdomain email'    => ['user@mail.example.com', true],
            'plus addressing'    => ['user+tag@example.com', true],
            'missing @'          => ['not-an-email', false],
            'missing domain'     => ['user@', false],
            'empty string'       => ['', false],
        ];
    }

    #[DataProvider('validEmails')]
    public function test_email_validation(string $email, bool $expected): void
    {
        $validator = new EmailValidator();
        $this->assertEquals($expected, $validator->isValid($email));
    }
}

Named datasets produce readable test output:

✓ email validation with data set "standard email"
✓ email validation with data set "subdomain email"
✗ email validation with data set "missing @"

Mocking and Test Doubles

Stubs — Returning Controlled Values

public function test_get_user_returns_user_dto(): void
{
    $mockRepo = $this->createMock(UserRepository::class);
    $mockRepo->method('findById')
             ->willReturn(new User(1, 'Alice', 'alice@example.com'));

    $service = new UserService($mockRepo);
    $dto = $service->getUser(1);

    $this->assertEquals('Alice', $dto->name);
}

Mocks — Verifying Calls

public function test_create_user_sends_welcome_email(): void
{
    $mockEmail = $this->createMock(EmailService::class);
    $mockEmail->expects($this->once())    // assert called exactly once
              ->method('sendWelcome')
              ->with($this->equalTo('alice@example.com'));

    $service = new UserService(
        $this->createMock(UserRepository::class),
        $mockEmail
    );
    $service->createUser(['email' => 'alice@example.com', 'name' => 'Alice']);
}

Argument Matchers

$mockService->expects($this->once())
            ->method('process')
            ->with(
                $this->isInstanceOf(Order::class),
                $this->greaterThan(0)
            );

Available matchers: equalTo, identicalTo, isInstanceOf, isType, greaterThan, lessThan, stringContains, matchesRegularExpression, anything, logicalAnd, logicalOr, logicalNot.

Consecutive Return Values

$mockApi->method('fetch')
        ->willReturnOnConsecutiveCalls(
            null,           // first call: not ready
            null,           // second call: still not ready
            ['status' => 'done'] // third call: complete
        );

Throwing Exceptions from Mocks

$mockRepo->method('save')
         ->willThrowException(new DatabaseException('Connection lost'));

Testing File System Operations

PHPUnit includes vfsStream support for testing file operations without touching the real disk:

composer require --dev mikey179/vfsstream
use org\bovigo\vfs\vfsStream;

public function test_file_logger_writes_to_file(): void
{
    $root = vfsStream::setup('logs');
    $logger = new FileLogger(vfsStream::url('logs/app.log'));

    $logger->log('ERROR', 'Something went wrong');

    $this->assertStringContainsString(
        'Something went wrong',
        file_get_contents(vfsStream::url('logs/app.log'))
    );
}

Code Coverage

# Generate HTML coverage report
./vendor/bin/phpunit --coverage-html coverage/

<span class="hljs-comment"># Generate text summary
./vendor/bin/phpunit --coverage-text

<span class="hljs-comment"># Coverage with Xdebug (requires Xdebug installed)
XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html coverage/

Coverage report shows which lines, branches, and methods were executed.

To require minimum coverage:

<coverage>
  <report>
    <html outputDirectory="coverage"/>
  </report>
</coverage>
<source>
  <include>
    <directory>src</directory>
  </include>
  <baseline>coverage-baseline.xml</baseline>
</source>

Running Tests

# All tests
./vendor/bin/phpunit

<span class="hljs-comment"># Specific test file
./vendor/bin/phpunit tests/Unit/UserServiceTest.php

<span class="hljs-comment"># Specific test method
./vendor/bin/phpunit --filter test_create_user_sends_welcome_email

<span class="hljs-comment"># Specific test suite
./vendor/bin/phpunit --testsuite Unit

<span class="hljs-comment"># Stop on first failure
./vendor/bin/phpunit --stop-on-failure

<span class="hljs-comment"># Verbose output
./vendor/bin/phpunit --verbose

CI/CD Integration

GitHub Actions

name: PHPUnit Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.2', '8.3']

    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: xdebug
          coverage: xdebug

      - run: composer install --no-interaction --prefer-dist

      - name: Run PHPUnit
        run: ./vendor/bin/phpunit --coverage-clover coverage.xml

      - uses: codecov/codecov-action@v4
        with:
          files: coverage.xml

PHPUnit vs Pest

PHPUnit is the foundation that Pest builds on. Pest is a newer test runner that wraps PHPUnit with a functional, describe/it API:

// PHPUnit style
public function test_add_returns_sum(): void
{
    $this->assertEquals(5, (new Calculator())->add(2, 3));
}

// Pest style
test('add returns sum', fn() => expect((new Calculator())->add(2, 3))->toBe(5));

Pest is gaining popularity for its concise syntax, especially in the Laravel community. Under the hood, it runs on PHPUnit and your existing test infrastructure. See the PHPUnit vs Pest comparison for a detailed breakdown.

Common Patterns

Testing Private Methods

Don't. Test private methods through their public interface. If a private method is complex enough to need direct testing, extract it into a collaborator class.

If you absolutely must:

$reflection = new ReflectionClass(MyClass::class);
$method = $reflection->getMethod('privateMethod');
$method->setAccessible(true);
$result = $method->invoke($object, $args);

Custom Assertions

// In a base test class
protected function assertUserIsAdmin(User $user): void
{
    $this->assertTrue($user->hasRole('admin'),
        "Expected user {$user->email} to have admin role, but they don't."
    );
}

Testing with Database (without Laravel)

For database integration tests without a framework, use a shared test database with transactions:

abstract class DatabaseTestCase extends TestCase
{
    protected static PDO $pdo;

    public static function setUpBeforeClass(): void
    {
        static::$pdo = new PDO('sqlite::memory:');
        static::$pdo->exec(file_get_contents('schema.sql'));
    }

    protected function setUp(): void
    {
        static::$pdo->beginTransaction();
    }

    protected function tearDown(): void
    {
        static::$pdo->rollBack(); // undo changes after each test
    }
}

Extending Tests with HelpMeTest

PHPUnit tests verify your PHP logic in isolation. For end-to-end testing of your PHP web application in a real browser — login flows, form submissions, complex user journeys — HelpMeTest runs Robot Framework + Playwright tests against your live environment.

HelpMeTest's free tier supports 10 tests with 24/7 monitoring. Pro is $100/month flat with unlimited tests.

Read more