Writing Unit Tests
Knowing how to run PHPUnit is only half the picture — the harder skill is writing tests that are clear, focused, and actually catch the bugs that matter. This page covers a simple structural pattern for every test you write, the assertion methods you'll reach for most, how to name tests so failures are self-explanatory, and data providers for testing many inputs without copy-pasting the same test body ten times.
The Arrange-Act-Assert pattern
Almost every good unit test breaks into three clear steps, often called Arrange-Act-Assert (or "given, when, then" if you prefer that phrasing):
Arrange — set up everything the test needs: create objects, prepare input data, configure any dependencies.
Act — call the single method or function actually being tested.
Assert — check that the result matches what you expected.
Arrange-Act-Assert in practice
<?php
use PHPUnit\Framework\TestCase;
class DiscountTest extends TestCase
{
public function testTenPercentDiscountReducesPrice(): void
{
// Arrange
$originalPrice = 200.0;
$discountRate = 0.10;
// Act
$finalPrice = apply_discount($originalPrice, $discountRate);
// Assert
$this->assertSame(180.0, $finalPrice);
}
}Keeping these three steps visually separate — even just with blank lines or comments, as above — makes a test easy to scan. When a test fails, the first thing you want to know is what was being asserted, and the pattern puts that right at the bottom, in the same spot every time.
Common assertion methods
PHPUnit ships dozens of assert* methods, but a handful cover the large majority of real tests.
assertEquals($expected, $actual)— checks the two values are equal, using PHP's loose comparison rules (so1and'1'are considered equal).assertSame($expected, $actual)— checks both value and type match, using PHP's strict===comparison. Prefer this overassertEqualswhenever the type matters, which in practice is most of the time.assertTrue($condition)/assertFalse($condition)— checks a boolean expression, useful for testing predicate-style methods likeisValid().assertInstanceOf(SomeClass::class, $object)— checks that a returned object is of the expected class, useful when a factory method or interface method can return different concrete types.expectException(SomeException::class)— declares, before calling the method under test, that it should throw a specific exception. If it doesn't throw, the test fails.
Testing that invalid input throws
<?php
use PHPUnit\Framework\TestCase;
use InvalidArgumentException;
class UsernameValidatorTest extends TestCase
{
public function testEmptyUsernameThrows(): void
{
$this->expectException(InvalidArgumentException::class);
validate_username('');
}
public function testValidUsernamePasses(): void
{
$this->assertTrue(is_valid_username('daniela92'));
}
}expectException() has to be called before the code that's expected to throw — it tells PHPUnit "the very next thing that runs should raise this exception," and the assertion happens automatically once execution reaches the end of the test method.
Naming tests so failures explain themselves
A test method name should describe the scenario and the expected outcome, not just repeat the method under test. testDiscount() tells you almost nothing when it fails at 2am during a CI run; testTenPercentDiscountReducesPriceByExactlyTenPercent() tells you immediately what broke, without opening the file.
Prefer
testThrowsWhenEmailIsMissingAtSignovertestEmail.Prefer
testReturnsEmptyArrayWhenNoResultsFoundovertestSearch2.One assertion concept per test where practical — a test named for one scenario that actually checks three unrelated things makes failures harder to diagnose.
Data providers for parameterized tests
Often you want to run the exact same assertion logic against many different inputs — several valid emails, several invalid ones. A data provider lets you supply a list of input/expected-output pairs once, and PHPUnit runs the test method once per row, reporting each as a separate test.
Data provider using the PHP 8 attribute form
<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class EmailValidatorTest extends TestCase
{
public static function emailProvider(): array
{
return [
'simple address' => ['user@example.com', true],
'missing at sign' => ['userexample.com', false],
'missing domain' => ['user@', false],
'plus addressing' => ['user+tag@example.com', true],
];
}
#[DataProvider('emailProvider')]
public function testEmailValidation(string $email, bool $expected): void
{
$this->assertSame($expected, is_valid_email($email));
}
}Each array key in the provider ('simple address', 'missing at sign') becomes a label in the test output, so a failing row shows up as e.g. testEmailValidation with data set "missing domain" rather than an anonymous numbered case. Older codebases use a @dataProvider emailProvider docblock annotation instead of the #[DataProvider] attribute — both work, but the attribute form is preferred in PHP 8 projects since it's checked by the language itself rather than a comment string.
Structure every test as Arrange, Act, then Assert.
Reach for
assertSame()overassertEquals()by default; useexpectException()for error paths.Name tests after the scenario and expected outcome, not the method name alone.
Use a data provider instead of duplicating a test body for each input.
Never let one test's leftover state affect another — reset everything in
setUp().