I found 2,684 junk records in a live third-party app. Every one of them was created by our own test suite, over roughly a year, through a guard that was written specifically to prevent exactly that.
The guard was correct. It was also useless, for a reason that took an embarrassingly long time to see.
The setup
When a user logs into our app for the first time, we mint a record for them in our help desk. It's a real record in a real third-party system, created over a real HTTP call. Useful in production. Catastrophic in tests, where we create and destroy hundreds of users per suite run.
So we did the obvious thing:
function isTest() {
// Cache only a positive result: TestClass is defined AFTER start.php runs,
// so an early call must not memoize false.
static $test = false;
if ( $test ) return true;
$test = class_exists("TestClass", false);
return $test;
}
TestClass is our test factory. It's declared by tests/bootstrap.php. If the class exists, we are inside PHPUnit. If it doesn't, we're serving real traffic. Then at the call site:
public function ensureUserHelpDeskRecord() {
if ( isTest() ) return;
// ... create the live record
}
This works. I can demonstrate it works. Write a test that creates a user, assert no HTTP call goes out, watch it pass. Green forever.
The hole
We have 24 test files that drive a real browser. Headless Chrome, pointed at the actual dev server, clicking actual buttons. Fourteen of them log in by filling out the real /login form and pressing submit.
Look at where that login code runs.
It does not run in PHPUnit. PHPUnit is the process holding the browser's leash. The login itself is an HTTP request handled by php-fpm, a completely separate process, which was started before the test suite existed and knows nothing about it. tests/bootstrap.php never loaded there. TestClass was never declared. class_exists("TestClass", false) returns false, because in that process it is false.
isTest() answers a question about the current process. The browser test spans two.
Every headless login was minting a live record. Fourteen tests, several suite runs a day, for about a year. 2,684 records.
The distinction
There are two kinds of "am I in a test" guard, and they are not interchangeable.
A process-scoped guard asks about the environment the code is executing in. class_exists, a global flag set at bootstrap, an env var, defined('PHPUNIT_RUNNING'). Cheap, precise, and it stops dead at the process boundary. Anything you spawn, fork, queue, or request over HTTP is outside its knowledge.
A data-scoped guard asks about the thing being operated on. Our test users all have email addresses at a dedicated domain. That domain rides along in the POST body, through nginx, into php-fpm, into the session, into the queue payload, into the daemon that picks the job up tomorrow morning. It survives every boundary the request crosses, because it is the request.
function isTestEmailAddress($emailAddress): bool {
if ( ! is_string($emailAddress) || $emailAddress === "" ) return false;
return stripos($emailAddress, "@test.example.com") !== false;
}
Twelve tokens of logic. It is strictly weaker than isTest() at what isTest() does, and it is the only one of the two that works.
The fix
We kept both, and made the difference explicit at the gate:
// isTest() alone is NOT enough here, which is why this gate takes the email
// address. The 14 headless-Chrome tests drive a real /login form, so the login
// runs in php-fpm, a process where tests/bootstrap.php never loaded and
// isTest() is therefore false.
function wantHelpDeskRecord($emailAddress = ""): bool {
if ( isTest() ) return TestClass::$helpdesk;
return ! isTestEmailAddress($emailAddress);
}
Two behaviours, deliberately different.
Inside PHPUnit, default to off, but let a test opt in. Exactly one test does. It creates a real record, asserts its fields, and deletes it again. That's how the live integration stays covered instead of being permanently mocked into meaninglessness.
Outside PHPUnit, in any process at all, look at the data. Test domain, no record. Real user, record.
The call site now passes the subject in, which is the whole point:
if ( ! wantHelpDeskRecord($this->emailAddress) ) return;
Where else this bites
Go looking for the pattern and it's everywhere. The question to ask of any test guard is not "is this correct" but "how far does this travel."
Grep your codebase for guards that answer from ambient state, then find every path where the guarded code can execute somewhere your bootstrap never ran:
- Browser and end-to-end tests. The obvious one, and the one that got us. The application under test is a different process by definition.
- Queues and job workers. The test enqueues. A daemon dequeues, tomorrow, on another box, with a fresh interpreter. Your flag is long gone. The payload is all that made it.
- Webhooks and callbacks. A test triggers something, a third party calls you back twenty seconds later on a clean request. Nothing about that request knows it descended from a test.
- Cron and scheduled work. Test data written today, cron runs at midnight, and now your test user is getting a real invoice email. exec, shell_exec, and CLI scripts. New process, new everything.
The failure mode has a specific signature, which is why it hides so well: the guard works perfectly in the fast, cheap, unit-level tests you run constantly, and fails only in the slow, expensive, integration-level ones you run less often and watch less closely. It fails exactly where the blast radius is largest. It fails silently, because a successful API call to a third party doesn't look like a bug from inside your assertions.
And it accrues. Nobody notices one junk record. Nobody notices a hundred. You notice at 2,684, because by then it's a wall of them and someone finally opens the app.
The rule
Mark the data, not the runtime.
A flag that describes the process can only protect the process. A marker that lives on the object being acted upon protects every path that object ever reaches, including the ones you haven't written yet and the ones you've forgotten exist. Test email domains, a reserved account id range, a is_synthetic column, a naming prefix on the resource: pick whichever fits, and make the external-effect gate take it as an argument rather than read it from the air.
Then use the process check for what it's actually good for, which is the narrow set of things that genuinely only ever run in-process.
Both guards are in our codebase today, four lines apart, with a comment between them explaining which one is load-bearing and why. That comment is the most valuable thing I wrote that week.
I build InfoLobby, a platform for teams who'd rather assemble their own business systems than buy six SaaS products that don't talk to each other. It's PHP, MySQL, and a lot of opinions about where guards belong.
Top comments (0)