Enterprise WordPress · 8 min read

Contract Testing the WordPress REST API with Pact and PHPUnit

Record what the front end relies on, replay it against WordPress in CI, and check responses against each route's own schema, which WordPress never does for you.

By Praful Patel

Short answer

Contract testing turns “the WordPress API changed and the front end broke” from a production incident into a failed pull request. The front end records exactly which fields, types and status codes it relies on as a Pact contract. WordPress replays that contract against a real instance in CI. Add a PHPUnit check that responses match each route’s own schema, which WordPress itself never enforces, and most silent breakages get caught before merge.

In a headless build, the REST API between WordPress and the front end is an interface between two codebases that deploy separately. Someone renames a meta key, a plugin update changes a field from boolean to string, or a filter starts returning null. WordPress still returns 200, and the front end fails at render time with Cannot read properties of undefined. End-to-end tests can catch this, but they are slow, flaky, and need both systems running together. Contract tests check the boundary on its own.

Three ways to catch an API break

Approach What it proves Blind spot
Provider schema tests (PHPUnit) Responses match the schema WordPress declares for the route Says nothing about which fields consumers actually use
Consumer-driven contracts (Pact) WordPress still satisfies every interaction each consumer recorded Only covers interactions someone wrote a test for
End-to-end browser tests A user journey works with both systems deployed Slow, environment-sensitive, late in the pipeline

They complement each other. The schema test is cheap and lives entirely in the WordPress repository. The contract test is what tells a WordPress developer that a change will break a specific consumer, before that consumer finds out.

Layer 1: make responses honour the route schema

WordPress validates request arguments against a schema. It does not validate responses. The REST API schema handbook describes the schema as a subset of JSON Schema draft 4. It is exposed through an OPTIONS request to the route, and rest_validate_value_from_schema() is available to check a value against it. Nothing stops a callback from returning something completely different from what it declares.

So declare a schema on every custom route, with additionalProperties set to false where the shape is closed, then make a test enforce it:

class Test_Articles_Endpoint extends WP_UnitTestCase {

	public function test_single_article_matches_route_schema() {
		$post_id = self::factory()->post->create( array( 'post_name' => 'contract-testing-guide' ) );

		$request  = new WP_REST_Request( 'GET', '/acme/v1/articles/contract-testing-guide' );
		$response = rest_get_server()->dispatch( $request );
		$this->assertSame( 200, $response->get_status() );

		// The schema the route itself publishes via OPTIONS.
		$options = rest_get_server()->dispatch( new WP_REST_Request( 'OPTIONS', '/acme/v1/articles/contract-testing-guide' ) );
		$schema  = $options->get_data()['schema'];

		$result = rest_validate_value_from_schema( $response->get_data(), $schema, 'response' );
		$this->assertTrue( $result, is_wp_error( $result ) ? $result->get_error_message() : '' );
		$this->assertSame( $post_id, $response->get_data()['id'] );
	}
}

This runs in the standard WordPress PHPUnit suite with no extra tooling. It catches the common class of bug where the callback and the schema drift apart. It cannot tell you that removing a field the schema marks as optional will break a consumer, which is what the next layer is for.

Layer 2: consumer-driven contracts with Pact

In consumer-driven contract testing, the consumer writes the expectations. The Next.js front end runs a test against a Pact mock server, records each request it makes and the parts of each response it depends on, and writes them to a contract file. The provider, WordPress, then replays the contract against a real instance.

The consumer test below uses Pact JS’s V4 interface (current Pact JS releases also export it as Pact). It exercises the real client function instead of a hand-written fetch, so the contract records what the code actually does.

// test/article.pact.test.ts
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { MatchersV3, PactV4, SpecificationVersion } from '@pact-foundation/pact';
import { getArticle } from '../src/wordpress';

const { boolean, integer, string, timestamp } = MatchersV3;

const pact = new PactV4( {
	consumer: 'acme-nextjs-frontend',
	provider: 'acme-wordpress-api',
	dir: path.resolve( 'pacts' ),
	spec: SpecificationVersion.SPECIFICATION_VERSION_V4,
} );

describe( 'GET /wp-json/acme/v1/articles/:slug', () => {
	it( 'returns the fields the article page renders', async () => {
		await pact
			.addInteraction()
			.given( 'a published article with slug contract-testing-guide' )
			.uponReceiving( 'a request for a published article' )
			.withRequest( 'GET', '/wp-json/acme/v1/articles/contract-testing-guide', ( b ) => {
				b.headers( { Accept: 'application/json' } );
			} )
			.willRespondWith( 200, ( b ) => {
				b.headers( { 'Content-Type': 'application/json; charset=UTF-8' } );
				b.jsonBody( {
					id: integer( 123 ),
					slug: string( 'contract-testing-guide' ),
					title: string( 'Contract Testing Guide' ),
					is_sponsored: boolean( false ),
					modified: timestamp( "yyyy-MM-dd'T'HH:mm:ss", '2026-10-01T09:30:00' ),
				} );
			} )
			.executeTest( async ( mockServer ) => {
				const article = await getArticle( mockServer.url, 'contract-testing-guide' );
				expect( article.isSponsored ).toBe( false );
			} );
	} );
} );

Two habits keep contracts useful:

  • Match on type, not value. integer( 123 ) means “any integer”. Pinning literal values makes the contract fail on content changes that are not contract changes.
  • Record only what you use. If the page does not read excerpt, leave it out. Pact ignores extra fields in responses by default. That is the point: WordPress stays free to add fields, and is only stopped from removing or retyping the ones a consumer depends on.

The original draft of this example called addInteraction() without executeTest(). In that form no request is made and no contract is written, so the test passes while protecting nothing. Check that your pacts/ directory actually contains a file after the run.

Verifying the contract against WordPress

Pact PHP exists, but there is no need to add a second toolchain. The Pact JS verifier runs from Node and can replay the contract against any HTTP server, including a disposable WordPress started with wp-env. Provider states, the given(...) strings, map to WP-CLI commands that put the right data in place:

// test/verify-wordpress.mjs
import { execFileSync } from 'node:child_process';
import { Verifier } from '@pact-foundation/pact';

// wp-env's development instance; "cli" is its WP-CLI container.
const wp = ( ...args ) =>
	execFileSync( 'npx', [ 'wp-env', 'run', 'cli', 'wp', ...args ], { encoding: 'utf8' } ).trim();

await new Verifier( {
	provider: 'acme-wordpress-api',
	providerBaseUrl: 'http://localhost:8888',
	pactUrls: [ process.env.PACT_FILE ], // or pactBrokerUrl + consumerVersionSelectors
	providerVersion: process.env.GITHUB_SHA,
	providerVersionBranch: process.env.GITHUB_REF_NAME,
	publishVerificationResult: process.env.CI === 'true' && Boolean( process.env.PACT_BROKER_URL ),
	stateHandlers: {
		'a published article with slug contract-testing-guide': () => {
			// Idempotent: remove any previous copy, then create it fresh.
			const ids = wp( 'post', 'list', '--name=contract-testing-guide', '--post_type=post', '--format=ids' );
			if ( ids ) {
				wp( 'post', 'delete', ...ids.split( ' ' ), '--force' );
			}
			wp( 'post', 'create', '--post_status=publish', '--post_title=Contract Testing Guide',
				'--post_name=contract-testing-guide', '--porcelain' );
		},
	},
} ).verifyProvider();

If is_sponsored becomes a string, or the key is renamed, verification fails in the WordPress pull request and names the consumer and interaction that broke. That is the feedback the end-to-end suite gives you a day later, if at all.

Authenticated endpoints

Use an Application Password for a test user that exists only in the disposable instance. Inject the Authorization header with the verifier’s requestFilter rather than recording it in the contract. Pact’s documentation advises using request filters only for values that cannot live in the contract, and credentials are the textbook case.

Sharing contracts between repositories

When the front end and WordPress live in one monorepo, pointing the verifier at the generated file is enough. Across repositories you need somewhere to exchange contracts. A Pact Broker (self-hosted, or the hosted PactFlow) stores contracts per consumer version. It records verification results, and its can-i-deploy check answers whether a given version of each side is safe to release together.

Evolving the API without breaking anyone

Contract tests enforce the rules. They do not replace them:

  • Add, then migrate, then remove. Ship the new field, move consumers to it, and remove the old field only when no contract mentions it.
  • Version breaking changes as new routes. acme/v2/articles beside acme/v1/articles, never an in-place change.
  • Shape responses deliberately. A mapping function that returns an explicit array gives you a contract you control. Returning raw WP_Post objects leaks internal fields and couples consumers to them.

The release sequencing behind the first rule, and the mapping layer behind the third, are covered in Headless WordPress with Next.js. If you are still choosing the API layer, WordPress REST API vs WPGraphQL compares the options. The same “fail the pull request, not production” approach applies to block markup, in Gutenberg block validation in CI.

Questions people actually ask

Can contract testing replace end-to-end tests?
No. Contract tests prove each side honours the interface. End-to-end tests prove a user journey works when everything is deployed together. Keep a small number of end-to-end tests for critical journeys, and move the “did the API shape change” checking into contracts, where it is faster and more precise.

Does this work with WPGraphQL?
Partly differently. A GraphQL schema already gives you type checking and schema-diff tooling for removed or retyped fields, which covers much of what contracts catch for REST. Pact can still record GraphQL interactions, which is useful for custom resolvers whose runtime data can drift from what the schema promises.

Is JSON Schema validation enough on its own?
It catches a callback drifting from its own schema, which is a real and common bug. It cannot catch a schema change that is internally consistent but breaks a consumer, such as removing a field. That is the gap consumer-driven contracts close.

Headless releases breaking in production?

I help teams put contract tests, schema checks and safe release sequencing between WordPress and their front ends.

Talk through your API contracts →
← Back to all insights