advanced Step 14 of 16

Testing with PHPUnit

PHP Programming

Testing with PHPUnit

Testing is a critical part of professional software development. PHPUnit is PHP's de facto standard testing framework, used by virtually every major PHP project and framework. Writing tests ensures your code works correctly, catches regressions when you make changes, and serves as living documentation for how your code should behave. PHPUnit supports unit tests (testing individual functions and classes in isolation), integration tests (testing how components work together), and provides features like data providers, mocking, and code coverage reporting.

Getting Started with PHPUnit

# Install PHPUnit
composer require --dev phpunit/phpunit

# Create phpunit.xml configuration
# phpunit.xml
<phpunit bootstrap="vendor/autoload.php" colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>
</phpunit>

# Run tests
vendor/bin/phpunit
vendor/bin/phpunit --filter testMethodName
vendor/bin/phpunit tests/Unit/CalculatorTest.php

Writing Tests

<?php
// src/Calculator.php
namespace App;

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

    public function divide(float $a, float $b): float {
        if ($b == 0) {
            throw new \DivisionByZeroError("Cannot divide by zero");
        }
        return $a / $b;
    }

    public function average(array $numbers): float {
        if (empty($numbers)) {
            throw new \InvalidArgumentException("Array cannot be empty");
        }
        return array_sum($numbers) / count($numbers);
    }
}

// tests/Unit/CalculatorTest.php
namespace Tests\Unit;

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

class CalculatorTest extends TestCase {
    private Calculator $calc;

    protected function setUp(): void {
        $this->calc = new Calculator();
    }

    public function testAddReturnsSum(): void {
        $this->assertEquals(5, $this->calc->add(2, 3));
        $this->assertEquals(0, $this->calc->add(-1, 1));
        $this->assertEquals(-5, $this->calc->add(-2, -3));
    }

    public function testDivideReturnsQuotient(): void {
        $this->assertEquals(5, $this->calc->divide(10, 2));
        $this->assertEqualsWithDelta(3.333, $this->calc->divide(10, 3), 0.001);
    }

    public function testDivideByZeroThrowsException(): void {
        $this->expectException(\DivisionByZeroError::class);
        $this->expectExceptionMessage("Cannot divide by zero");
        $this->calc->divide(10, 0);
    }

    public function testAverageWithEmptyArrayThrowsException(): void {
        $this->expectException(\InvalidArgumentException::class);
        $this->calc->average([]);
    }

    /**
     * @dataProvider averageProvider
     */
    public function testAverage(array $numbers, float $expected): void {
        $this->assertEqualsWithDelta($expected, $this->calc->average($numbers), 0.001);
    }

    public static function averageProvider(): array {
        return [
            'single number' => [[5], 5.0],
            'two numbers' => [[4, 6], 5.0],
            'multiple numbers' => [[1, 2, 3, 4, 5], 3.0],
            'with decimals' => [[1.5, 2.5, 3.0], 2.333],
        ];
    }
}
?>

Testing with Mocks

<?php
// src/UserService.php
namespace App;

interface UserRepository {
    public function findById(int $id): ?array;
    public function save(array $user): int;
}

class UserService {
    public function __construct(private readonly UserRepository $repo) {}

    public function getUser(int $id): array {
        $user = $this->repo->findById($id);
        if (!$user) throw new \RuntimeException("User not found");
        return $user;
    }
}

// tests/Unit/UserServiceTest.php
namespace Tests\Unit;

use App\UserService;
use App\UserRepository;
use PHPUnit\Framework\TestCase;

class UserServiceTest extends TestCase {
    public function testGetUserReturnsUser(): void {
        $mockRepo = $this->createMock(UserRepository::class);
        $mockRepo->method('findById')
            ->with(1)
            ->willReturn(['id' => 1, 'name' => 'Alice']);

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

        $this->assertEquals('Alice', $user['name']);
    }

    public function testGetUserThrowsWhenNotFound(): void {
        $mockRepo = $this->createMock(UserRepository::class);
        $mockRepo->method('findById')->willReturn(null);

        $service = new UserService($mockRepo);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage("User not found");
        $service->getUser(999);
    }
}
?>
Pro tip: Follow the AAA pattern in tests: Arrange (set up data and mocks), Act (call the method under test), Assert (verify the results). Use data providers for testing the same logic with multiple inputs. Aim for your tests to be independent — each test should be able to run in isolation without depending on other tests.

Key Takeaways

  • PHPUnit is the standard PHP testing framework; install it with composer require --dev phpunit/phpunit.
  • Test methods must start with test or use the @test annotation, and extend TestCase.
  • Use assertEquals(), assertTrue(), assertCount(), and expectException() for assertions.
  • Data providers (@dataProvider) let you run the same test with multiple input/output combinations.
  • Use createMock() to isolate the class under test from its dependencies.