Tests let you refactor and upgrade PHP versions with confidence, and every serious PHP project has them.
PHPUnit is the standard PHP testing framework (Pest, built on top of it, offers a terser function-based syntax). Install it per project with composer require --dev phpunit/phpunit and run vendor/bin/phpunit.
A test case is a class extending PHPUnit\Framework\TestCase. Each public method whose name starts with test, or which has the #[Test] attribute, is a test. Modern PHPUnit (11 and 12) uses attributes instead of docblock annotations.
assertSame($expected, $actual)compares with===. Prefer it toassertEquals, which compares loosely.assertTrue,assertCount,assertContains,assertInstanceOf,assertStringContainsStringcover most checks.$this->expectException(InvalidArgumentException::class)before the call asserts that it throws.setUp()runs before every test to build fresh fixtures.
A data provider runs one test with many inputs: a public static method returns named cases, and #[DataProvider('cases')] links it.
Replace slow or external collaborators (HTTP clients, mailers) with test doubles: $this->createStub(Clock::class) returns canned values, and createMock() additionally verifies calls. Doubles work best when your classes depend on interfaces injected through the constructor.
Configure test suites and coverage in phpunit.xml; coverage needs the Xdebug or PCOV extension.
Example
<?php
// tests/PriceTest.php, run with vendor/bin/phpunit tests
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class PriceTest extends TestCase
{
public static function discounts(): array
{
return [
'no discount' => [100, 0, 100],
'ten percent' => [100, 10, 90],
'rounds down' => [99, 50, 49],
];
}
#[DataProvider('discounts')]
public function testApplyDiscount(int $price, int $pct, int $expected): void
{
$this->assertSame($expected, intdiv($price * (100 - $pct), 100));
}
public function testRejectsBadInput(): void
{
$this->expectException(ValueError::class);
str_repeat('x', -1); // ValueError: times must be >= 0
}
}Remember
- Tests extend TestCase; use assertSame for strict checks
- Attributes such as #[DataProvider] replace old docblock annotations
- Inject interfaces so collaborators can be replaced by stubs and mocks
Watch out for
- Using assertEquals where types matter: assertEquals(1, '1') passes.