Why the Test Runner Must Come From vendor/bin
You pull a teammate's branch, their CI badge is green, and your first local run dies with:
PHP Fatal error: Uncaught Error: Class "PHPUnit\Framework\TestCase" not found
Or the mirror image: your laptop is red with warnings their machine never prints. The application code is identical on both machines, so the disagreement is about the test runner — which version of PHPUnit is installed, and where it comes from.
PHPUnit is a project dependency, not a machine-wide utility. Add it to the project:
composer require --dev phpunit/phpunit
composer show phpunit/phpunit # installed version + the PHP constraint it declares
vendor/bin/phpunit --version # PHPUnit 11.5.x by Sebastian Bergmann and contributors
vendor/bin/phpunit --testsuite unit --testdox
--dev is the point, not a formality. Test dependencies live in require-dev, so a production image built with composer install --no-dev never receives PHPUnit, its autoloader, or any test-only package. Your deploy stays smaller and your attack surface stays smaller, while every developer and CI job gets the identical runner from composer.lock.
A fresh clone is the proof: git clone, composer install, vendor/bin/phpunit --version — and the runner answers with the exact locked version. Nothing to install globally, nothing to document beyond "this repo needs Composer". Editor integrations agree for free too: PHPUnit-aware plugins resolve vendor/bin/phpunit from the project, so the green you see in your IDE and the green in CI come from the same binary. A global phar breaks that symmetry — terminal and editor can execute different versions of the same command name, and the discrepancy only surfaces when a config feature one of them understands behaves differently.
Upgrading is then a deliberate, testable act rather than a hope:
composer require --dev phpunit/phpunit:^12
vendor/bin/phpunit # the entire suite is the upgrade test
If the suite stays green, your config and tests still speak the new runner's language. If it fails, you see exactly which removal or deprecation bit you — in a pull request, not in a deploy.
Version choice is constrained by the PHP version on the box, and composer enforces it for you:
| PHPUnit | Minimum PHP | What changed for test authors |
|---|---|---|
| 10.x | 8.1 | Attributes replace @ annotations, data providers become static, withConsecutive() removed |
| 11.x | 8.2 | Deprecation warnings for PHPUnit 9 leftovers tighten |
| 12.x | 8.3 | Metadata annotations removed outright; attributes are the only route |
Install PHPUnit 11 on a PHP 8.1 container and composer refuses with phpunit/phpunit ... requires php >= 8.2 ... your php version (8.1.x) does not satisfy that requirement. That error is a feature: it stops the classic failure where a config file written for one major is fed to a runner from another, which surfaces as Test results may not be as expected because the XML configuration file did not pass validation.
Running vendor/bin/phpunit guarantees the binary matches the lock file. A global install — a phar on PATH, brew install phpunit, a distro package — can be any major version at all, is not recorded in the repository, and silently disagrees with the config schema. Worse, it drifts: the person who installed it two years ago left the company, and nobody can say which version produced yesterday's logs.
Hide the path behind composer scripts so humans and CI share one entry point:
{
"scripts": {
"test": "phpunit",
"test:unit": "phpunit --testsuite unit",
"test:coverage": "phpunit --coverage-text"
}
}
composer test resolves vendor/bin on its own, so CI YAML, editors, and new hires all run the same command with the same flags. When the team bumps PHPUnit, one composer require --dev phpunit/phpunit:^12 plus a green run proves the whole suite survives — the same discipline you applied when you pinned client-visible behavior in Versioning API Endpoints, now applied to the tool itself.
With the runner pinned, the remaining question is what PHPUnit should do when you invoke it: which directories are tests, which file boots the autoloader, and which source counts toward coverage.
Reading phpunit.xml: Suites, Bootstrap, and What Counts as Your Code
vendor/bin/phpunit with no arguments needs a map. That map is phpunit.xml (or, better, phpunit.xml.dist, the committed default that every clone inherits; a local phpunit.xml overrides it and belongs in .gitignore).
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
colors="true"
failOnRisky="true"
failOnWarning="true"
executionOrder="depends,defects">
<testsuites>
<testsuite name="unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory suffix="Test.php">tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory>src/Migrations</directory>
</exclude>
</source>
</phpunit>
Four settings do most of the work.
bootstrap runs once before the first test. It normally loads vendor/autoload.php, because PHPUnit has no idea how your src/ namespace maps to files — without it, the very first new Cart() in a test produces Class "App\Cart" not found, which looks like a code bug and is not.
testsuites define what gets run. The name is the handle you pass on the command line: --testsuite unit for a fast pre-commit loop, the full run for CI. suffix="Test.php" keeps fixtures, fakes, and helper classes out of discovery even when they sit in the same directory.
source defines what gets measured. This split is the single most misunderstood part of the config: suites and coverage are independent decisions. Including tests/ in <source> inflates every percentage (test code is 100% covered by itself), and forgetting src/ leaves you with a report full of vendor/ noise. Coverage counts only what you declare here — or what you pass once with --coverage-filter src.
Strict flags turn sloppiness into failures. failOnRisky="true" and failOnWarning="true" make a test with zero assertions, stray output, or a PHP deprecation exit non-zero, so CI cannot go green on a suite that quietly stopped asserting. executionOrder="depends,defects" runs already-failing and dependency-ordered tests first, which shortens the feedback loop when something breaks.
Two more entries earn their place in most projects. Environment values for the code under test belong in the config, not in the developer's shell:
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_DATABASE" value="app_test"/>
</php>
Tests that rely on whatever $_ENV happens to hold pass on the machine that exported it and fail everywhere else — declaring the test environment inside phpunit.xml.dist moves those variables into version control. beStrictAboutOutputDuringTests="true" flags stray echo and print from tested code, which in PHP is how you catch a header() called after output has already started.
The four messages you will meet first, and what each one is really telling you:
| Symptom in the output | What it actually means |
|---|---|
Class "App\Cart" not found |
bootstrap did not load your autoloader, or the class sits outside every mapped namespace |
A test file you just wrote is missing from --list-tests |
suffix mismatch (Test.php versus Tests.php) or it lives outside every <testsuite> directory |
Testsuite unit not found |
a typo in --testsuite, or the suite exists only in a local phpunit.xml you never committed |
the XML configuration file did not pass validation |
the config was written for a different PHPUnit major than the one executing it |
Because phpunit.xml.dist is the contract between your laptop and CI, never run without it: with no config there is no bootstrap, no named suites, and --testsuite has nothing to select.
Anything in XML can be overridden per run, and the overrides are what you type daily:
vendor/bin/phpunit --testsuite unit # narrow suite
vendor/bin/phpunit --filter CartTotal # match test names
vendor/bin/phpunit --testdox # human-readable checklist
vendor/bin/phpunit --colors=never # clean CI logs
vendor/bin/phpunit --list-tests # what would actually run
--list-tests is the cheapest sanity check you own: when a test "does not run", look at what PHPUnit thinks exists before you debug the test.
Next comes the object that holds the assertions.
One Method, One Behavior: Anatomy of a Test That Fails for a Single Reason
A test case is a class extending PHPUnit\Framework\TestCase, with one public method per behavior you want to pin down:
<?php
use App\Cart;
use App\Item;
use PHPUnit\Framework\TestCase;
final class CartTotalTest extends TestCase
{
public function testTotalAddsTaxToSubtotalInCents(): void
{
$cart = new Cart([new Item(price: 1000)]);
$this->assertSame(1200, $cart->totalWithTax(rate: 0.20));
}
}
Three rules make this readable in six months.
Name the behavior, not the method under test. testTotalAddsTaxToSubtotalInCents says what must remain true. testTotal1 says nothing, and it forces the reader to open the production class to learn the intent. The test prefix is the default convention; #[Test] from PHPUnit\Framework\Attributes lets you write public function totalAddsTax(): void instead. Either works — PHPUnit 10 and later discover attributes, and by PHPUnit 12 the old @test and @dataProvider annotations are gone, so attributes are the durable choice. Return type is always void; a missing return type is a hint you copied a PHPUnit 5-era example.
Each test method gets a fresh instance. PHPUnit constructs a new object for every method, so $this state never leaks from one test into the next. Expensive shared setup belongs in protected function setUp(): void — called before every test — or protected static function setUpBeforeClass(): void for fixtures too heavy to rebuild, which is where a database seed or a parsed 2 MB fixture file belongs.
A test must fail for exactly one reason. A method that checks the total, then the tax label, then the JSON shape has three reasons to go red; when it does, you get one stack trace and three suspects. Split them. Then run --testdox:
Cart total
✔ Total adds tax to subtotal in cents
✔ Rejects an empty cart
✔ Rounds half cents down
That output is a spec review: product people can read it, and a reviewer can see the gap (✘ Applies discount before tax is missing) without opening PHP.
Watch the summary line too — OK (12 tests, 34 assertions) versus OK, but incomplete, skipped, or risky!. A test with no assertions is flagged risky, and with failOnRisky="true" it fails the build. That is deliberate: a test that cannot fail is worse than no test, because it buys confidence it has not earned.
Lifecycle setup deserves a concrete shape, because uninitialized state is the classic self-inflicted wound:
protected function setUp(): void
{
$this->cart = new Cart();
$this->cart->add(new Item(price: 1000));
}
protected function tearDown(): void
{
// close handles, reset statics, delete temp files
}
Build the state every test needs in setUp(), release what the test created in tearDown(). Because a fresh instance is constructed per method, a property you never initialize fails loudly — Typed property CartTotalTest::$cart must not be accessed before initialization — rather than surfacing as a null three frames downstream.
Two attributes you will use within a week of this setup:
#[Group('slow')]
public function testRebuildsSearchIndex(): void
{
if (getenv('ELASTIC_HOST') === null) {
$this->markTestSkipped('set ELASTIC_HOST to run index tests');
}
// ...
}
--group slow runs only those tests and --group ~slow excludes them, which is how a pre-commit hook stays under a second while CI still runs everything. Skipping is honest work: it appears in the summary as OK, but incomplete, skipped, or risky, so nobody mistakes an unrun test for a passing one.
Finally, review test names the way you review commit messages. test1, testWorks, and testFooBarBaz fail for the same reason a commit called stuff fails: when it breaks, the name tells nobody which guarantee broke.
You now have a runner, a config, and a test class that fails loudly for one reason. Writing Assertions is where you decide what "fails" actually means — strict types versus PHP's loose comparisons, exceptions as expected outcomes, and messages that make CI output diagnosable without reproducing locally.
Quiz
1. A developer runs a global `phpunit` (installed via a phar on PATH) against a project configured with a PHPUnit 11 phpunit.xml.dist. What is the most likely failure mode?
2. You run `vendor/bin/phpunit --coverage-text` and see a warning that no code coverage driver is available. Which statement explains the config side of coverage?
3. composer refuses to install phpunit/phpunit:^11 on your PHP 8.1 container. What is the correct fix?
4. Your suite reports `OK (12 tests, 0 assertions)` and CI is green. Which config change makes this state fail the build?
Flashcards
Question
Why install PHPUnit with `composer require --dev` instead of a global phar?
Click to reveal answer
Answer
require-dev pins the exact runner in composer.lock so every clone and CI job runs the same version, and `composer install --no-dev` keeps the test framework out of production images. A global phar can be any major, is not recorded in the repo, and validates phpunit.xml against the wrong schema.
Question
What does the `bootstrap` attribute in phpunit.xml load, and what error appears without it?
Click to reveal answer
Answer
It runs once before the first test and normally requires vendor/autoload.php so your PSR-4 classes resolve. Without it, instantiating any application class fails with `Class "App\Cart" not found`, which looks like a code bug rather than a missing autoloader.
Question
What is the difference between <testsuites> and <source> in phpunit.xml?
Click to reveal answer
Answer
<testsuites> declares which directories are scanned and executed as tests; <source> declares which directories count as your own code for coverage measurement. Suites affect what runs, <source> affects what is measured — they are configured independently.
Question
Which phpunit.xml flags turn warnings and assertion-free tests into build failures?
Click to reveal answer
Answer
failOnRisky="true" (catches tests with no assertions, unexpected output, fragile tests) and failOnWarning="true" (catches PHPUnit warnings such as PHP deprecations). Together they make a degraded suite exit non-zero in CI instead of reporting OK.
Question
What lifecycle hooks run around each test method, and where does expensive shared setup belong?
Click to reveal answer
Answer
PHPUnit creates a NEW TestCase instance per method: setUpBeforeClass() (static) runs once for the class, setUp() before every method, tearDown() after every method, tearDownAfterClass() (static) once at the end. Expensive one-time fixtures such as a seeded database or a parsed fixture file belong in setUpBeforeClass(); per-test state belongs in setUp().
Revision Notes
Key Takeaways
- 1. PHPUnit belongs in require-dev and runs from vendor/bin/phpunit, so composer.lock pins the runner for every clone, CI job, and production image built with --no-dev
- 2. A global phar validates phpunit.xml against its own schema, producing the results may not be as expected warning when its major version differs from the project's
- 3. testsuites decide what runs while <source> decides what coverage measures — including tests/ in <source> inflates every percentage
- 4. bootstrap loads your Composer autoloader; skipping it makes missing classes look like application bugs
- 5. failOnRisky and failOnWarning convert assertion-free tests and PHP deprecations into non-zero exits, which is what keeps a green badge honest
Interview Tips
- • "How do you keep tests consistent across a team?" — one entry point (composer test) backed by a committed phpunit.xml.dist plus a locked dev dependency; mention that CI and laptops execute the identical binary and flags
- • "What is the difference between phpunit.xml and phpunit.xml.dist?" — .dist is committed and inherited by every clone; a local phpunit.xml overrides it and is gitignored for machine-specific tweaks such as paths
- • "A colleague's run shows different failures than yours. Where do you look?" — compare vendor/bin/phpunit --version and the resolved composer.lock, then validate the config; localizing runner versus config versus code shows systematic debugging
- • "How do you make sure tests actually assert something?" — failOnRisky=true plus --testdox review; a test that cannot fail buys confidence it has not earned, and risky-test enforcement is the mechanical guard against it
Cheat Sheet
Configuring PHPUnit
Install and run (never a global binary):
composer require --dev phpunit/phpunit
vendor/bin/phpunit --version
composer test # resolves the composer script, which resolves vendor/bin
Version ↔ PHP floor:
| PHPUnit | Minimum PHP | Breaking note for authors |
|---|---|---|
| 10.x | 8.1 | attributes replace @ annotations; data providers static; withConsecutive() gone |
| 11.x | 8.2 | PHPUnit 9 leftovers start warning |
| 12.x | 8.3 | metadata annotations removed entirely |
phpunit.xml decisions:
| Setting | Controls | Fails loudly when wrong |
|---|---|---|
bootstrap |
autoloader booted before first test | Class "App\X" not found |
<testsuites> |
what runs; --testsuite name selects |
tests silently not discovered |
<source><include> |
what coverage measures | meaningless percentages |
failOnRisky / failOnWarning |
zero-assertion tests, deprecations | green build on a dead suite |
cacheDirectory |
.phpunit.cache (result cache) |
stale ordering across runs |
CLI overrides: --testsuite unit · --filter CartTotal · --testdox · --list-tests · --colors=never
Test case shape:
final class CartTotalTest extends TestCase
{
protected function setUp(): void { /* per-test state */ }
public function testTotalAddsTaxToSubtotalInCents(): void
{
$cart = new Cart([new Item(price: 1000)]);
$this->assertSame(1200, $cart->totalWithTax(rate: 0.20));
}
}
New instance per method; void return; name the behavior, not the method under test; one reason to fail.