PHP Testing with PHPUnit

Install PHPUnit, write your first test, master assertions, data providers, mocks, and code coverage. The foundation of a maintainable PHP codebase.

Advanced 11 min read 12 examples

Why Testing?

Automated tests catch regressions before users do, document expected behavior, and give you the courage to refactor. PHPUnit is the de-facto testing framework for PHP.

Install & Configure

Bash
composer require --dev phpunit/phpunit ^10.5
XMLphpunit.xml
<?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"
         cacheDirectory=".phpunit.cache">

    <testsuites>
        <testsuite name="default">
            <directory>tests</directory>
        </testsuite>
    </testsuites>

    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

Your First Test

PHPsrc/Calculator.php
<?php
namespace App;

class Calculator {
    public function add(int $a, int $b): int {
        return $a + $b;
    }
}
PHPtests/CalculatorTest.php
<?php
namespace App\Tests;

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

final class CalculatorTest extends TestCase {
    public function testAddsTwoPositiveNumbers(): void {
        $calc = new Calculator();
        $this->assertSame(7, $calc->add(3, 4));
    }

    public function testAddsNegativeNumbers(): void {
        $calc = new Calculator();
        $this->assertSame(-1, $calc->add(2, -3));
    }
}

Running Tests

Bash
./vendor/bin/phpunit              # all tests
./vendor/bin/phpunit --testdox    # human-readable output
./vendor/bin/phpunit tests/CalculatorTest.php
./vendor/bin/phpunit --filter testAddsTwoPositiveNumbers

Common Assertions

AssertionPurpose
assertSame($expected, $actual)Strict equality (===)
assertEquals($expected, $actual)Loose equality (==)
assertTrue($x) / assertFalse($x)Boolean check
assertNull($x)Is null
assertInstanceOf(X::class, $obj)Type check
assertCount($n, $arr)Array/collection size
assertContains($needle, $arr)Element present
assertArrayHasKey("k", $arr)Key exists
assertGreaterThan($n, $x)Numeric comparisons
assertMatchesRegularExpression($pat, $s)Regex match
assertJsonStringEqualsJsonString($a, $b)JSON equality
Prefer assertSame over assertEquals

assertEquals(1, "1") passes (loose). assertSame(1, "1") fails (strict). Strict catches subtle bugs - prefer it unless you genuinely need type coercion.

Data Providers

Run the same test logic with multiple inputs:

PHP
<?php
use PHPUnit\Framework\Attributes\DataProvider;

final class CalculatorTest extends TestCase {
    #[DataProvider("additionCases")]
    public function testAdd(int $a, int $b, int $expected): void {
        $this->assertSame($expected, (new Calculator())->add($a, $b));
    }

    public static function additionCases(): array {
        return [
            "positives"           => [3, 4, 7],
            "negatives"           => [-1, -2, -3],
            "mixed signs"         => [5, -3, 2],
            "zeros"               => [0, 0, 0],
        ];
    }
}

setUp / tearDown Fixtures

PHP
<?php
final class UserServiceTest extends TestCase {
    private UserService $service;
    private InMemoryUserRepo $repo;

    protected function setUp(): void {
        $this->repo    = new InMemoryUserRepo();
        $this->service = new UserService($this->repo);
    }

    protected function tearDown(): void {
        // Cleanup if needed (files, temp DB rows, etc.)
    }

    public function testCreate(): void {
        $id = $this->service->create(["name" => "Ruban"]);
        $this->assertSame("Ruban", $this->repo->find($id)["name"]);
    }
}

Mocks & Stubs

PHP
<?php
public function testSendsWelcomeEmailOnRegister(): void {
    // Stub - returns canned data
    $repo = $this->createStub(UserRepository::class);
    $repo->method("create")->willReturn(42);

    // Mock - verifies interaction
    $mailer = $this->createMock(MailerInterface::class);
    $mailer->expects($this->once())
           ->method("send")
           ->with(
               $this->equalTo("alice@example.com"),
               $this->stringContains("Welcome")
           );

    $service = new UserService($repo, $mailer);
    $service->register("alice@example.com", "secret");
}

Testing Exceptions

PHP
<?php
public function testThrowsOnNegativeAmount(): void {
    $this->expectException(InvalidArgumentException::class);
    $this->expectExceptionMessage("Amount must be positive");

    (new Wallet())->deposit(-100);
}

Code Coverage

Bash
# Requires Xdebug or PCOV
./vendor/bin/phpunit --coverage-text
./vendor/bin/phpunit --coverage-html coverage/

# Open coverage/index.html in browser
# Look for uncovered lines, branches, methods

Aim for high coverage of business logic; chasing 100% on infrastructure code is often wasted effort.

CI Integration

YAML.github/workflows/test.yml
name: 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 }}
          coverage: pcov
      - run: composer install --no-progress --prefer-dist
      - run: ./vendor/bin/phpunit --coverage-clover coverage.xml

Next Steps

Frequently Asked Questions

Test behavior not implementation - cover business logic, edge cases, and bug-fix regressions. Trivial getters/setters and pass-through methods rarely earn their keep. Aim for "would this test fail if the behavior broke?"

PHPUnit is the standard - more docs, more jobs ask for it, every framework integrates. Pest is a friendlier syntax on top of PHPUnit, popular in Laravel land. Both are excellent; PHPUnit is the safer default for learning.

Mocking the PDO/repository in unit tests is fine. For integration tests, use a real database (in-memory SQLite or a Docker MySQL) - mocks hide real DB behavior including SQL syntax errors, constraint violations, and migration mismatches.

Both replace real dependencies. A stub returns canned data when called. A mock additionally verifies how it was called (which methods, how many times, with what arguments). PHPUnit's API spans both.

Yes - tests/ next to src/, mirroring the namespace structure. src/Service/UserService.php -> tests/Service/UserServiceTest.php. Class name ends in Test, method names start with test (or use the #[Test] attribute).