PHP Unit Testing with PHPUnit: Code Quality Guide

PHP Unit Testing with PHPUnit: Code Quality Guide

  1. PHP 🐘
  2. 2023-12-20 21:00
  3. 9 min read

You’ve built an amazing PHP application. It’s fast, it looks great, and it works flawlessly — most of the time. But when users encounter unexpected errors or watch pages crash, they don’t care about your elegant architecture. They care about reliability.

Unit testing transforms good code into trustworthy code. It catches bugs when they’re small, prevents regressions when you refactor, and serves as living documentation for how your code should behave.

What You’ll Learn

  • Writing and organizing effective PHPUnit test cases
  • Test-Driven Development (TDD) workflow with PHPUnit
  • Mocking and stubbing dependencies for isolated tests
  • Parameterized testing with data providers
  • Exception handling and assertion strategies
  • Advanced techniques for comprehensive test coverage

Fundamentals of Unit Testing

Unit testing means examining individual components of your code in isolation. Each test verifies that a single unit — typically a method — behaves as expected. Think of it as testing each gear in a machine separately before assembling the whole.

Why Unit Testing Matters

Bug prevention: Unit tests catch bugs when they’re small and localized. A failing test pinpoints exactly which component broke, reducing debugging time from hours to minutes.

Improved code quality: Writing testable code naturally leads to better design. Classes with single responsibilities are easier to test. Methods with clear inputs and outputs are easier to verify. Testing encourages modular, loosely coupled architecture.

Confidence in changes: With a comprehensive test suite, you can refactor aggressively. If your tests pass, your changes haven’t broken existing functionality. This confidence enables continuous improvement.

Living documentation: Tests describe how code should behave. New team members can read tests to understand expected behavior without digging through implementation details.

Introduction to PHPUnit

PHPUnit is the standard unit testing framework for PHP. It provides test runners, assertions, mock objects, and code coverage analysis:

<?php
 
function add($a, $b) {
    return $a + $b;
}
 
use PHPUnit\Framework\TestCase;
 
class MathTest extends TestCase
{
    public function testAdd(): void
    {
        $result = add(2, 3);
        $this->assertEquals(5, $result);
    }
}

Every test class extends TestCase. Test methods are public and named with a test prefix. Assertions validate expected outcomes. If an assertion fails, PHPUnit reports the failure with detailed information.

Writing Effective Unit Tests

Identifying Testable Units

In PHP, testable units are typically individual methods within classes. These methods should be focused, self-contained, and handle a specific responsibility:

<?php
 
class ShoppingCart
{
    public function calculateSubtotal(array $items): float
    {
        $subtotal = 0;
 
        foreach ($items as $item) {
            $subtotal += $item['price'] * $item['quantity'];
        }
 
        return $subtotal;
    }
}

The calculateSubtotal method is an ideal testable unit: it accepts input, performs a clear calculation, and returns a predictable output.

Structuring Tests

Follow conventions that make tests easy to find and maintain:

  • Mirror your source directory structure in the tests directory
  • Name test files ClassNameTest.php
  • Use one test class per source class
  • Use one test method per behavior
<?php
 
use PHPUnit\Framework\TestCase;
 
class ShoppingCartTest extends TestCase
{
    public function testCalculateSubtotal(): void
    {
        $cart = new ShoppingCart();
 
        $items = [
            ['price' => 10, 'quantity' => 2],
            ['price' => 5, 'quantity' => 4],
            ['price' => 15, 'quantity' => 1],
        ];
 
        $expected = 55;
        $subtotal = $cart->calculateSubtotal($items);
 
        $this->assertEquals($expected, $subtotal);
    }
}

Best Practices for Test Cases

Clear test names: testCalculateSubtotalWithMultipleItems is better than testSubtotal. The name should describe the scenario and expected behavior.

Arrange-Act-Assert pattern: Set up test data, execute the method under test, then verify the result:

<?php
 
public function testCalculateWithMultipleItems(): void
{
    $cart = new ShoppingCart();
    $items = [
        ['name' => 'Item A', 'price' => 10],
        ['name' => 'Item B', 'price' => 15],
    ];
 
    $subtotal = $cart->calculateSubtotal($items);
 
    $this->assertEquals(25, $subtotal);
}

Test boundary conditions: Test empty inputs, maximum values, negative numbers, and edge cases. Bugs often hide at boundaries.

Isolate dependencies: Use mocks and stubs for external services, databases, and APIs.

Test-Driven Development (TDD)

TDD reverses the traditional development flow: write the test first, then write the code to make it pass.

The TDD Cycle

Red: Write a failing test that describes the desired behavior. Green: Write the minimum code to make the test pass. Refactor: Improve the code while keeping tests green.

TDD in Practice

Let’s implement an isEven function using TDD:

Step 1: Write the test

<?php
 
use PHPUnit\Framework\TestCase;
 
class IsEvenTest extends TestCase
{
    public function testIsEvenReturnsTrueForEvenNumbers(): void
    {
        $this->assertTrue(isEven(2));
        $this->assertTrue(isEven(4));
        $this->assertTrue(isEven(100));
    }
 
    public function testIsEvenReturnsFalseForOddNumbers(): void
    {
        $this->assertFalse(isEven(1));
        $this->assertFalse(isEven(3));
        $this->assertFalse(isEven(99));
    }
 
    public function testIsEvenReturnsTrueForZero(): void
    {
        $this->assertTrue(isEven(0));
    }
 
    public function testIsEvenReturnsFalseForNegativeEvenNumbers(): void
    {
        $this->assertFalse(isEven(-2));
        $this->assertFalse(isEven(-4));
    }
}

Step 2: Run the test — it fails because isEven doesn’t exist.

Step 3: Write the code

<?php
 
function isEven(int $number): bool
{
    return $number % 2 === 0;
}

Step 4: Run the test — it passes.

Step 5: Refactor — the implementation is already minimal and clear.

This simple example demonstrates the TDD rhythm. For each feature, you repeat this cycle: write a failing test, make it pass, then improve the design.

Advanced Techniques

Mocking and Stubbing

Real applications depend on external services — databases, APIs, file systems. Mocks simulate these dependencies so your tests remain fast, reliable, and isolated.

<?php
 
// Creating a mock object for a database dependency
$mockDatabase = $this->getMockBuilder(Database::class)
    ->getMock();
 
$mockDatabase->expects($this->once())
    ->method('query')
    ->willReturn($fakeData);

Stubs return predefined values without verifying interaction:

<?php
 
// Stubbing an HTTP client
$httpClient = $this->getMockBuilder(HttpClient::class)
    ->getMock();
 
$httpClient->method('get')
    ->willReturn($fakeApiResponse);

Mocking ensures your tests focus on the code unit under test, not on the behavior of its dependencies.

Handling Exceptions

Use expectException to verify that code throws the expected exceptions:

<?php
 
public function testDivideByZeroThrowsException(): void
{
    $this->expectException(DivisionByZeroError::class);
    $this->expectExceptionMessage('Cannot divide by zero');
 
    divide(10, 0);
}

For more complex exception assertions, use assertThrows:

<?php
 
public function testExceptionMessage(): void
{
    $this->assertThrows(
        MyCustomException::class,
        function () {
            // Code that should throw MyCustomException
        },
        'Expected exception message'
    );
}

Data Providers

Data providers eliminate repetitive test code by running the same test with multiple input sets:

<?php
 
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
 
class MathTest extends TestCase
{
    #[DataProvider('additionProvider')]
    public function testAddition(int $a, int $b, int $expected): void
    {
        $result = add($a, $b);
        $this->assertEquals($expected, $result);
    }
 
    public static function additionProvider(): array
    {
        return [
            [1, 2, 3],
            [0, 0, 0],
            [-1, 1, 0],
            [10, -5, 5],
        ];
    }
}

PHPUnit runs testAddition once for each dataset in additionProvider, reporting each dataset as a separate test result.

Organizing Your Test Suite

A well-organized test suite grows with your application without becoming unmanageable.

Directory Structure

Mirror your source directory structure:

tests/
├── Unit/
│   ├── Models/
│   ├── Services/
│   ├── Repositories/
│   └── Support/
├── Feature/
│   ├── Http/
│   │   ├── Controllers/
│   │   └── Middleware/
│   └── Console/
└── TestCase.php

Unit tests verify individual classes in isolation. Feature tests verify interactions between components.

Naming Conventions

  • Test class: {ClassName}Test
  • Test method: test{Action}{Scenario}{ExpectedBehavior}
  • Test data provider: {methodName}Provider

Test Suite Configuration

Configure PHPUnit with a phpunit.xml file at 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 suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory suffix=".php">./app</directory>
        </include>
    </source>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
    </php>
</phpunit>

Integrating Tests into CI/CD

Automated test execution in CI/CD pipelines ensures regressions are caught before deployment.

GitHub Actions Example

name: Tests
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
 
    steps:
    - uses: actions/checkout@v3
 
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: '8.2'
        coverage: xdebug
 
    - name: Install Dependencies
      run: composer install --prefer-dist --no-progress
 
    - name: Execute Tests
      run: vendor/bin/phpunit --coverage-clover clover.xml
 
    - name: Upload Coverage
      uses: codecov/codecov-action@v3

Running Specific Test Suites

# Run only unit tests
vendor/bin/phpunit --testsuite=Unit
 
# Run only a specific test file
vendor/bin/phpunit tests/Unit/MathTest.php
 
# Run tests matching a name pattern
vendor/bin/phpunit --filter=testCalculate
 
# Generate coverage report
vendor/bin/phpunit --coverage-html=coverage

Real-World Use Cases

  • E-commerce platforms: Test pricing calculations, discount rules, tax strategies, and inventory management
  • API integrations: Verify request formatting, response parsing, and error handling for third-party services
  • SaaS applications: Validate subscription logic, feature flags, and permission systems
  • Financial systems: Test transaction processing, balance calculations, and audit trail generation
  • Data pipelines: Verify ETL transformations, data validation, and reporting calculations

Best Practices

  • Test behavior, not implementation: Test what code does, not how it does it. Implementation tests break during refactoring
  • Keep tests fast: Slow tests discourage running them. Aim for sub-millisecond unit tests
  • Write tests alongside code: Treat tests as a first-class deliverable, not an afterthought
  • Use meaningful assertions: assertEquals for values, assertTrue/assertFalse for booleans, assertCount for arrays
  • Follow the single assertion guideline: Each test method should verify one behavior

Common Mistakes to Avoid

  • Testing framework internals: Don’t test that PHPUnit works; test that your code works
  • Brittle assertions: Avoid asserting exact strings when you only need to verify a substring exists
  • Ignoring test failures: A failing test suite is broken software. Fix failures immediately
  • Over-mocking: Mock external dependencies, not value objects or simple data structures
  • Testing private methods: Test through public interfaces. Private methods are implementation details

Frequently Asked Questions

What’s the difference between unit and integration tests? Unit tests verify individual components in isolation. Integration tests verify that components work together. Both are necessary for a comprehensive testing strategy.

Should I use PHPUnit or Pest? Both use PHPUnit under the hood. Pest offers a more expressive syntax. PHPUnit is more established with broader community support. Choose based on team preference.

How much test coverage is enough? 100% coverage doesn’t guarantee bug-free code. Focus on testing critical business logic, edge cases, and frequently changing code rather than chasing coverage metrics.

Can I test private methods? Reflection can test private methods, but this indicates a design problem. Test through public methods that use the private logic.

How do I test code that uses facades in Laravel? Laravel facades are testable by design. Use Facade::shouldReceive for mocking or rely on Laravel’s automatic facade mocking in tests.

What’s the best way to learn TDD? Start with simple functions, then move to class methods. The kata format — practicing the same problem repeatedly — helps internalize the red-green-refactor cycle.

Conclusion

Unit testing with PHPUnit transforms PHP development from a reactive process (fixing bugs when users report them) to a proactive one (preventing bugs before they exist). The investment in writing tests pays dividends throughout the application lifecycle.

Start where you are. Write tests for new code. Add tests when fixing bugs. Gradually build coverage for critical paths. The goal isn’t perfect coverage — it’s confidence that your code works as intended and stays working as you evolve it.

Every test you write is a safety net for the next developer who touches your code, which might be you six months from now.

PHPUnit Documentation | PHPUnit GitHub

PHPUnit Unit Testing TDD PHP Testing Code Quality Mocking Test-Driven Development PHP 8.x