PHPUnit Fundamentals — Installation, Assertions, Fixtures, Data Providers, and Coverage

Install and configure PHPUnit via Composer, write test classes that follow PHPUnit's conventions, use the common assertion methods correctly, understand how PHPUnit discovers and runs tests, use setUp()/tearDown() for fixtures, parameterize tests with data providers, verify that code throws the right exception, and know what code coverage does and does not tell you.

PHPUnit is the de facto standard testing framework for PHP, and it's the tool every layer of the testing pyramid ultimately runs through — whether a test is a millisecond-fast unit test or a slower integration test against a real database, it's almost always a PHPUnit TestCase underneath. This lesson is the hands-on foundation: how to get PHPUnit installed and configured, the anatomy of a test class, the assertion vocabulary you'll use constantly, how PHPUnit finds and runs your tests, how to set up and tear down fixtures around each test, how to avoid copy-pasted near-duplicate tests with data providers, how to assert that code throws an exception, and finally what a code coverage report is actually measuring — and the specific ways a high coverage percentage can still hide untested behavior.

Installing PHPUnit and the anatomy of a test class

PHPUnit is installed per project as a Composer dev dependency — never globally and never bundled into production — with composer require --dev phpunit/phpunit. This adds it to composer.json's require-dev section and installs the phpunit executable into vendor/bin/phpunit, which is how you'll run tests: vendor/bin/phpunit, not a globally installed phpunit binary, so that every developer and CI run uses the exact version the project has locked. A phpunit.xml (or phpunit.xml.dist) configuration file at the project root tells PHPUnit where to find tests, which bootstrap file to load (typically vendor/autoload.php, so Composer's autoloader is available), and how to define test suites.

A PHPUnit test class is an ordinary PHP class that extends PHPUnit\Framework\TestCase, conventionally named after the class it tests with a Test suffix (a class Order gets a test class OrderTest), and conventionally placed in a tests/ directory mirroring the structure of src/. Inside it, any public method whose name starts with test (testAddsTwoLineItems()) — or any method carrying the #[Test] attribute in modern PHPUnit — is treated as an individual test case that PHPUnit will discover and run independently, with its own pass/fail result and its own fresh instance of the test class.

• composer require --dev phpunit/phpunit — a dev dependency, never shipped to production. • Run tests with vendor/bin/phpunit, which uses the exact version locked in composer.lock, not a global install. • A test class extends PHPUnit\Framework\TestCase and is conventionally named <ClassUnderTest>Test. • A method is a test case if its name starts with test..., or is annotated with the #[Test] attribute.

Test discovery and running the suite

PHPUnit discovers tests by scanning the directories configured in phpunit.xml's <testsuites> block, looking for classes that extend TestCase (directly or indirectly) inside files matching its naming convention (by default, files ending in Test.php). It doesn't require a manifest of test files to run — adding a new *Test.php file under the configured directory is enough for PHPUnit to pick it up automatically on the next run, with no registration step. Running vendor/bin/phpunit with no arguments runs every discovered test in every configured suite; you can narrow this to a single file (vendor/bin/phpunit tests/Unit/OrderTest.php), a single method (--filter testAddsTwoLineItems), or a named suite defined in the config (--testsuite unit) to run just the fast layer during active development.

Each test method runs independently, in its own fresh instance of the test class (PHPUnit constructs a new object per test method specifically so state from one test can't leak into another), and PHPUnit reports a dot per passing test, an F per failure, and an E per error (an uncaught exception PHPUnit didn't expect), followed by a summary and full detail on every failure — the class, method, assertion, and a diff of expected vs. actual where applicable.

The core assertion vocabulary

Assertions are how a test states what it expects and fails loudly when reality doesn't match. assertEquals($expected, $actual) checks loose equality (comparing values, with some type coercion for compatible types), while assertSame($expected, $actual) checks strict equality (=== — same type and, for objects, the same instance) and should be your default for scalars unless you have a specific reason to allow type coercion. assertTrue()/assertFalse() check a boolean condition directly; assertNull() checks for null specifically, which is more precise and gives a clearer failure message than assertSame(null, $value).

assertCount($expectedCount, $iterable) checks the size of an array or Countable without you writing count() yourself and getting a less useful failure message. assertInstanceOf(SomeClass::class, $object) checks an object's type, which matters constantly when testing factory methods or polymorphic return values. assertStringContainsString(), assertArrayHasKey(), assertEmpty(), and assertGreaterThan() round out the everyday set. The general principle: reach for the most specific assertion available for what you're checking — assertCount(3, $items) over assertSame(3, count($items)), assertInstanceOf(Order::class, $result) over assertTrue($result instanceof Order) — because a specific assertion produces a clearer, more actionable failure message when it fails.

• assertSame($expected, $actual) — strict === comparison; the default choice for scalars. • assertEquals($expected, $actual) — loose comparison with type coercion; use deliberately, not by default. • assertTrue() / assertFalse() / assertNull() — direct, specific boolean/null checks. • assertCount($n, $iterable), assertInstanceOf($class, $obj), assertArrayHasKey($key, $array), assertStringContainsString($needle, $haystack) — specific checks with clear failure messages.

setUp(), tearDown(), and shared fixtures

A test method's job is to test one behavior, not to rebuild the same scaffolding — a repository instance, a fixed 'current time', a handful of sample objects — from scratch in every single method. PHPUnit calls setUp() automatically before every test method in the class (not once per class — once per test, since each test gets a fresh instance), which is the place to construct fresh fixtures that every test in the class needs, assigned to instance properties so test methods can use them directly. tearDown() runs automatically after every test method, even if the test failed or threw, and is the place to release anything that setUp() acquired that wouldn't clean itself up — closing a file handle, rolling back a database transaction opened for the test.

Because setUp() runs fresh before every single test method, fixtures built there can't leak state between tests — this is a deliberate and important property, since a test suite where test order or shared mutable state affects results is a suite that produces intermittent, confusing failures. If you need something that's genuinely expensive to build once per test class rather than once per test (a rare need — usually reserved for integration tests with a costly real connection), PHPUnit offers setUpBeforeClass()/tearDownAfterClass() as static methods running once for the whole class, but reaching for these means being deliberate about the shared-state risk they reintroduce.

Data providers: one test method, many scenarios

When the same logical test needs to run against several different inputs — validating an email format across a dozen valid and invalid strings — copy-pasting a near-identical test method per case is a maintenance trap: a shared bug in the assertion logic has to be fixed in a dozen places. A data provider solves this: it's a separate method that returns an iterable of argument sets, and the #[DataProvider('methodName')] attribute above a test method tells PHPUnit to run that one test method once per entry in the provider, injecting each entry's values as the test method's parameters. PHPUnit reports each data set as its own pass/fail line (often labeled with the data set's index or a name you supply as the array key), so a failure in one input doesn't hide the others.

Data providers keep one test method focused on one behavior — 'this function correctly identifies valid vs. invalid emails' — while covering many concrete inputs, which is both less code to maintain and a clearer signal when something breaks: you see exactly which specific inputs started failing, not a single vague failing test covering a dozen unrelated assertions.

• #[DataProvider('methodName')] above a test method, paired with a provider method returning an iterable of argument arrays. • PHPUnit runs the test method once per data set, reporting each one's pass/fail independently. • Naming data sets (string array keys in the provider) makes failure output readable instead of just '#3 failed'. • Prefer a data provider over copy-pasted near-duplicate test methods whenever the only difference between them is input values.

Testing that an exception is thrown

Verifying that invalid input correctly triggers an exception is just as important as verifying that valid input produces the right result — a validation method that silently accepts garbage is a real bug. PHPUnit's pattern for this is $this->expectException(SomeException::class) called before the code that should throw, optionally paired with expectExceptionMessage() or expectExceptionMessageMatches() to also check the message, and expectExceptionCode() to check a specific exception code. Once expectException() is called, PHPUnit expects the exception to be thrown by the remainder of the test method — if it isn't thrown, the test fails (rather than silently passing), which is exactly the safety property you want: this correctly catches the case where the exception was accidentally removed from the code being tested.

A common mistake is putting code after the exception-triggering call inside the same test that assumes execution continues normally — once the expected exception is thrown, PHPUnit stops executing the rest of that test method (control never returns to the line after the throwing call, just like normal exception propagation), so any assertions meant to run after the exception need to happen before expectException() is called, or in a separate test entirely.

Code coverage: what it tells you, and what it doesn't

Code coverage measures which lines (or branches) of your source code were executed at least once while the test suite ran, typically generated with vendor/bin/phpunit --coverage-html coverage-report/ (which requires the Xdebug or PCOV extension installed, since PHP itself doesn't track line execution by default) and browsed as an HTML report showing covered lines in one color and uncovered lines in another, plus an overall percentage per file and per project. Coverage is genuinely useful for one specific thing: finding code that no test exercises at all — a whole branch of an if/else, an entire method, a whole class with zero tests — which is real, actionable information about blind spots in the suite.

What coverage does not tell you is whether the code that was executed was actually verified to behave correctly. A line can be 'covered' by a test that calls the function and asserts nothing about its result, or asserts something trivially true — the line executed, the coverage tool marks it green, and the test provides essentially zero confidence. This is why '100% coverage' is not the same claim as 'fully tested,' and why chasing a coverage percentage as a goal in itself can produce a suite full of tests that execute code without meaningfully checking it. Coverage is a floor-finding tool (what's definitely untested) not a ceiling-proving one (what's definitely correct) — use it to find gaps, not as a substitute for judgment about whether the assertions in each test actually pin down the behavior that matters.

• --coverage-html generates a browsable report; requires Xdebug or PCOV, since PHP has no built-in coverage tracking. • Coverage finds code with ZERO tests touching it — genuinely useful for spotting blind spots. • Coverage cannot tell you whether an executed line was actually verified — a test with no or weak assertions still counts as 'covered'. • 100% coverage is not the same claim as 'fully tested'; treat the percentage as a floor-finder, not a quality score.

PHPUnit test class skeleton

setUp() runs before EVERY test method (fresh instance each time), not once per class — this is what keeps tests isolated from each other. Data provider methods must be static in modern PHPUnit, and the #[DataProvider] attribute names them by string. expectException() must be called BEFORE the code that's expected to throw — execution stops at the throw, so nothing after it in that test runs.

// composer require --dev phpunit/phpunit
// vendor/bin/phpunit                          (run everything)
// vendor/bin/phpunit --filter testAddsTwoLineItems
// vendor/bin/phpunit --coverage-html coverage-report/

declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use App\Domain\Order;
use App\Domain\LineItem;
use App\Domain\InvalidQuantityException;

final class OrderTest extends TestCase
{
    private Order $order;

    protected function setUp(): void
    {
        $this->order = new Order(); // fresh fixture before EVERY test method
    }

    protected function tearDown(): void
    {
        // release anything acquired in setUp() that needs explicit cleanup
    }

    public function testStartsWithZeroTotal(): void
    {
        $this->assertSame(0, $this->order->totalInCents());
    }

    #[DataProvider('lineItemProvider')]
    public function testTotalSumsLineItems(int $qty, int $priceCents, int $expectedTotal): void
    {
        $this->order->addItem(new LineItem('Widget', $qty, $priceCents));
        $this->assertSame($expectedTotal, $this->order->totalInCents());
    }

    public static funct
…

Code Examples

assertCount(2, $active) fails with 'Failed asserting that actual size 1 matches expected size 2' if the filter logic breaks — far more actionable than assertSame(2, count($active)), which would just report a generic '1 does not equal 2' with no mention of what was being counted. assertNull() rather than assertSame(null, $x) is preferred for the same reason: PHPUnit's failure message for assertNull explicitly says 'Failed asserting that ... is null,' which is immediately clear even out of context.

$this->assertCount(2, $active);

PHPUnit runs testIsValid() once per named entry, printing each as its own pass/fail line labeled by its key ('missing @', 'leading space', ...), so a regression in one specific case is immediately visible without hunting through five separately named test methods that all call the same assertion.

Common mistake: Copy-pasting testIsValidRejectsMissingAt(), testIsValidRejectsMissingDomain(), etc. as five separate methods means a bug in the shared assertion logic — or a needed change to how validity is checked — has to be found and fixed in five places instead of one.

declare(strict_types=1);

final class UserRepositoryTest extends \PHPUnit\Framework\TestCase
{
    public function testFindActiveReturnsOnlyActiveUsers(): void
    {
        $repository = new InMemoryUserRepository([
            new User('alice', active: true),
            new User('bob', active: false),
            new User('carol', active: true),
        ]);

        $active = $repository->findActive();

        // Specific assertions -> specific, useful failure messages
        $this->assertCount(2, $active);
        $this->assertContainsOnlyInstancesOf(User::class, $active);
        $this->assertSame(['alice', 'carol'], array_map(fn (User $u) => $u->name(), $active));
    }

    public function testFindByNameReturnsNullWhenNotFound(): void
    {
        $repository = new InMemoryUserRepository([]);

        $this->assertNull($repository->findByName('nobody'));
    }
}
declare(strict_types=1);

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class EmailValidatorTest extends TestCase
{
    #[DataProvider('emails')]
    public function testIsValid(string $email, bool $expectedValid): void
    {
        $this->assertSame($expectedValid, EmailValidator::isValid($email));
    }

    public static function emails(): iterable
    {
        yield 'plain valid address' => ['alice@example.com', true];
        yield 'valid with subdomain' => ['bob@mail.example.co.uk', true];
        yield 'missing @' => ['not-an-email', false];
        yield 'missing domain' => ['alice@', false];
        yield 'leading space' => [' alice@example.com', false];
    }
}

PHPUnit test lifecycle, per test method

A fresh instance and a fresh setUp()/tearDown() cycle per test method is what keeps tests isolated.

new TestCase instance
        |
        v
  setUp()  -----------------> fresh fixtures for THIS test only
        |
        v
  test method runs  --------> assertions pass/fail/throw
        |
        v
  tearDown()  --------------> cleanup, runs even if the test failed
        |
        v
  (repeat: a NEW instance is created for the NEXT test method)

Important Rules

Install PHPUnit as a Composer dev dependency and always run it via vendor/bin/phpunit, never a global install.

Prefer assertSame() over assertEquals() for scalars unless loose comparison is specifically intended.

Prefer the most specific assertion available (assertCount, assertInstanceOf, assertNull) over a generic assertTrue()/assertSame() with a manual check.

Build fixtures in setUp() so every test method starts from the same known, fresh state — never share mutable state across test methods.

Use a data provider instead of copy-pasting near-duplicate test methods that differ only in input values.

Treat a code coverage percentage as a tool for finding untested code, never as proof that tested code is correct.

How PHPUnit fits into a real project's workflow

A typical PHP project runs vendor/bin/phpunit --testsuite unit on every file save via an editor integration for instant feedback, runs the full suite (unit + integration + feature) on every push through CI, and generates a coverage report (--coverage-html, or --coverage-clover for a machine-readable format fed into a tool like Codecov) as part of that CI run so the team can see coverage trends over time and get a PR comment flagging newly added, uncovered lines. Data providers are used constantly for validation logic, pricing rules, and anything with many boundary cases, keeping the test files a fraction of the size copy-pasted methods would require.

public function testDivide(): void { try { $this->calculator->divide(10, 0); $this->fail('Expected exception was not thrown'); } catch (DivisionByZeroError $e) { $this->assertTrue(true); } } public function testDivide(): void { $this->expectException(DivisionByZeroError::class); $this->calculator->divide(10, 0); }

The try/catch version works but is verbose, error-prone (it's easy to forget the $this->fail() call, which would make the test pass even if no exception is thrown), and doesn't read as clearly as a direct expectation. expectException() expresses the same intent in two lines and fails automatically if the exception never arrives.

// CI gate: require 100% code coverage before merging any PR.

// CI gate: require coverage on new/changed lines, and review whether new tests have meaningful assertions, not just that lines execute.

Chasing 100% coverage as a hard requirement tends to produce tests written purely to make a line green — calling a function with no assertion, or a trivially-true assertion — which inflates the coverage number without adding real confidence, and can even discourage testing the genuinely hard cases in favor of easy lines that pad the percentage.

How should PHPUnit be installed and run in a PHP project? A. Installed globally with a system package manager and run as phpunit from anywhere. B. As a Composer dev dependency (composer require --dev phpunit/phpunit), run via vendor/bin/phpunit. C. Downloaded as a single .phar file and committed directly into the src/ directory. D. It comes bundled with PHP itself and needs no installation.

B. As a Composer dev dependency (composer require --dev phpunit/phpunit), run via vendor/bin/phpunit. — Installing PHPUnit as a dev dependency and running the project-local vendor/bin/phpunit ensures every developer and CI run uses the exact version locked in composer.lock, rather than whatever happens to be installed globally.

What is the key difference between assertSame() and assertEquals()? A. assertSame() checks strict equality (===, including type), while assertEquals() allows loose comparison with type coercion. B. They are exact synonyms with no behavioral difference. C. assertEquals() only works on strings; assertSame() works on any type. D. assertSame() is deprecated in favor of assertEquals().

A. assertSame() checks strict equality (===, including type), while assertEquals() allows loose comparison with type coercion. — assertSame() uses strict comparison (===), catching type mismatches that assertEquals()'s loose comparison would let pass — which is why assertSame() should be the default choice for scalar values unless loose comparison is specifically what you want.

What does the #[DataProvider] attribute do, and why is it preferred over copy-pasted test methods? A. It disables a test so it doesn't run in CI. B. It runs one test method once per data set from a provider method, avoiding copy-pasted near-duplicate methods that all share the same assertion logic. C. It generates random test data automatically with no explicit values. D. It marks a test as an integration test rather than a unit test.

B. It runs one test method once per data set from a provider method, avoiding copy-pasted near-duplicate methods that all share the same assertion logic. — A data provider lets a single test method be exercised against many named input/output pairs, so a bug in the shared assertion logic is fixed in one place instead of several, and each data set's pass/fail is reported independently.

A project reports 100% code coverage. What can be correctly concluded from that number alone? A. Every line of code was executed at least once by the test suite during the run; it does NOT prove every execution was meaningfully verified with correct assertions. B. The application is fully bug-free. C. No further testing is needed for this codebase. D. Every possible input and edge case has been tested. E. The test suite covers the pyramid's E2E layer completely.

A. Every line of code was executed at least once by the test suite during the run; it does NOT prove every execution was meaningfully verified with correct assertions. — Coverage measures execution, not verification — a line can execute inside a test with a weak or missing assertion and still count as covered, so 100% coverage tells you nothing ran untouched, but says nothing about whether what ran was actually checked for correctness.

Write a PHPUnit test class for a class Temperature with a static method fromCelsius(float $c): self and an instance method toFahrenheit(): float. Use a data provider to check at least four Celsius-to-Fahrenheit conversions (including 0°C = 32°F and 100°C = 212°F), and write a separate test asserting that fromCelsius() throws an InvalidArgumentException when given a value below absolute zero (-273.15).

assertEqualsWithDelta() is more appropriate than assertSame() for floating-point results, since float arithmetic can introduce tiny rounding differences.