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
composer require --dev phpunit/phpunit ^10.5
<?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
<?php
namespace App;
class Calculator {
public function add(int $a, int $b): int {
return $a + $b;
}
}
<?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
./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
| Assertion | Purpose |
|---|---|
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 |
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
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
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
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
public function testThrowsOnNegativeAmount(): void {
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("Amount must be positive");
(new Wallet())->deposit(-100);
}
Code Coverage
# 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
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