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.
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).");
}
});
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-slashare 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)