PHP unit testing is the habit that lets you refactor on a Friday without dreading Monday. This guide compares PHPUnit and Pest, then walks through writing your first tests, data providers, mocking, handling code with side effects, measuring coverage and running everything in GitHub Actions.
PHPUnit vs Pest: which should you pick?
PHPUnit is the standard testing framework for PHP. Tests are classes that extend PHPUnitFrameworkTestCase, and each public test method contains assertions. Nearly every PHP library and framework ships PHPUnit tests, so it’s the safe default.
Pest is built on top of PHPUnit. It runs on the same engine but offers a functional syntax with test() and it() closures and a fluent expect() API. Because Pest runs PHPUnit underneath, you can keep existing PHPUnit test classes in the same suite and migrate gradually.
| Concern | PHPUnit | Pest |
|---|---|---|
| Style | Classes and methods | Closures and expectations |
| Ecosystem | Universal | Strong in Laravel, growing elsewhere |
| Parameterised tests | Data providers | Datasets |
| Mixing | n/a | Runs PHPUnit classes too |
I use PHPUnit for libraries and WordPress plugins, where contributors expect it, and Pest for Laravel apps where the team likes its readability. The concepts below apply to both, and I’ll show each where the syntax differs.
Installing and configuring your test suite
Both install as dev dependencies. Check the official docs for the version that matches your PHP release, because each major version of PHPUnit drops older PHP versions.
# PHPUnit
composer require --dev phpunit/phpunit
# Pest (installs PHPUnit as a dependency)
composer require --dev pestphp/pest
vendor/bin/pest --init
Tests need an autoloader entry under autoload-dev so test helpers never ship to production. If that mapping is unfamiliar, read my guide to Composer PSR-4 autoloading. A minimal phpunit.xml for recent PHPUnit versions looks like this:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true" failOnRisky="true" failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
The <source> element tells PHPUnit which code counts for coverage. Older PHPUnit versions used a <coverage> element instead; vendor/bin/phpunit --migrate-configuration updates an old file for you.
Writing your first PHP unit test
Start with pure logic: a function that takes input and returns output with no database, network or clock involved. Here’s a small class that calculates an order total with a percentage discount.
<?php
declare(strict_types=1);
namespace AppPricing;
final class PriceCalculator
{
public function total(int $subtotalCents, int $discountPercent = 0): int
{
if ($discountPercent < 0 || $discountPercent > 100) {
throw new InvalidArgumentException('Discount must be between 0 and 100.');
}
return (int) round($subtotalCents * (100 - $discountPercent) / 100);
}
}
The PHPUnit test follows the arrange, act, assert pattern:
<?php
declare(strict_types=1);
namespace TestsUnit;
use AppPricingPriceCalculator;
use PHPUnitFrameworkTestCase;
final class PriceCalculatorTest extends TestCase
{
public function test_it_applies_a_percentage_discount(): void
{
$calculator = new PriceCalculator();
$total = $calculator->total(10_000, 15);
$this->assertSame(8_500, $total);
}
public function test_it_rejects_invalid_discounts(): void
{
$this->expectException(InvalidArgumentException::class);
(new PriceCalculator())->total(10_000, 120);
}
}
The same tests in Pest:
<?php
use AppPricingPriceCalculator;
it('applies a percentage discount', function () {
expect((new PriceCalculator())->total(10_000, 15))->toBe(8_500);
});
it('rejects invalid discounts', function () {
(new PriceCalculator())->total(10_000, 120);
})->throws(InvalidArgumentException::class);
Prefer assertSame() (or Pest’s toBe()) over assertEquals(). Strict comparison catches the difference between "8500" and 8500, which is exactly the kind of bug you want tests to find.
Data providers and datasets
When the same behaviour needs checking against many inputs, don’t copy the test. Use a data provider. In current PHPUnit versions providers are declared with an attribute and must be public static methods; the older @dataProvider docblock annotation is deprecated in recent releases.
<?php
use PHPUnitFrameworkAttributesDataProvider;
final class PriceCalculatorTest extends TestCase
{
public static function discountCases(): iterable
{
yield 'no discount' => [10_000, 0, 10_000];
yield 'half price' => [10_000, 50, 5_000];
yield 'rounds half up' => [999, 50, 500];
yield 'free' => [10_000, 100, 0];
}
#[DataProvider('discountCases')]
public function test_total(int $subtotal, int $discount, int $expected): void
{
$this->assertSame($expected, (new PriceCalculator())->total($subtotal, $discount));
}
}
Named keys show up in failure messages, so you see “half price” instead of “data set #1”. Pest calls these datasets:
<?php
it('calculates totals', function (int $subtotal, int $discount, int $expected) {
expect((new PriceCalculator())->total($subtotal, $discount))->toBe($expected);
})->with([
'no discount' => [10_000, 0, 10_000],
'half price' => [10_000, 50, 5_000],
'free' => [10_000, 100, 0],
]);
Mocking dependencies
A unit test should test one unit. When a class depends on a mailer or payment gateway, replace that dependency with a test double so the test is fast and doesn’t send real emails. This only works if the dependency is injected, typically through the constructor and typed against an interface. That’s dependency injection doing its job, and it’s one of the patterns I cover in design patterns in PHP.
<?php
interface Mailer
{
public function send(string $to, string $subject, string $body): bool;
}
final class OrderConfirmation
{
public function __construct(private Mailer $mailer) {}
public function notify(string $email, string $orderId): void
{
$this->mailer->send($email, "Order {$orderId} confirmed", 'Thanks for your order.');
}
}
PHPUnit’s built-in mocks verify the interaction:
<?php
public function test_it_emails_the_customer(): void
{
$mailer = $this->createMock(Mailer::class);
$mailer->expects($this->once())
->method('send')
->with('[email protected]', 'Order A-100 confirmed', $this->anything())
->willReturn(true);
(new OrderConfirmation($mailer))->notify('[email protected]', 'A-100');
}
If you only need a dependency to return canned values and don’t care how it’s called, use createStub() instead. Over-specified mocks make tests brittle: they break when you refactor internals even though behaviour hasn’t changed. Many Pest users reach for Mockery, which has a similar shouldReceive() API.
Testing code with side effects
Time, randomness, the filesystem, HTTP calls and global state are what make code hard to test. The fix is the same each time: push the side effect behind an interface and inject it.
- Time: depend on
PsrClockClockInterface(PSR-20) instead of callingnew DateTimeImmutable()directly, then pass a frozen clock in tests. - Filesystem: write to a temporary directory created in
setUp()and deleted intearDown(), or wrap file access in a small storage interface. - HTTP: depend on a PSR-18 client or your own gateway interface, and return fake responses in tests.
- Globals and statics: wrap them. A class that reads
$_SERVERdirectly is far harder to test than one that receives a request object.
<?php
use PsrClockClockInterface;
final class FrozenClock implements ClockInterface
{
public function __construct(private DateTimeImmutable $now) {}
public function now(): DateTimeImmutable
{
return $this->now;
}
}
// In a test
$clock = new FrozenClock(new DateTimeImmutable('2026-01-15 10:00:00'));
$coupon = new CouponValidator($clock);
$this->assertFalse($coupon->isValid('NEWYEAR', expires: '2026-01-01'));
Code that genuinely must touch a database belongs in integration tests, kept in a separate suite so the fast unit tests stay fast.
Measuring code coverage
Coverage needs a driver: Xdebug or PCOV. PCOV is lighter and only does coverage, which makes it a good fit for CI; Xdebug also gives you step debugging locally.
# With Xdebug 3
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html build/coverage
# Pest, failing the run below a threshold
vendor/bin/pest --coverage --min=80
Treat coverage as a map of untested code, not a score. A high percentage with weak assertions proves little. I look at the uncovered branches in the HTML report and ask whether any of them handle money, permissions or data deletion. Those get tests first.
Running PHP unit tests in GitHub Actions
Tests only protect you if they run on every push. The community-maintained shivammathur/setup-php action installs PHP, extensions and a coverage driver in one step.
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
phpunit:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: pcov
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Run tests
run: vendor/bin/phpunit --coverage-text
The matrix runs the suite against each PHP version you support, which catches deprecations before your users do. Swap the last step for vendor/bin/pest if you use Pest. Add Composer caching once the workflow works; the setup-php README documents the recommended cache step.
Key takeaways
- Pest runs on PHPUnit, so the choice is mostly about syntax; you can mix both in one suite.
- Start with pure logic, use
assertSame()and follow arrange, act, assert. - Use data providers or datasets with named cases instead of duplicating tests.
- Inject dependencies so they can be mocked, and prefer stubs when you don’t need to verify calls.
- Hide time, HTTP and filesystem access behind interfaces to make side effects testable.
- Run the suite with coverage in CI across every PHP version you support.
Frequently asked questions
Can I use Pest and PHPUnit in the same project?
Yes. Pest runs on top of PHPUnit and will execute existing PHPUnit test classes alongside Pest test files, so you can migrate one file at a time.
Should I use Xdebug or PCOV for code coverage?
PCOV is usually faster and is a good default for CI because it only collects coverage. Use Xdebug when you also want step debugging or profiling on your local machine.
Why must data providers be static in PHPUnit?
Recent PHPUnit versions call data providers before any test case object exists, so they are required to be public static methods. Non-static providers trigger deprecations or errors depending on the version.
What code coverage percentage should I aim for?
There is no universal number. Focus on covering business-critical paths such as payments, permissions and data changes, and use the report to find untested branches rather than chasing a percentage.
Need help adding tests to an existing PHP or Laravel codebase, or setting up CI that the team will actually trust? Contact me and we can plan it together.