PHP Testing: PHPUnit & Pest
PHPUnit is the standard PHP testing framework. Pest is a modern testing framework built on top of PHPUnit with a more expressive syntax. Both produce the same output and can run the same tests.
PHPUnit Setup
composer require --dev phpunit/phpunit
# phpunit.xml (configuration)
# <?xml version="1.0"?>
# <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>tests/Unit</directory>
# </testsuite>
# <testsuite name="Integration">
# <directory>tests/Integration</directory>
# </testsuite>
# </testsuites>
# </phpunit>
./vendor/bin/phpunit # run all tests
./vendor/bin/phpunit tests/Unit # specific directory
./vendor/bin/phpunit --filter CartTest # specific test class
./vendor/bin/phpunit --filter testAdd # specific test method
./vendor/bin/phpunit --coverage-html coverage/PHPUnit Tests
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
class CartTest extends TestCase
{
private Cart $cart;
protected function setUp(): void
{
$this->cart = new Cart();
}
protected function tearDown(): void
{
// cleanup after each test
}
#[Test]
public function it_starts_empty(): void
{
$this->assertCount(0, $this->cart->items());
$this->assertSame(0.0, $this->cart->total());
}
public function testAddingItemIncreasesTotal(): void
{
$this->cart->add(new Item('Widget', 9.99));
$this->assertSame(9.99, $this->cart->total());
$this->assertCount(1, $this->cart->items());
}
public function testDuplicateItemIncreasesQuantity(): void
{
$item = new Item('Widget', 9.99);
$this->cart->add($item);
$this->cart->add($item);
$this->assertCount(1, $this->cart->items());
$this->assertSame(19.98, $this->cart->total());
}
public function testRemovingNonexistentItemThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Item not in cart');
$this->cart->remove(new Item('Ghost', 0.0));
}
// Data providers — run same test with multiple inputs
#[DataProvider('discountProvider')]
public function testDiscount(float $subtotal, float $pct, float $expected): void
{
$this->assertSame($expected, applyDiscount($subtotal, $pct));
}
public static function discountProvider(): array
{
return [
'ten percent off' => [100.0, 10, 90.0],
'no discount' => [50.0, 0, 50.0],
'full discount' => [80.0, 100, 0.0],
];
}
// Assertions reference
public function testAssertions(): void
{
$this->assertTrue($expr);
$this->assertFalse($expr);
$this->assertSame($expected, $actual); // === strict
$this->assertEquals($expected, $actual); // == loose
$this->assertNull($value);
$this->assertNotNull($value);
$this->assertCount(3, $collection);
$this->assertEmpty($collection);
$this->assertContains($needle, $haystack);
$this->assertArrayHasKey('key', $array);
$this->assertInstanceOf(User::class, $obj);
$this->assertStringContainsString('needle', $str);
$this->assertMatchesRegularExpression('/pattern/', $str);
}
}Mocking
<?php
class OrderServiceTest extends TestCase
{
public function testCreatesOrderAndSendsEmail(): void
{
// Create mock of PaymentGateway interface
$payment = $this->createMock(PaymentGateway::class);
$payment->expects($this->once())
->method('charge')
->with(9999, 'USD') // assert called with these args
->willReturn(new PaymentResult(success: true, transactionId: 'txn_123'));
$mailer = $this->createMock(Mailer::class);
$mailer->expects($this->once())
->method('send')
->with($this->callback(fn($email) => $email->to() === 'user@example.com'));
$service = new OrderService($payment, $mailer);
$order = $service->create(userId: 1, amount: 9999, currency: 'USD');
$this->assertTrue($order->isPaid());
$this->assertSame('txn_123', $order->transactionId());
}
public function testRefundsWhenEmailFails(): void
{
$payment = $this->createMock(PaymentGateway::class);
$payment->expects($this->once())->method('charge')->willReturn(new PaymentResult(true, 'txn_456'));
$payment->expects($this->once())->method('refund')->with('txn_456'); // assert refund called
$mailer = $this->createMock(Mailer::class);
$mailer->method('send')->willThrowException(new MailerException('SMTP error'));
$this->expectException(OrderException::class);
(new OrderService($payment, $mailer))->create(1, 9999, 'USD');
}
}Pest
<?php
// tests/Unit/CartTest.php — Pest syntax
use function Pest\Laravel\{get, post, actingAs};
// Simple test
it('starts empty', function () {
$cart = new Cart();
expect($cart->items())->toBeEmpty();
expect($cart->total())->toBe(0.0);
});
// Grouped tests
describe('Cart', function () {
beforeEach(function () {
$this->cart = new Cart();
});
it('adds items', function () {
$this->cart->add(new Item('Widget', 9.99));
expect($this->cart->total())->toBe(9.99);
});
it('throws when removing missing item', function () {
expect(fn() => $this->cart->remove(new Item('Ghost', 0))
)->toThrow(\InvalidArgumentException::class, 'Item not in cart');
});
});
// Data-driven with dataset()
it('applies discount correctly', function (float $subtotal, float $pct, float $expected) {
expect(applyDiscount($subtotal, $pct))->toBe($expected);
})->with([
'ten percent' => [100.0, 10, 90.0],
'no discount' => [50.0, 0, 50.0],
]);
// Pest expectations
expect($value)
->toBe(42)
->toBeTrue()
->toBeFalse()
->toBeNull()
->toBeEmpty()
->toBeInstanceOf(User::class)
->toContain('needle')
->toHaveCount(3)
->toHaveKey('name')
->toMatchArray(['name' => 'Alice'])
->toBeGreaterThan(0)
->not->toBeNull();Keep your own version of these notes — editable, searchable, and organised by your stack.
Start free