Skip to content

fix: unregister app error handlers after testing boot - #652

Merged
markshust merged 1 commit into
developfrom
feature/testing-unregister-error-handlers
Nov 1, 2026
Merged

markshust merged 1 commit into
developfrom
feature/testing-unregister-error-handlers

Conversation

@markshust

Copy link
Copy Markdown
Collaborator

Summary

TestClient::boot() and TestDatabase::boot() boot the application. The errors module's boot callback then pushes a global error handler and exception handler, and nothing removed them. As a result PHPUnit reported every test that booted the app as risky ("did not remove its own error handlers"), which also triggers the Pest TIA false-green from pestphp/pest#2012. The handlers also piled up within a process.

Design

The fix adds a public Application::shutdown() in core. marko/testing now owns the teardown for the boots it performs.

  • core: module.php gets a shutdown callback that mirrors boot. Application::shutdown() runs every module's shutdown callback in reverse module load order, so handlers come off PHP's stacks in the reverse order they went on. It then removes the bootstrap error handler if a failed boot left it installed. Calling it again does nothing. Long-running workers can call it too.
  • errors-simple / errors-advanced: ErrorHandlerInterface is now also listed under singletons, so the shutdown callback gets the same instance boot registered. The new shutdown callback calls $handler->unregister(), which already pops exactly what register() pushed.
  • testing:
    • BootedApplications tracks every app booted by TestClient::boot() and TestDatabase::boot(). The TestDatabase app is tracked right after boot, before its environment and driver checks, so a refused boot is cleaned up too.
    • shutdownAll() shuts the apps down in reverse boot order. If one throws, it still shuts down the rest, then rethrows the first failure.
    • Teardown runs automatically:
      • PHPUnit: Marko\Testing\TestCase::tearDown().
      • Pest: a new Pest plugin, ShutdownApplicationsPlugin, declared in extra.pest.plugins next to ExpectationsPlugin. Pest has no global afterEach (pest()->afterEach() only applies to in() paths), so the plugin registers a PHPUnit AfterTestMethodFinished subscriber. That event fires after tearDown and before PHPUnit's handler-stack check, so plain it() tests with nothing in Pest.php are covered.
    • Apps passed to TestClient::forApplication() are not tracked: the caller booted them, so the caller shuts them down.

Why not track apps only inside marko/testing and call ErrorHandlerInterface::unregister() directly? marko/testing would need to know about the errors package and about BootstrapErrorHandler. A core lifecycle hook keeps that knowledge in the modules that install the handlers.

Tests

  • Core: shutdown callbacks run in reverse order with injected parameters, run only once, do nothing for an uninitialized app, and remove a leftover bootstrap handler. Cached manifests keep the shutdown closure.
  • errors-simple and errors-advanced: boot changes the handler stacks, and shutdown restores them exactly.
  • marko/testing:
    • A new errors-app fixture uses the real errors-simple module.php.
    • Tests capture the error and exception handler stacks before boot and after teardown and assert they are identical. Cases: TestClient::boot(), repeated boots, several clients in one test, a refused TestDatabase::boot(), and TestCase::tearDown().
  • End to end: the pest-project fixture now has a plain it() spec that calls TestClient::boot(), and failOnRisky is on. The subprocess run used to report "1 risky". It now passes cleanly.

Notes

  • Existing projects pick up the new Pest plugin when Composer regenerates vendor/pest-plugins.json, which happens on composer install, update or dump-autoload.
  • Docs updated: testing (new "Shutdown after each test" section, BootedApplications API), core (shutdown key and the new "Shutting down" section), errors-simple and errors-advanced.

Closes #647

🤖 Generated with Claude Code

https://claude.ai/code/session_017KaHtYgss6ELkNd1fkcNyX

TestClient::boot() and TestDatabase::boot() boot the application, and the
errors module's boot callback pushes a global error handler and exception
handler that nothing removed. Every test that booted the app was reported
risky, and the handlers piled up within a process.

- core: module.php gains a `shutdown` callback, and Application::shutdown()
  runs every module's shutdown callback in reverse load order (then removes
  a leftover bootstrap error handler). Idempotent; usable by long-running
  workers too.
- errors-simple / errors-advanced: bind ErrorHandlerInterface as a
  singleton and unregister the handler from the shutdown callback.
- testing: BootedApplications tracks every app booted by TestClient::boot()
  and TestDatabase::boot(); TestCase::tearDown() and a new Pest plugin
  (ShutdownApplicationsPlugin, a PHPUnit AfterTestMethodFinished
  subscriber) shut them down after each test, plain it() tests included.

Closes #647

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KaHtYgss6ELkNd1fkcNyX
@github-actions github-actions Bot added the bug Something isn't working label Nov 1, 2026
@markshust
markshust merged commit c58fe32 into develop Nov 1, 2026
6 checks passed
@markshust
markshust deleted the feature/testing-unregister-error-handlers branch November 1, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[testing] TestClient/TestDatabase::boot() leave the app's error and exception handlers installed, so every HTTP test is risky

1 participant