With WP_UnitTestCase available from the last lesson, this is where the payoff
starts. An ability with no AI dependency, the kind Lesson 1 put in the top three rows
of its table, is fully, exactly testable: registration matches expectations,
permission_callback behaves correctly for every real user role, and
execute_callback returns the right shape for the right input. This lesson builds
that suite for a concrete example, an ability that lists support tickets by status,
and pushes it further than a happy-path test each, into the edge cases that actually
catch regressions.
The PHPUnit setup from Lesson 2, a WP_UnitTestCase-based test running. At least one
registered ability with no AI dependency, permission_callback checking a capability,
execute_callback running a query.
Step 1: The ability under test
// File: my-plugin/class-list-tickets-ability.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'my-plugin/list-tickets',
array(
'label' => __( 'List tickets', 'my-plugin' ),
'description' => __( 'Lists support tickets, optionally filtered by status.', 'my-plugin' ),
'category' => 'content',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'status' => array( 'type' => 'string', 'enum' => array( 'open', 'closed' ) ),
),
),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'execute_callback' => function ( $input ) {
$posts = get_posts( array(
'post_type' => 'ticket',
'post_status' => 'open' === ( $input['status'] ?? '' ) ? 'publish' : 'draft',
) );
return array_map( fn( $p ) => array( 'title' => $p->post_title ), $posts );
},
'meta' => array( 'mcp' => array( 'public' => false ) ),
)
);
} );
Step 2: Test registration against exact expectations, not just existence
// File: tests/Unit/ListTicketsRegistrationTest.php
<?php
class ListTicketsRegistrationTest extends WP_UnitTestCase {
public function test_ability_is_registered(): void {
$this->assertTrue( wp_has_ability( 'my-plugin/list-tickets' ) );
}
public function test_input_schema_defines_status_enum(): void {
$ability = wp_get_ability( 'my-plugin/list-tickets' );
$schema = $ability->get_input_schema();
$this->assertSame( array( 'open', 'closed' ), $schema['properties']['status']['enum'] );
}
public function test_ability_is_not_publicly_mcp_exposed(): void {
$ability = wp_get_ability( 'my-plugin/list-tickets' );
$meta = $ability->get_meta();
$this->assertFalse( $meta['mcp']['public'] ?? false );
}
}
That last test is a regression test in the strictest sense: nothing about it checks current behavior working correctly, it checks that a future pull request didn’t silently flip a security-relevant flag. It’s cheap to write and it’s exactly the kind of change a code review can miss.
Step 3: Test permission_callback across real user roles
A permission check that only gets tested as an admin is barely tested at all, since admins pass almost any capability check by default. The real coverage is in the roles that should fail:
// File: tests/Unit/ListTicketsPermissionTest.php
<?php
class ListTicketsPermissionTest extends WP_UnitTestCase {
public function test_editor_can_call_ability(): void {
$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
wp_set_current_user( $editor );
$ability = wp_get_ability( 'my-plugin/list-tickets' );
$this->assertTrue( $ability->has_permission( array() ) );
}
public function test_subscriber_cannot_call_ability(): void {
$subscriber = self::factory()->user->create( array( 'role' => 'subscriber' ) );
wp_set_current_user( $subscriber );
$ability = wp_get_ability( 'my-plugin/list-tickets' );
$this->assertFalse( $ability->has_permission( array() ) );
}
public function test_logged_out_user_cannot_call_ability(): void {
wp_set_current_user( 0 );
$ability = wp_get_ability( 'my-plugin/list-tickets' );
$this->assertFalse( $ability->has_permission( array() ) );
}
}
Step 4: Test execute_callback with a data provider, not three copy-pasted methods
// File: tests/Unit/ListTicketsExecuteTest.php
<?php
class ListTicketsExecuteTest extends WP_UnitTestCase {
public static function status_cases(): array {
return array(
'open filter returns only published tickets' => array( 'publish', 'open', 1 ),
'closed filter returns only draft tickets' => array( 'draft', 'closed', 1 ),
'open filter excludes draft tickets' => array( 'draft', 'open', 0 ),
);
}
/**
* @dataProvider status_cases
*/
public function test_status_filter_returns_expected_count( string $post_status, string $filter, int $expected_count ): void {
self::factory()->post->create( array( 'post_type' => 'ticket', 'post_status' => $post_status ) );
$ability = wp_get_ability( 'my-plugin/list-tickets' );
$result = $ability->execute( array( 'status' => $filter ) );
$this->assertCount( $expected_count, $result );
}
}
A data provider like this earns its keep the moment a bug fix needs a fourth or fifth case added, one new array entry instead of one new method with the same body as the other three.
WP_Ability::has_permission() and WP_Ability::execute() are the right seam for a
unit test, they exercise your own code with none of the MCP Adapter’s transport layer
in the way. Spinning up an actual STDIO or HTTP MCP connection inside a unit test is
slow, flaky, and tests the adapter’s transport code, not your ability’s logic. Save
anything that needs a real MCP client for the contract tests in Lesson 6, a smaller,
separate, slower suite.
Test it: break something on purpose and watch the right test fail
Comment out the current_user_can( 'edit_posts' ) check in permission_callback,
rerun the suite, and confirm test_subscriber_cannot_call_ability fails, specifically
that one, not a cascade of unrelated failures:
# File: terminal
composer test -- --filter ListTicketsPermissionTest
A single, precisely-located red test is exactly the signal this suite exists to produce. If breaking one thing makes several unrelated tests fail at once, that’s usually a sign the tests are coupled to shared state instead of testing one concern each.
Recap
An ability with no AI dependency is fully, exactly testable with ordinary PHPUnit:
registration against specific expectations (not just existence), permission_callback
across real user roles (not just the admin happy path), and execute_callback with a
data provider covering several inputs cleanly. Call WP_Ability::has_permission() and
WP_Ability::execute() directly rather than through a live MCP transport, that’s the
right seam for a unit test, and the transport-level testing belongs in Lesson 6’s
contract tests instead.
Resources & further reading
- wp_register_ability() function reference, developer.wordpress.org
- Abilities API reference, developer.wordpress.org
- MCP Adapter repository, testing guide, GitHub