DEV Community

Cover image for A test that catches the bug your feature tests can't see
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

A test that catches the bug your feature tests can't see

There's a class of bug that's maddening: it passes every test you have, then crashes in the user's face. I hit one in the admin UI of laravel-config-sso today, and the real fix wasn't changing an icon name — it was writing a test that could see the bug in the first place.

The bug: wrong icon name, crashes only at runtime

The admin UI uses Flux. Flux resolves icons through <flux:delegate-component>, and it throws for a name that doesn't exist:

Flux component [icon.ellipsis] does not exist.
Enter fullscreen mode Exit fullscreen mode

It's an easy mistake. Flux ships Heroicons, not Lucide. So your Lucide reflexes lie to you:

You type (Lucide) Flux wants (Heroicon)
ellipsis ellipsis-horizontal
trash-2 trash
eye-off eye-slash

Why feature tests don't catch it

Here's the interesting part. I had a feature test that hits the admin route and asserts 200. Green. But the real UI crashes. How?

Because in the headless test harness, Flux renders icons as no-ops. No real <flux:delegate-component> boots, so the icon name never gets resolved. The crash only surfaces under a full boot (testbench serve) — exactly where your automated tests don't go.

Analogy: it's like a spell-checker that only runs when you print the document, not while you type. Your tests type away happily. The crash waits at the printer.

The fix: a static test that reads the Blade and validates every icon

Instead of relying on runtime, I wrote a Pest test that reads the Blade view, extracts every icon name (static and inside dynamic expressions), and asserts Flux actually ships a stub for each one:

$fluxIconStubs = base_path('vendor/livewire/flux/stubs/resources/views/flux/icon');

it('only references Flux icons that exist', function () use ($fluxIconStubs) {
    expect(is_dir($fluxIconStubs))->toBeTrue("Flux icon stubs not found");

    $view = file_get_contents(__DIR__.'/../../resources/views/livewire/sso-providers.blade.php');

    // Static `icon="name"` plus quoted tokens inside dynamic
    // `icon="{{ $cond ? 'eye-slash' : 'eye' }}"` expressions
    preg_match_all('/icon="([a-z][a-z0-9-]*)"/', $view, $static);
    preg_match_all('/icon="\{\{(.+?)\}\}"/', $view, $dynamic);

    $names = $static[1];
    foreach ($dynamic[1] as $expression) {
        preg_match_all("/'([a-z][a-z0-9-]*)'/", $expression, $tokens);
        $names = array_merge($names, $tokens[1]);
    }

    $names = array_values(array_unique($names));
    expect($names)->not->toBeEmpty();

    foreach ($names as $name) {
        expect(is_file("{$fluxIconStubs}/{$name}.blade.php"))
            ->toBeTrue("Flux has no icon [{$name}] — use a valid Heroicon name (Flux ships Heroicons, not Lucide).");
    }
});
Enter fullscreen mode Exit fullscreen mode

What I like about this test:

  • It runs against the source of truth. Flux's icon registry is a folder of stubs in vendor/. The test checks directly against that — not a hardcoded list that goes stale.
  • It handles dynamic icons. Toggles like eye / eye-slash are the ones that usually slip through. The second regex catches quoted tokens inside Blade expressions.
  • The failure message teaches. When it breaks, it tells you the real reason: "Flux ships Heroicons, not Lucide." Future-me will be grateful.

That payoff was immediate: the very same Flux free-vs-Pro icon trap bit a sibling package the same day (a webhook/ellipsis/list set that only exists in Flux Pro). A guard test like this turns a "crashes in production" into a "fails in CI" — which is exactly where you want it.

When to reach for this pattern

Not every typo deserves a test. This pattern shines when your runtime lies to you during tests — where a component becomes a no-op, where an adapter is mocked out, where the environment differs from production. There, a static test that reads the artifact (a Blade view, a config file, a migration) can catch what a dynamic test can't.

The rule I keep: if a failure only appears under a full boot but your tests run headless, don't chase the full boot in CI. Catch the thing earlier with a static check over the source files. It's cheaper, faster, and it never renders a no-op.

Top comments (0)