Skip to content
sean fisher

wordpress / laravel / ai

You can't test code that calls exit()

Sep 3rd 2026 ยท 7 min

PHP's exit construct stops the process before your assertions can run. Mantle Testkit's terminate_request() preserves the same terminal behavior while turning the response into something your tests can inspect.

wordpress Testing Mantle

I once wrote a test that looked something like this:

1public function test_it_sends_an_empty_response(): void {
2 send_empty_response();
3
4 $this->assertSame( 204, http_response_code() );
5}

The code under test was just as simple:

1function send_empty_response(): never {
2 http_response_code( 204 );
3
4 exit;
5}

There is a small problem with this test.

The assertion never runs.

PHP reaches exit, stops the process, and takes the test runner with it. Since exit without a status code exits successfully, whatever launched the test may even report a successful command.

Change the assertion to this:

1$this->assertSame( 500, http_response_code() );

You can get the same result. That is not a passing test. It is a test that disappeared before it had a chance to fail.

Or maybe you had to test a legacy callback that checked for a unit-test constant and skipped exit():

1function send_empty_response(): void {
2 http_response_code( 204 );
3
4 if ( defined( 'UNIT_TESTING' ) && UNIT_TESTING ) {
5 return;
6 }
7
8 exit;
9}

A test for that code might appear to work:

1public function test_it_sends_an_empty_response(): void {
2 send_empty_response();
3
4 $this->assertSame( 204, http_response_code() );
5}

But the test is really checking that the unit-test constant prevented exit(), not that the production behavior terminates the request. The test-only branch has changed what the application does, so it is no longer testing the same code path users run.

You cannot mock exit

exit and die are language constructs. They are not functions you can swap out with a mock.

That leaves you with a few options, none of them especially good.

You can register a shutdown function, but by the time it runs, PHPUnit is shutting down too. You have somewhere to put code, but you no longer have a useful test boundary.

You can run the test in a separate process. That keeps the main test runner alive, but process isolation is slow, adds another layer to debugging, and makes an ordinary request test much more complicated than it should be.

You can also decide the code is not worth testing.

That last option is surprisingly common. Older PHP applications often have request handlers that set a header, echo a response, and call exit. The code predates the test suite, everyone knows it is awkward, and eventually the awkwardness starts to feel permanent.

It does not have to be.

Introducing terminate_request() from Mantle Testkit

When you install Mantle Testkit, you get the Mantle\Support\Helpers\terminate_request() helper and the testing package that makes it work.

Using it is intentionally uneventful:

1use function Mantle\Support\Helpers\terminate_request;
2
3function send_empty_response(): never {
4 terminate_request(
5 exit_status: 0,
6 response_code: 204,
7 );
8}

In production, terminate_request() does three things:

  1. It passes a non-null response code to WordPress's status_header() function.
  2. It sends any supplied headers, as long as PHP has not sent headers already.
  3. It calls exit with the supplied exit status.

The defaults are an exit status of 0, an HTTP response status of 200, and no additional headers. You can pass null as the response code when you do not want the helper to set one.

During a Mantle test, the helper takes a different path. Mantle's test bootstrap defines MANTLE_IS_TESTING, which is what is_unit_testing() checks. When that check passes, terminate_request() throws a Mantle\Testing\Exceptions\Exit_Simulation_Exception before it sends headers or calls exit.

If the testing exception class is unavailable, the helper throws a RuntimeException telling you to install mantle-framework/testing. That guard matters because terminate_request() itself lives in Mantle's support package, while its test behavior depends on the testing package. Testkit installs both.

Yes, this still has a test-environment branch. The difference is what the branch does.

The legacy example returned from the function. That lets the caller continue during a test even though production stops the whole request. Mantle throws. Both paths are terminal for the application code, but the exception stops only the simulated request instead of killing PHPUnit.

What the exception carries

Exit_Simulation_Exception extends Mantle's Response_Exception. That puts it in the same response-control flow used for redirects and other responses that need to interrupt WordPress during a test request.

The exception records the exit status separately. It also passes an effective HTTP status and the supplied headers to Response_Exception.

If you give terminate_request() a response code, that becomes the exception's HTTP status. If the response code is null, the exception reads PHP's current http_response_code() instead. If PHP does not return an integer, Mantle falls back to 200.

Response_Exception also normalizes header names to lowercase. That keeps header handling predictable when the test response is assembled.

The exception is not the assertion. It is a small packet of response state that can cross the point where production would have exited.

How Testkit turns it into a response

Mantle's HTTP test runner does more than catch the exception.

Before loading WordPress, Pending_Testable_Request::call() installs filters that record status codes, headers, and redirects. It starts an output buffer, sets up the WordPress query, and loads the normal WordPress template loader.

A Response_Exception can be thrown while Mantle sets up the query or while WordPress loads the template. Mantle catches it in both places. It reads the exception's status, merges its headers with any headers WordPress already produced, and keeps everything written to the output buffer.

Finally, Mantle builds a Test_Response from three pieces:

  • The captured output becomes the response body.
  • The exception or intercepted WordPress status becomes the response status.
  • The intercepted and exception headers become the response headers.

That is why application code like this:

1use function Mantle\Support\Helpers\terminate_request;
2
3add_action( 'template_redirect', function (): void {
4 echo 'This is the response!';
5
6 terminate_request();
7} );

Can be tested like a normal HTTP response:

1$this->get( '/' )
2 ->assertOk()
3 ->assertContent( 'This is the response!' );

Nothing after terminate_request() runs. PHPUnit stays alive, the output survives, and a failing assertion really fails.

JSON uses the same path

Mantle's send_json_response() helper uses the same mechanism.

It builds an application/json content type using the site's blog_charset option. Outside a test, it sends that header immediately if headers have not already been sent. It then echoes the result of wp_json_encode() and calls terminate_request() with the requested status and the content-type header.

During a test, the header is not sent through PHP. It travels on the Exit_Simulation_Exception instead, where Mantle's request harness can add it to the Test_Response.

That makes this callback:

1use function Mantle\Support\Helpers\send_json_response;
2
3add_action( 'template_redirect', fn () => send_json_response( [
4 'success' => true,
5 'data' => [
6 'foo' => 'bar',
7 ],
8], 201 ) );

Testable without starting another PHP process:

1$this->get( '/' )
2 ->assertStatus( 201 )
3 ->assertIsJson()
4 ->assertJsonPath( 'success', true )
5 ->assertJsonPath( 'data.foo', 'bar' );

The response body, status, and content type take the same trip they would in production. Testkit changes how the request terminates, then turns the captured pieces into something you can assert against.

Mantle added Exit_Simulation_Exception, terminate_request(), and send_json_response() in version 1.12.0.

Keep the boundary small

Do not scatter unit-test checks throughout the application. Let terminate_request() own the difference between production and testing.

Your request code should prepare its response and call the helper. It does not need to know whether termination means throwing an exception or ending the process.

The important part is that direct calls to exit disappear from the code you want to test.

Redirect-and-die handlers, JSON endpoints, legacy callbacks, and wrappers around wp_die() all become easier to test once they route through that boundary.

exit is final. Your application code does not have to be.

This post also kicks off a new series about Mantle Testkit and the problems it tries to remove from WordPress testing. Next, I'll look at how to test a WordPress application without maintaining a second copy of WordPress just for the test suite.