Unit Testing

Unit Testing permission_callback and execute_callback in Isolation

⏱ 16 min

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.

What you'll learn in this lesson
Testing registration against exact expectations
Not just "it exists," but schema shape and the meta.mcp.public gate specifically.
Testing permission_callback across real user factories
Editor, subscriber, logged-out, and why testing only the happy path misses the point.
Testing execute_callback with data providers
Covering several inputs without writing near-duplicate test methods.
Calling the ability object directly, not through a transport
Why WP_Ability::execute() is the right seam, and what to avoid testing through instead.
Prerequisites

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.

What each of these three test files actually rules out
1
Registration test
Rules out a schema typo or an accidentally-flipped meta.mcp.public flag ever reaching production unnoticed.
2
Permission test
Rules out a permission_callback that quietly starts returning true for everyone, the single highest-severity regression this suite catches.
3
Execute test
Rules out execute_callback silently returning the wrong shape or the wrong records after a query refactor.
Call the ability object directly, not through a live MCP transport

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

← Setting Up PHPUnit for Ability Testing Designing Testable Abilities: Injecting the AI Client Dependency →