PHP
05 / 06

Testing: PHPUnit & Pest

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