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.
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.
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
- PHP AI Client SDK repository, GitHub
- Introducing the AI Client in WordPress 7.0, make.wordpress.org
- Abilities API reference, developer.wordpress.org