tiime/tested-routes-checker-bundle
A Symfony bundle that checks which application routes are covered by tests, flagging untested endpoints so teams can catch coverage gaps early and keep route testing visible in CI.
tiime/tested-routes-checker-bundle is a test-tooling bundle: it records which Symfony routes are actually hit while your test suite runs, then lets you assert that every registered route was exercised. Treat it like a coverage report for your routing table.
Install as a dev dependency — this should never ship to production:
composer require --dev tiime/tested-routes-checker-bundle
Register the bundle only where tests run. Since it hooks into the kernel and request lifecycle, scope it to test (and optionally dev) in config/bundles.php:
return [
// ...
Tiime\TestedRoutesCheckerBundle\TestedRoutesCheckerBundle::class => ['test' => true],
];
Look at the bundle's own README/config first. The package is pre-1.0 and small; the authoritative surface area is the bundle class, its DependencyInjection/Configuration.php, and the console command it exposes (usually something under a tested-routes: namespace). Read the Configuration.php before guessing config keys — that file tells you exactly what's tunable (storage path, exclusion patterns, output format).
First use case: run your functional/integration tests once so the checker records the routes it saw, then run the bundle's "check" command. It will diff the recorded set against the router's RouteCollection and report anything untouched:
bin/phpunit
bin/console tested-routes:check
A green run means "every route in config/routes*.yaml / attributes was requested at least once." A red run gives you the list of orphans — exactly the routes that have no test hitting them.
Start with one small controller so you can see the tracking file appear and understand its shape before wiring it into CI.
CI as a gate. The most valuable placement is a dedicated CI step after your test suite:
- run: bin/phpunit
- run: bin/console tested-routes:check
Because it consumes the state written during the tests, it must run after the suite in the same job/workspace — don't split them across runners unless you persist the tracking artifact between them.
Reset before every run. Always start from a clean tracking state so stale route names from a previous suite don't mask a regression. A rm -f var/tested-routes* (or the bundle's own reset flag) at the top of the job, or in a PHPUnit bootstrap/setUpBeforeClass, is the reliable pattern.
Exclude the boring routes explicitly, not implicitly. Routes for health checks (/healthz), webhooks (Stripe, GitHub), the profiler, error pages, and assets served through a controller typically shouldn't count. Put them in the bundle's exclusion config rather than writing pointless smoke tests:
# config/packages/test/tested_routes_checker.yaml
tested_routes_checker:
excluded_routes:
- '_profiler*'
- '_wdt'
- 'health_check'
- 'webhook_*'
Pair it with a "smoke test" controller test. For routes that are hard to cover end-to-end (authenticated admin pages, form submissions), write one parameterized functional test that logs in a fixture user and GETs every listed route, asserting < 500. That both satisfies the checker and catches template/runtime explosions. This is often cheaper than writing a bespoke test per route.
Track per-suite when you have multiple test layers. If you run unit tests (which never boot the kernel) and functional tests separately, only the functional run produces meaningful tracking data. Wire the checker into the functional suite only, or you'll get confusing "partially tested" reports.
Combine with a coverage threshold. The checker answers "was this route reached?" — not "was the controller code inside it covered?" Run it alongside Pest/PHPUnit coverage (--coverage-text --min=80) so a route being hit by a shallow 404-producing test doesn't give false comfort.
Pre-commit / local feedback. For a fast inner loop, add the check to a composer test script:
"scripts": {
"test": [
"@php bin/phpunit",
"@php bin/console tested-routes:check"
]
}
That way developers see orphaned routes before pushing rather than in CI.
State file persistence is the #1 source of confusing results. If the tracking file lives in var/ and isn't cleared, a passing local run can make CI green even after you deleted the test that covered a route. Add the tracking file to .gitignore, clear it in the bootstrap, and treat "file exists from yesterday" as a bug in your setup.
Subrequests and fragments inflate the record. Symfony fires kernel.request for ESI/fragment/render(controller(...)) subrequests, and your listener will see those too. Route names usually dedupe this correctly, but if the checker keys off paths or request URIs you'll get counts that don't match reality. Prefer route names, and dedupe in a Set.
Redirects count as "tested" — sometimes misleadingly. A 302 from /old to /new marks /old as covered, but the target controller may never run if the client doesn't follow. If your test client has followRedirects(false) (the default in Symfony's KernelBrowser), assert the redirect and follow it, or you'll have "covered" routes that execute one line of code.
Route parameters are not the test. /orders/{id} records as orders_show regardless of whether you tested id=1 or id=999. The checker gives you breadth, not depth — pair it with data-provider tests for boundary values.
Routes are matched by name, and names can be autogenerated. Attribute routes without an explicit name: get names like app_controller_method. Refactoring a controller method silently renames the route, so the checker may report the old name as missing and the new one as untested across a single commit. Name your routes explicitly (#[Route('/orders/{id}', name: 'orders_show')]) — it makes the checker's diffs readable and stable.
Console commands are invisible to it. Anything registered as a console command, or reached only via Messenger workers/CRON, is outside kernel.request and will always look "untested." Don't try to force those into the checker; use CommandTester / Messenger assertions for those separately.
404s and unmatched requests may be recorded. If your listener logs every request rather than only those where $request->attributes->get('_route') is set, a deliberate 404 test can mark an unrelated path as covered. Guard with a null check on _route and skip when it's null.
Environment and cache warmth matter. The checker compares against the router as compiled for the current environment. Run the check with the same APP_ENV (usually test) you used for the suite; running it under dev picks up dev-only routes (profiler, _errors/preview, etc.) and produces phantom misses. Run bin/console cache:clear --env=test if you've just added routes and see stale entries.
Debugging the tracker itself. When the report is surprising, don't guess — dump what was collected. The tracker service is a normal Symfony service in the container, so you can fetch it in a test (self::getContainer()->get(...)) and var_dump the recorded set, or tail -f the tracking file between tests. Knowing the file format takes the mystery out of the diff.
Parallel test runs need care (Paratest, --processes). Multiple workers writing to the same JSON/file will corrupt or lose entries. Either disable parallelism for the functional suite when using this bundle, configure a per-worker tracking file and merge them, or run the checker against a serial run in CI.
It is a small, low-traffic package — pin it. With 1 star and 0 dependents, expect occasional upstream churn in config keys or class names between releases. Pin a range in composer.json (^1.0 or the specific version you tested), and read the CHANGELOG on upgrade. Because it's dev-only, the blast radius of a break is small — but a silent config-key change can make the checker pass vacuously rather than fail loudly.
Extension points. If you need custom behaviour (e.g., writing the results to a JUnit XML report, or emitting a Slack summary), the bundle will most likely expose a decorated storage/reporter service rather than a hard-coded command. Override the relevant service in config/services_test.yaml with decorates: — that's the idiomatic Symfony way and avoids forking the bundle.
How can I help you explore Laravel packages today?