Testing

Testing PHP in 2026: PHPUnit 10, Pest, and Code Coverage

Write effective PHP tests with PHPUnit 10 and Pest. Covers unit testing, integration testing, mocking, code coverage, and CI integration.

PHP test results showing green passing tests and code coverage report

Testing in PHP has matured significantly. PHPUnit 10 introduced a cleaner API and dropped legacy baggage. Pest brought an expressive, closure-based syntax that many teams prefer. Code coverage tools are faster and more accurate. And CI integration is straightforward with any provider.

This guide covers practical PHP testing in 2026—not theoretical best practices, but the patterns that work in production codebases.

PHPUnit 10: what changed

PHPUnit 10 was a major cleanup release. It removed methods that were deprecated in 9.x and introduced a simpler, more consistent API.

Key changes from PHPUnit 9

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// PHPUnit 9 (deprecated)
$mock = $this->getMockBuilder(UserRepository::class)
->onlyMethods(['find'])
->getMock();
$mock->method('find')->withConsecutive([1], [2])->willReturnOnConsecutiveCalls($user1, $user2);

// PHPUnit 10 (current)
$mock = $this->createMock(UserRepository::class);
$mock->method('find')->willReturnCallback(
fn(int $id) => match ($id) {
1 => $user1,
2 => $user2,
}
);

Minimal PHPUnit setup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- phpunit.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="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
1
2
composer require --dev phpunit/phpunit:^10
vendor/bin/phpunit

Pest: expressive PHP testing

Pest is built on PHPUnit but uses a closure-based syntax inspired by Jest (JavaScript) and RSpec (Ruby). It produces more readable tests with less boilerplate.

Pest vs PHPUnit syntax

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// PHPUnit
class UserTest extends TestCase
{
public function test_user_has_full_name(): void
{
$user = new User(firstName: 'Jane', lastName: 'Doe');

$this->assertEquals('Jane Doe', $user->fullName());
}
}

// Pest (same test)
test('user has full name', function () {
$user = new User(firstName: 'Jane', lastName: 'Doe');

expect($user->fullName())->toBe('Jane Doe');
});

Pest expectations

Pest’s expect() API is chainable and readable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
test('order total calculation', function () {
$order = Order::factory()
->hasItems(3, ['price' => 1000])
->create();

expect($order->total())
->toBeInt()
->toBeGreaterThan(0)
->toBe(3000);

expect($order->items)
->toHaveCount(3)
->each->toBeInstanceOf(OrderItem::class);
});

Pest datasets

1
2
3
4
5
6
7
8
9
dataset('valid emails', [
'standard' => ['user@example.com'],
'subdomain' => ['user@mail.example.com'],
'plus addressing' => ['user+tag@example.com'],
]);

test('accepts valid email addresses', function (string $email) {
expect(filter_var($email, FILTER_VALIDATE_EMAIL))->not->toBeFalse();
})->with('valid emails');

Pest architecture testing

Pest includes architecture tests that enforce structural rules:

1
2
3
4
5
6
7
8
9
10
11
arch('models should extend base model')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');

arch('controllers should not use DB facade directly')
->expect('App\Http\Controllers')
->not->toUse('Illuminate\Support\Facades\DB');

arch('no debugging functions in production code')
->expect(['dd', 'dump', 'ray', 'var_dump'])
->not->toBeUsed();

Unit testing patterns

Testing pure functions

1
2
3
4
5
6
// Simple, deterministic tests
test('price formatter handles currencies', function () {
expect(formatPrice(1999, 'USD'))->toBe('$19.99');
expect(formatPrice(1999, 'EUR'))->toBe('€19.99');
expect(formatPrice(0, 'USD'))->toBe('$0.00');
});

Testing with mocks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
test('order service calculates shipping', function () {
$shippingCalculator = Mockery::mock(ShippingCalculator::class);
$shippingCalculator
->shouldReceive('calculate')
->with(Mockery::type(Address::class), Mockery::type('float'))
->andReturn(599); // $5.99

$service = new OrderService($shippingCalculator);
$total = $service->calculateTotal(
items: [['price' => 2000, 'qty' => 2]],
address: new Address(country: 'US', zip: '90210'),
);

expect($total)->toBe(4599); // $40.00 + $5.99
});

Testing exceptions

1
2
3
4
5
test('rejects negative quantities', function () {
$cart = new Cart();

$cart->addItem(productId: 1, quantity: -1);
})->throws(InvalidArgumentException::class, 'Quantity must be positive');

Integration testing

Integration tests verify that components work together. For PHP web applications, this means testing HTTP endpoints with a real database.

Laravel feature tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
test('authenticated user can create article', function () {
$user = User::factory()->create();

$response = $this->actingAs($user)->postJson('/api/articles', [
'title' => 'Testing PHP in 2026',
'body' => 'Content here...',
'status' => 'draft',
]);

$response->assertStatus(201)
->assertJson([
'data' => [
'title' => 'Testing PHP in 2026',
'status' => 'draft',
'author_id' => $user->id,
],
]);

$this->assertDatabaseHas('articles', [
'title' => 'Testing PHP in 2026',
'author_id' => $user->id,
]);
});

Database testing strategies

1
2
3
4
5
6
7
8
// Option 1: RefreshDatabase (migrate fresh for each test class)
uses(RefreshDatabase::class);

// Option 2: DatabaseTransactions (wrap each test in a transaction)
uses(DatabaseTransactions::class);

// Option 3: LazilyRefreshDatabase (migrate only when needed)
uses(LazilyRefreshDatabase::class);

DatabaseTransactions is fastest because it rolls back instead of dropping/recreating tables. RefreshDatabase is safest because it starts from a clean state.

Code coverage

Setting up coverage

1
2
3
4
5
# With Xdebug
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html coverage/

# With PCOV (faster, recommended for CI)
php -dpcov.enabled=1 vendor/bin/phpunit --coverage-html coverage/

Coverage configuration

1
2
3
4
5
6
7
<!-- phpunit.xml -->
<coverage>
<report>
<html outputDirectory="coverage" lowUpperBound="50" highLowerBound="80"/>
<clover outputFile="coverage/clover.xml"/>
</report>
</coverage>

What to measure

  • Line coverage: Which lines were executed. The most common metric.
  • Branch coverage: Which branches (if/else, switch) were taken. More meaningful than line coverage.
  • Path coverage: Which paths through functions were taken. Most thorough but slowest.

Coverage targets

Code type Target Why
Business logic 90%+ Core value, must be correct
Controllers 80%+ Integration tests cover these
Models/DTOs 70%+ Often simple, high coverage is easy
Config/bootstrap Skip Not worth testing

CI integration

GitHub Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: pcov
- run: composer install --no-interaction
- run: vendor/bin/phpunit --coverage-clover coverage.xml
- uses: codecov/codecov-action@v4
with:
files: coverage.xml

Common mistakes

Testing implementation instead of behavior: Tests should verify what a function does, not how it does it. If refactoring breaks tests that verify internal method calls, those tests are too coupled.

Skipping test data cleanup: Tests that leave data behind cause flaky failures in other tests. Use database transactions or RefreshDatabase.

Mocking everything: If a unit test mocks every dependency, it only tests that you configured the mocks correctly. Use real implementations when practical.

Ignoring slow tests: A test suite that takes 10 minutes gets run less often. Profile your tests, optimize the slow ones, and parallelize.

FAQ

Should I use PHPUnit or Pest?

Both work well. Pest has cleaner syntax and architecture testing. PHPUnit has wider ecosystem support and more documentation. New projects tend to prefer Pest; existing codebases can mix both.

What code coverage percentage should I target?

80% overall is a reasonable target. 100% is usually not worth the effort—the last 20% covers edge cases that are expensive to test and unlikely to fail.

How do I test private methods?

You don’t. Test the public interface that calls the private method. If a private method is complex enough to need its own tests, extract it into a separate class.

Next steps

Start by running composer require --dev pestphp/pest and writing three tests: one unit test, one feature test, and one architecture test. Then add coverage reporting to your CI pipeline.

For testing PHP APIs specifically, the REST API best practices guide includes API testing patterns. For managing test dependencies with Composer, ensure your test frameworks are in require-dev and always run composer install --no-dev in production.