Unit Testing

Designing Testable Abilities: Injecting the AI Client Dependency

⏱ 17 min

Lesson 1 named the problem: there’s no built-in test double for wp_ai_client_prompt(), so “mock the AI call” isn’t an option sitting on a shelf waiting for you. This lesson builds the fix, a small interface between your execute_callback and the AI Client SDK, so your production code calls the real SDK while your tests substitute a different implementation that returns a canned value instantly. This is ordinary dependency injection, nothing AI-specific about the pattern itself, applied to the one seam in an ability that actually needs it.

What you'll learn in this lesson
Extracting the AI call behind a one-method interface
A contract your own code controls, instead of the SDK's fluent builder chain.
Swapping implementations with a WordPress filter
A native, idiomatic way to inject a dependency without changing execute_callback's signature.
Writing a fake that returns a canned response
Testing the ability's own logic exactly, with zero real API calls.
Testing error propagation with a failing fake
Confirming a WP_Error from the AI layer surfaces correctly, without needing a real provider outage.
Prerequisites

Lesson 3’s PHPUnit setup and testing patterns. The summarize-post ability pattern from the PHP AI Client SDK course, an execute_callback that calls wp_ai_client_prompt() directly. This lesson redesigns that same ability for testability.

Step 1: Define the contract, not the implementation

Start with the smallest interface that captures what execute_callback actually needs from the AI call, one method, a plain input, a plain output:

// File: my-plugin/interface-ai-summarizer.php
interface My_Plugin_Ai_Summarizer_Interface {
	/**
	 * @return string|WP_Error
	 */
	public function summarize( string $content );
}

This interface is the seam. Your execute_callback will only ever talk to this contract, never to wp_ai_client_prompt() directly, which is exactly what makes the next two steps possible.

Step 2: A real implementation that calls the actual SDK

// File: my-plugin/class-ai-client-summarizer.php
class My_Plugin_Ai_Client_Summarizer implements My_Plugin_Ai_Summarizer_Interface {
	public function summarize( string $content ) {
		return wp_ai_client_prompt()
			->with_text( $content )
			->using_system_instruction( 'Summarize this WordPress post in three sentences or fewer.' )
			->using_temperature( 0.3 )
			->generate_text();
	}
}

All of the SDK-specific detail, the builder chain, the system instruction, the temperature, lives inside this one class now, not scattered through execute_callback. That’s a second, independent benefit of this pattern beyond testability: the prompt itself becomes a single, easy-to-find place to change.

Step 3: Wire the dependency in with a filter, not a constructor argument

An ability’s execute_callback is called by the Abilities API with a single $input argument, there’s no constructor to pass a dependency into. A WordPress filter gives you an equally real, idiomatic injection point instead:

// File: my-plugin/class-summarize-ability.php
function my_plugin_get_ai_summarizer(): My_Plugin_Ai_Summarizer_Interface {
	return apply_filters( 'my_plugin_ai_summarizer', new My_Plugin_Ai_Client_Summarizer() );
}

function my_plugin_summarize_post_ability( $input ) {
	$post = get_post( $input['post_id'] );

	if ( ! $post || 'publish' !== $post->post_status ) {
		return new WP_Error( 'invalid_post', 'Post not found or not published.' );
	}

	$result = my_plugin_get_ai_summarizer()->summarize( wp_strip_all_tags( $post->post_content ) );

	if ( is_wp_error( $result ) ) {
		return $result;
	}

	return array( 'summary' => $result );
}

Production code never touches the filter, apply_filters() just returns the real summarizer with nothing else hooked in. A test, though, can hook my_plugin_ai_summarizer before calling the ability and get a completely different implementation back, with no change to execute_callback at all.

Step 4: A fake that returns a canned response

// File: tests/Unit/Fixtures/FakeSummarizer.php
class FakeSummarizer implements My_Plugin_Ai_Summarizer_Interface {
	public function summarize( string $content ) {
		return 'Canned summary for testing.';
	}
}
// File: tests/Unit/SummarizeAbilityTest.php
<?php
class SummarizeAbilityTest extends WP_UnitTestCase {

	public function tear_down(): void {
		remove_all_filters( 'my_plugin_ai_summarizer' );
		parent::tear_down();
	}

	public function test_returns_summary_for_published_post(): void {
		add_filter( 'my_plugin_ai_summarizer', fn() => new FakeSummarizer() );

		$post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) );
		$result  = my_plugin_summarize_post_ability( array( 'post_id' => $post_id ) );

		$this->assertSame( array( 'summary' => 'Canned summary for testing.' ), $result );
	}

	public function test_rejects_unpublished_post_without_calling_the_summarizer(): void {
		$post_id = self::factory()->post->create( array( 'post_status' => 'draft' ) );
		$result  = my_plugin_summarize_post_ability( array( 'post_id' => $post_id ) );

		$this->assertWPError( $result );
		$this->assertSame( 'invalid_post', $result->get_error_code() );
	}
}

Notice the second test never even hooks a fake summarizer. If my_plugin_get_ai_summarizer() somehow got called for a draft post, that would be a real bug, an unnecessary AI call for input that should have been rejected first, and this test would happily let it fail loudly if it started happening (since no filter means the real, un-faked implementation would run, and in a test environment with no provider configured, that’s exactly the kind of accidental real call you want a test to expose, not hide).

Step 5: Testing error propagation without a real outage

// File: tests/Unit/Fixtures/FailingSummarizer.php
class FailingSummarizer implements My_Plugin_Ai_Summarizer_Interface {
	public function summarize( string $content ) {
		return new WP_Error( 'provider_unavailable', 'No provider configured.' );
	}
}
// File: tests/Unit/SummarizeAbilityErrorTest.php
<?php
class SummarizeAbilityErrorTest extends WP_UnitTestCase {

	public function tear_down(): void {
		remove_all_filters( 'my_plugin_ai_summarizer' );
		parent::tear_down();
	}

	public function test_provider_error_propagates_as_wp_error(): void {
		add_filter( 'my_plugin_ai_summarizer', fn() => new FailingSummarizer() );

		$post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) );
		$result  = my_plugin_summarize_post_ability( array( 'post_id' => $post_id ) );

		$this->assertWPError( $result );
		$this->assertSame( 'provider_unavailable', $result->get_error_code() );
	}
}

There is no real provider outage anywhere in this test, no network call, no waiting for a timeout. FailingSummarizer deterministically reproduces exactly one condition, the AI layer returning an error, and the test confirms execute_callback handles it correctly, exactly the kind of edge case that’s expensive and flaky to reproduce against a real provider and free to reproduce against a fake.

Don't reach for a general mocking library on the SDK's builder chain instead

It’s still possible to use PHPUnit’s own mock objects, WP_Mock, or Brain\Monkey to stub wp_ai_client_prompt()’s fluent chain directly, but every method in the chain your production code calls, with_text(), using_system_instruction(), using_temperature(), generate_text(), needs its own stub kept in sync, and any future change to that chain breaks tests that have nothing to do with the change being made. Wrapping the whole chain behind one interface means only your own one-method contract has to stay stable, which is a contract you control.

Test it: run the whole file and confirm zero real AI calls happen

# File: terminal
composer test -- --filter SummarizeAbility

All three tests should pass instantly, no delay waiting on a real HTTP response. If a test in this file is slow, something is wrong, either a filter didn’t get hooked before the ability ran, or the real My_Plugin_Ai_Client_Summarizer is being constructed and called somewhere it shouldn’t be.

Recap

There’s no SDK mock for wp_ai_client_prompt(), so the fix lives in how the ability is designed: extract the AI call behind a small interface your own code owns, wire it in through a WordPress filter (an idiomatic injection point that needs no change to execute_callback’s signature), and substitute fakes in tests that return canned values, both successful and failing, deterministically and instantly. Everything execute_callback does around that one call, validating input, handling errors, shaping the result, is now exactly-testable, the way Lesson 3’s non-AI abilities already were.

Resources & further reading

← Unit Testing permission_callback and execute_callback in Isolation Writing Evals to Catch AI-Output Regressions →