Share
October 14, 2026

Code without tests doesn’t go to production

I have a rule I don’t negotiate on: code without tests doesn’t go to production. For me, a feature is done when a test says it works. That test has to keep saying so after the next ten changes.

Plenty of teams agree with me in theory and then write tests that nobody trusts. The suite is slow, it fails for odd reasons, and somebody has to read a log to know what happened. I skip a suite like that myself, and then it’s no better than having none. That’s why I care about how tests are written, and I don’t stop at whether they exist. I’d call a test suite that nobody trusts a liability, because we pay for it on every change and get nothing back.

FIRST is a short checklist, and I use it to keep my tests useful. It stands for Fast, Isolated, Repeatable, Self-verifying and Timely. The acronym was popularised through Clean Code and is usually credited to Tim Ottinger and Brett Schuchert. Later, Jeff Langr and Tim Ottinger put it on a flash card in their Agile in a Flash project, so the origin is blurry. Some sources add a U for Understandable, and I’d gladly count it. I like that one too, but I’ll stay with the classic five.

My examples are PHP with PHPUnit, because that’s what I write daily with Symfony. I think the ideas carry over to any language, and I’d be happy to hear if yours disagrees.

Fast and isolated

Slow suites discourage me from running them, and I doubt I’m special. That’s the whole problem. Nobody argues against running tests. Still, I run them less often when they’re slow, and then I learn about a break an hour late.

The usual cause is an external dependency: a database, files, the network. I read one example that puts it in numbers. Two hundred milliseconds per test across two thousand tests is more than six minutes, and I won’t wait that long for feedback on a one line change more than a few times. Painful. Tim Ottinger is quoted saying that even a quarter of a second per test is painfully slow, and I agree with him. I think he’s right. I’d rather have a thousand tests that finish while I’m still looking at the screen.

In daily work I follow a few habits. I keep the unit suite free of the kernel, the container and the database, so it needs no boot time at all. I run it on every save, or at least before every commit. When I’m working on one class, I run only its test with --filter, and I run the full suite before I push. None of this is clever, and I think that’s the point. A fast suite is mostly a matter of keeping slow things out of it. I’d rather make that call once, at the start of a project. Fighting for it later is much harder.

I still write a few slow tests, because some bugs only show up with a real database. I keep them in their own suite and let the pipeline run them. My bigger worry is the habit of booting the whole application in every test “just to be safe”. Nobody notices until the suite takes a coffee break, and by then I wouldn’t know where to start cutting.

Isolation is the same story from another angle. In my view one test must not change the result of another, and nothing outside my control should decide whether a test passes. So I let the code under test talk to a port, an interface that I own, and my test plugs in a double.

PHP
interface ExchangeRates
{
    public function rate(string $from, string $to): float;
}

final class PriceConverter
{
    public function __construct(private ExchangeRates $rates) {}

    public function convert(int $minorUnits, string $from, string $to): int
    {
        return (int) round($minorUnits * $this->rates->rate($from, $to));
    }
}

final class PriceConverterTest extends TestCase
{
    public function testConvertsUsingTheCurrentRate(): void
    {
        $rates = $this->createStub(ExchangeRates::class);
        $rates->method('rate')->willReturn(4.0);

        $converter = new PriceConverter($rates);

        self::assertSame(400, $converter->convert(100, 'EUR', 'PLN'));
    }
}

No HTTP call, no API key, no flaky provider. I also like that the test reads like a sentence, and I care about that more each year. I give the real adapter its own few tests against the real thing, and I keep those in the separate suite. For a repository I do the same. I often prefer an in-memory fake to a mock full of expects() calls. The test then describes behaviour and not the order of method calls.

Mocking has a downside, and I won’t hide it. A stub can drift away from the real service, so the unit test stays green while production breaks. I accept that risk only because the adapter tests exist. So I keep the thin adapter tests, and I wouldn’t drop them.

Repeatable

A repeatable test gives me the same result on any machine, on any run. Time, randomness and environment are the usual enemies. If a test passes at 10:00 and fails at 23:55, it depends on the clock. I treat the clock as an input like any other.

So I inject it. PSR-20 defines a clock interface, and Symfony ships a MockClock that implements it.

PHP
final class TrialPolicy
{
    public function __construct(private ClockInterface $clock) {}

    public function isExpired(DateTimeImmutable $startedAt): bool
    {
        return $this->clock->now() > $startedAt->modify('+14 days');
    }
}

public function testTrialExpiresAfterFourteenDays(): void
{
    $clock = new MockClock('2026-01-14 09:00:00');
    $policy = new TrialPolicy($clock);
    $startedAt = new DateTimeImmutable('2026-01-01 09:00:00');

    self::assertFalse($policy->isExpired($startedAt));

    $clock->sleep(2 * 86400);

    self::assertTrue($policy->isExpired($startedAt));
}

My test moves time forward without waiting. I like that a lot. I’d get the same answer in a year, or in CI.

Hidden order dependence is the other quiet enemy. One test leaves a row, a static property or a cached value behind, and the next one relies on it. It works until somebody renames a file, and then we lose an afternoon. PHPUnit can shuffle the order for you. I turn that on from day one:

Bash
vendor/bin/phpunit --order-by=random
vendor/bin/phpunit --order-by=defects

Random order shakes out the tests that lean on each other, and PHPUnit reports the seed it used. With the defects order I run last time’s failures first, so I can confirm a fix quickly.

Self-verifying

The test decides pass or fail by itself, without a human reading output, opening a page or squinting at a diff to make the call. That’s the rule. If I have to read a log, open a page or compare two dumps by eye, I call that a manual check with extra steps.

For me that means real assertions and strict settings, so I tell PHPUnit to be picky. I’d start from a phpunit.xml like this:

XML
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    bootstrap="vendor/autoload.php"
    colors="true"
    cacheDirectory=".phpunit.cache"
    executionOrder="random"
    failOnRisky="true"
    failOnWarning="true"
    beStrictAboutOutputDuringTests="true"
>
    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

A risky test is one that asserts nothing, or prints output. With failOnRisky it fails the run, so a test that can’t fail doesn’t slip in. I set the same rule for warnings. The two suites let me run only unit on every save and the slower integration before I push. To me, a risky test in a green suite is a quiet lie, and I’d rather see the red run.

I also like --testdox. It prints the test names as sentences, so testTrialExpiresAfterFourteenDays turns into a line of documentation. When that list reads badly, I know my names were bad. I find it a cheap and honest review.

I don’t treat green as proof, though. A test can run the code and still check nothing I care about. Infection is a mutation testing tool for PHP, and I trust it more than a coverage number. It changes your code in small ways, for example turning > into >=, and runs the tests again. If they stay green, the mutant survived, and your tests didn’t notice the change.

Bash
vendor/bin/infection --threads=4 --min-msi=70

It needs a coverage driver such as Xdebug or PCOV, and it’s slow. I wouldn’t run it on every push. I’d probably run it nightly instead. The trial test above would let that >= mutant live, because it never checks the exact moment the trial ends. I’d add a boundary case, and that’s the kind of gap I want Infection to show me.

Timely

This is the letter I see people argue about the most. The idea is to write tests at the right time: before the production code if you do TDD, or at least together with it.

I don’t want to start a religious war here. I’d pick TDD when the rules are clear, like a pricing policy or a state machine. When I’m exploring a design and don’t know its shape yet, I’d sketch first and write the tests right after, in the same sitting. What I don’t accept is “tests later”, and I’d say so in a code review. I think “later” means never, or it means tests that describe what the code happens to do instead of what it should do. I’m open to being proven wrong, but I haven’t seen the “later” approach work out for a team.

I have a practical reason too. Code written without a test in mind is often hard to test. I’d bet most of us have written some. I’d flag hidden new calls, static access and DateTimeImmutable('now') in the middle of a method in any review. Fixing that later costs me more than designing for it would have. I also think a test that is painful to write is a design review I didn’t ask for, and I’d listen to it.

Making it a gate

My daily loop is simple. I write a failing test, I make it pass, and I run the unit suite with random order while I still remember what I changed. Before I push, I run the integration suite too. I want the pipeline to confirm my work, not to find my mistakes. When a test fails in CI and not on my machine, I suspect the test first. It usually isn’t isolated or repeatable.

Rules that depend on good intentions don’t last, because deadlines always win an argument with good intentions. So I let the pipeline enforce this rule: tests run on every push, and a merge request can’t be merged while they’re red.

YAML
name: tests
on:
  push:
  pull_request:

jobs:
  phpunit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none
      - run: composer install --no-interaction --prefer-dist
      - run: vendor/bin/phpunit --testsuite unit
      - run: vendor/bin/phpunit --testsuite integration

I know the job alone doesn’t block anything. In the repository settings, I add a branch protection rule and mark this check as required, so a red run keeps the merge button grey. After that I can’t merge around it either, and I want it that way.

I want my gate right in front of production. That’s where untested code costs the most.

Which letter does your team break most often?