Testing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
A WordPress plugin is tested at three levels: plain PHP and JavaScript units with WordPress replaced by test doubles,
PHP code running inside a real WordPress with a real database, and the finished site driven through a browser. This
page is for plugin and block developers. It builds all three levels for the lumen-catalog plugin of the Lumen
Press site, around the Lumen\Catalog\Meta class of
Metadata and Custom Fields, the PUT /lumen/v1/books/<id>/stock
route of Roles and Capabilities API
and the lumen/book-card block with the 0.2.0 deprecation of
Variations, Styles, Transforms and Deprecations.
PHPUnit itself (assertions, data providers, test doubles, coverage) is explained in
Testing with PHPUnit and Pest, and browser testing
in general in E2E Testing in the Browser. This page covers what WordPress adds.
The page was written against WordPress 7.1.3 (released 2026-10-06, examples tested; 7.1.4, the security release of
2026-10-28, is the one to run in production), PHP 7.4 minimum and 8.3+ recommended, @wordpress/scripts 37.0.0,
@wordpress/env 12.0.0 and WP-CLI 2.12.0, and checked on 2026-10-28. Composer resolved PHPUnit 9.6.38, Brain Monkey
2.7.0 (with Mockery 1.6.15 and Patchwork 2.2.4) and PHPUnit Polyfills 4.0.0. npm installed Vitest 5.0.5 with Vite
8.3.5, Playwright 1.64.1 and @wordpress/e2e-test-utils-playwright 3.1.0. Every suite below was run: unit tests in
php:7.4-cli and php:8.4-cli, integration and end-to-end tests in wp-env on WordPress 7.1.3 with PHP 8.3 and on
6.9 with PHP 7.4, JavaScript on Node.js 24. WordPress 7.2 (in beta, planned for 2026-12-08) changes nothing here.
The test pyramid of a plugin
| Level | Tools | Needs | Lumen run | Catches |
|---|---|---|---|---|
Unit (PHP) |
PHPUnit 9, Brain Monkey, Mockery, PHPUnit Polyfills |
PHP |
6 tests, 0.1 s |
Logic in sanitizers, validators and formatters; wrong arguments passed to WordPress functions; missing hooks |
Unit (JS) |
|
Node.js |
2 tests, about 5 s (mostly importing the editor packages) |
Block validation failures, broken deprecations, helper logic |
Integration |
WordPress PHPUnit test library, |
Docker (wp-env), or PHP, MySQL/MariaDB and Subversion |
6 tests, 0.3 s plus about 2 s to reinstall the test site |
Registration, capabilities, sanitization through core APIs, REST routes, SQL, interplay with core |
End-to-end |
Playwright, |
Docker (wp-env), Chromium |
1 test, about 6 s |
The editor loads the block, assets resolve, the published page renders |
The shape is a guideline: many fast unit tests for logic, fewer integration tests where code touches WordPress, a handful of browser tests for the paths editors and readers take. Code that is mostly hook wiring gains little from unit tests full of stubs and belongs in the integration layer. Static checks sit below all of them, in Coding Standards and Static Analysis.
Layout and dependencies
The tests live in tests/Unit/, tests/Integration/ and tests/e2e/specs/, block tests next to the block sources,
and .wp-env.test.json describes the test site (port 8889) beside the development site’s .wp-env.json (8888). The
test packages go into require-dev, next to the tools in the composer.json of
Coding Standards and Static Analysis:
{
"require-dev": {
"brain/monkey": "^2.7", "php-stubs/wordpress-tests-stubs": "^7.1",
"phpunit/phpunit": "^9.6", "yoast/phpunit-polyfills": "^4.0"
},
"autoload-dev": { "psr-4": { "Lumen\\Catalog\\Tests\\": "tests/" } },
"config": { "platform": { "php": "7.4.0" } },
"scripts": { "test": "phpunit" }
}
Source: Core Handbook: PHPUnit
PHPUnit stays on 9.6 although 13 is current: on WordPress 7.0 and 7.1 the test library supports only PHPUnit 9, on
every PHP version from 7.4 to 8.5 (7.0 dropped PHP 7.2 and 7.3, and with them PHPUnit 8). One major for both suites
means one vendor/, and annotations (@covers, @dataProvider) instead of the PHPUnit 10+ attributes of
Testing with PHPUnit and Pest.
The polyfills, required by WordPress since 5.9, supply the snake_case fixtures set_up(), tear_down() and their
class-level variants. The stubs package teaches PHPStan the test-library classes, and config.platform.php keeps
every package installable on the 7.4 floor when Composer runs on a newer PHP. A PHPUnit configuration has exactly one
bootstrap, so each suite has its own file: phpunit.xml.dist (tests/Unit, which never loads WordPress) and
phpunit-integration.xml.dist (tests/Integration, which loads it first), both with failOnRisky and failOnWarning.
Unit tests without WordPress
Brain Monkey replaces WordPress functions and hooks with test doubles, so a class that calls sanitize_text_field()
or user_can() can be tested without WordPress. Patchwork defines and redefines the functions, and Mockery records
the calls and checks expectations. Brain Monkey is built for code that runs without WordPress loaded: functions that
WordPress already declared cannot be replaced the same way. That is the other reason the suites stay apart.
<?php
// tests/Unit/TestCase.php: base class of the unit suite.
namespace Lumen\Catalog\Tests\Unit;
use Brain\Monkey;
use Brain\Monkey\Functions;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Yoast\PHPUnitPolyfills\TestCases\TestCase as PolyfillTestCase;
abstract class TestCase extends PolyfillTestCase {
use MockeryPHPUnitIntegration;
protected function set_up(): void {
parent::set_up();
Monkey\setUp();
Functions\stubTranslationFunctions();
Functions\stubEscapeFunctions();
}
protected function tear_down(): void {
Monkey\tearDown();
parent::tear_down();
}
}
The unit bootstrap, tests/Unit/bootstrap.php, only defines ABSPATH (as __DIR__ . '/') and requires
vendor/autoload.php. Without ABSPATH, the defined( 'ABSPATH' ) || exit; guard of every plugin class would end
the run silently on the first autoload. Monkey\tearDown() after every test keeps stubs from leaking into the next
one, and the Mockery trait turns unmet expectations into failures. The two bulk stubs make __(), _e(), _x()
and the esc_*() functions return or print their text (escaping is simplified).
<?php
// tests/Unit/MetaTest.php: no WordPress, no database.
namespace Lumen\Catalog\Tests\Unit;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Lumen\Catalog\Meta;
use Lumen\Catalog\Plugin;
/** @covers \Lumen\Catalog\Meta */
final class MetaTest extends TestCase {
protected function set_up(): void {
parent::set_up();
Functions\when( 'sanitize_text_field' )->alias( 'trim' );
}
/**
* @dataProvider isbn_provider
*/
public function test_sanitize_isbn( string $input, string $expected ): void {
$this->assertSame( $expected, Meta::sanitize_isbn( $input ) );
}
public function isbn_provider(): array {
return array(
'valid, hyphenated' => array( '978-1-23456-789-7', '978-1-23456-789-7' ),
'surrounding spaces' => array( ' 978-1-23456-789-7 ', '978-1-23456-789-7' ),
'not hyphenated' => array( '9781234567897', '' ),
'ISBN-10' => array( '1-23456-789-X', '' ),
);
}
public function test_only_catalogue_editors_may_set_an_isbn(): void {
Functions\expect( 'user_can' )->once()->with( 3, 'edit_others_lp_books' )->andReturn( false );
$this->assertFalse( Meta::can_edit_isbn( true, 'lp_isbn', 12, 3 ) );
}
public function test_boot_hooks_meta_registration_to_init(): void {
Actions\expectAdded( 'rest_api_init' )->once();
Plugin::boot();
$this->assertSame( 10, has_action( 'init', array( Meta::class, 'register' ) ) );
}
}
$ docker run --rm -v "$PWD":/app -w /app php:7.4-cli vendor/bin/phpunit --testdox
PHPUnit 9.6.38 by Sebastian Bergmann and contributors.
Meta (Lumen\Catalog\Tests\Unit\Meta)
✔ Sanitize isbn with data set "valid, hyphenated"
✔ Sanitize isbn with data set "surrounding spaces"
✔ Sanitize isbn with data set "not hyphenated"
✔ Sanitize isbn with data set "ISBN-10"
✔ Only catalogue editors may set an isbn
✔ Boot hooks meta registration to init
Time: 00:00.084, Memory: 12.00 MB
OK (6 tests, 8 assertions)
Source: Brain Monkey
php:8.4-cli gives the same result. Functions\when() creates a stub (any number of calls; justReturn(),
returnArg(), alias()), while Functions\expect() creates a Mockery expectation (once(), with(),
andReturn()) that fails the test when unmet. Aliasing sanitize_text_field() to trim() is faithful only for
plain strings; the real function runs in the integration layer. Brain Monkey defines add_action(), has_action(),
do_action(), apply_filters() and the rest of the hook API itself: hooks are recorded, never run, has_action()
returns the priority, and Actions\expectAdded(), Actions\expectDone() and Filters\expectApplied() set
expectations on them.
A function the test forgot to stub stops PHP with Error: Call to undefined function Lumen\Catalog\user_can().
Internal PHP functions such as time() can be redefined only when listed under redefinable-internals in a
patchwork.json, which slows every call; injecting a clock is usually simpler.
Integration tests with the WordPress test library
The WordPress PHPUnit test library is the tests/phpunit/includes and tests/phpunit/data folders of
wordpress-develop, on which core’s own suite runs. Its bootstrap drops every table in the configured database,
installs a fresh site, loads WordPress, and wraps each test in a transaction that is rolled back afterwards.
| Test library feature | What it gives a plugin |
|---|---|
|
Base class: a transaction per test, |
|
|
|
Fixtures created once per class, outside the per-test transaction; all data is deleted after the class. |
|
Adds |
|
An unexpected |
|
|
No HTTP mock |
Requests are not blocked; tests that would reach the distributor use |
Option 1: wp scaffold plugin-tests
Run from a WordPress installation, WP-CLI writes phpunit.xml.dist, .phpcs.xml.dist, bin/install-wp-tests.sh,
tests/bootstrap.php, tests/test-sample.php and, with --ci=github, .github/workflows/testing.yml into the
plugin. The install script downloads WordPress to $TMPDIR/wordpress, exports the matching test library to
$TMPDIR/wordpress-tests-lib with Subversion and creates the database, so it needs svn, curl or wget and a
MySQL client. The generated bootstrap loads the plugin on muplugins_loaded with tests_add_filter(). In
wordpress:cli-2.12.0-php8.4 (plus subversion and bash) against MariaDB 11.8, the sample test passed:
$ wp scaffold plugin-tests lumen-catalog --ci=github
$ bash bin/install-wp-tests.sh wordpress_tests root "$DB_PASSWORD" 127.0.0.1 7.1.3
$ vendor/bin/phpunit
OK (1 test, 1 assertion)
The scaffold is a starting point: its phpunit.xml.dist excludes test-sample.php and expects a test- file
prefix, and the workflow generated by WP-CLI 2.12.0 is broken as written. Its php-version: line is a bare $
(the template engine consumed ${{ matrix.php-version }}), and it tests PHP 8.2, 8.0 and 7.4 against
mysql:latest. The handbook page on the subject still assumes a LAMP machine and Travis CI.
Option 2: a dedicated wp-env environment
Every wp-env WordPress and CLI container has PHPUnit, Composer and the test library of its WordPress version at
WP_TESTS_DIR=/wordpress-phpunit, with a wp-tests-config.php derived from the site’s wp-config.php. So the tests
reinstall the site’s own database: after one run, the 8889 site was called Test Blog, had only admin and the
theme default. The suite gets its own environment, with separate containers and database, from a second file:
{
"$schema": "https://schemas.wp.org/trunk/wp-env.json",
"core": "WordPress/WordPress#7.1.3",
"phpVersion": "8.3",
"mariadbVersion": "11.8",
"plugins": [ "." ],
"port": 8889,
"testsEnvironment": false,
"config": { "WP_DEBUG": true }
}
.wp-env.json is the same without port and config (the development site, see
Local Development Environments). testsEnvironment is
deprecated in wp-env 12, but the explicit false is required: although the package README lists false as its
default, wp-env 12.0.0 still creates the tests-wordpress and tests-cli containers (the ones older tutorials run
PHPUnit in, on port 8889) unless the option is false, and prints a deprecation warning when it is missing. The package.json scripts:
{
"scripts": {
"build": "wp-scripts build --source-path=src/blocks --output-path=build/blocks --experimental-modules --blocks-manifest",
"test:env:start": "wp-env start --config=.wp-env.test.json",
"test:integration": "wp-env run cli --config=.wp-env.test.json --env-cwd=wp-content/plugins/lumen-catalog vendor/bin/phpunit -c phpunit-integration.xml.dist",
"test:unit": "wp-scripts test-unit-js",
"test:e2e": "wp-scripts test-e2e --wp-env-config=.wp-env.test.json"
}
}
The devDependencies pin @wordpress/env 12.0.0, @wordpress/scripts 37.0.0, vitest 5.0.5, vite 8.3.5,
jsdom 26.1.0, react/react-dom 18.3.1, @playwright/test 1.64.1 and @wordpress/e2e-test-utils-playwright
3.1.0. --env-cwd assumes the repository folder is called lumen-catalog, because wp-env names the mount after it,
and PHPUnit uses the host’s vendor/, so composer install runs on the host first. The integration bootstrap:
<?php
// tests/Integration/bootstrap.php: loads the WordPress test library, then the plugin.
$lumen_tests_dir = getenv( 'WP_TESTS_DIR' ) ?: '/wordpress-phpunit';
// Composer first: it brings the PHPUnit Polyfills the WordPress bootstrap requires.
require_once dirname( __DIR__, 2 ) . '/vendor/autoload.php';
require_once $lumen_tests_dir . '/includes/functions.php';
tests_add_filter(
'muplugins_loaded',
static function () {
require dirname( __DIR__, 2 ) . '/lumen-catalog.php';
}
);
// Installs a fresh test site (all tables dropped and recreated), then loads WordPress.
require $lumen_tests_dir . '/includes/bootstrap.php';
// Activation hooks never fire here: create the table, capabilities and role once per run.
\Lumen\Catalog\Installer::activate();
Source: Core Handbook: Writing PHP Tests
Loading the plugin file does not activate it. Installer::activate()
(Plugin Fundamentals, with the steps of
Roles and Capabilities API) creates the {$wpdb->prefix}lp_stock
stock-history table,
grants the lp_book capabilities and adds lp_catalogue_manager. Run outside any test transaction, its changes last
for the whole run; inside a test, the library would turn the CREATE TABLE into a temporary table.
Testing meta, sanitization and capabilities
<?php
// tests/Integration/BookMetaTest.php: real WordPress, real database.
namespace Lumen\Catalog\Tests\Integration;
use Lumen\Catalog\Meta;
use WP_REST_Request;
use WP_Test_REST_TestCase;
/** @covers \Lumen\Catalog\Meta */
final class BookMetaTest extends WP_Test_REST_TestCase {
private static int $author_id;
public static function wpSetUpBeforeClass( \WP_UnitTest_Factory $factory ): void {
self::$author_id = $factory->user->create( array( 'role' => 'author', 'user_login' => 'tom.author' ) );
}
/** The base class unregisters every meta key after each test. */
public function set_up(): void {
parent::set_up();
Meta::register();
}
public function test_isbn_is_trimmed_or_rejected_on_save(): void {
$book = self::factory()->post->create( array( 'post_type' => 'lp_book', 'post_title' => 'The Lighthouse Ledger' ) );
update_post_meta( $book, 'lp_isbn', ' 978-1-23456-789-7 ' );
$this->assertSame( '978-1-23456-789-7', get_post_meta( $book, 'lp_isbn', true ) );
update_post_meta( $book, 'lp_isbn', '9781234567897' );
$this->assertSame( '', get_post_meta( $book, 'lp_isbn', true ) );
}
public function test_an_author_cannot_set_the_isbn_of_their_own_draft(): void {
$book = self::factory()->post->create(
array( 'post_type' => 'lp_book', 'post_status' => 'draft', 'post_author' => self::$author_id )
);
wp_set_current_user( self::$author_id );
$request = new WP_REST_Request( 'POST', "/wp/v2/lp_book/{$book}" );
$request->set_body_params( array( 'meta' => array( 'lp_isbn' => '978-1-23456-791-0' ) ) );
$this->assertErrorResponse( 'rest_cannot_update', rest_do_request( $request ), 403 );
$this->assertSame( '', get_post_meta( $book, 'lp_isbn', true ) );
}
}
Source: Core Handbook: Writing PHP Tests
These tests exercise what the unit tests could only fake: the real sanitize_text_field(), the REST schema, the
auth_callback and the map_meta_cap rules of lp_book. Two details were learned by failing first:
-
The factory creates posts with the
publishstatus unless told otherwise. On a published book the Author lacksedit_published_lp_books, so the request failed earlier withrest_cannot_editand proved nothing about the ISBN. -
WP_UnitTestCasecallsunregister_all_meta_keys()intear_down(). Without theset_up()above, every test after the first saw no registered meta:update_post_meta()stored the ISBN unsanitized, and REST responses lacked the plugin’smetaentries. Post types and taxonomies, on the other hand, are reset only for core’s own tests (WP_RUN_CORE_TESTS), solp_bookstays registered.
Testing a REST route
rest_do_request() dispatches a WP_REST_Request without HTTP through the whole pipeline (args schema validation,
permission_callback, callback) and returns the WP_REST_Response a client would receive, errors included.
<?php
// tests/Integration/StockRouteTest.php: PUT /lumen/v1/books/<id>/stock.
namespace Lumen\Catalog\Tests\Integration;
use WP_REST_Request;
use WP_Test_REST_TestCase;
/** @covers \Lumen\Catalog\StockController */
final class StockRouteTest extends WP_Test_REST_TestCase {
private static int $book_id;
private static int $author_id;
private static int $manager_id;
public static function wpSetUpBeforeClass( \WP_UnitTest_Factory $factory ): void {
$editor = $factory->user->create( array( 'role' => 'editor', 'user_login' => 'maria.editor' ) );
self::$author_id = $factory->user->create( array( 'role' => 'author', 'user_login' => 'tom.author' ) );
self::$manager_id = $factory->user->create( array( 'role' => 'lp_catalogue_manager', 'user_login' => 'lena.catalogue' ) );
self::$book_id = $factory->post->create(
array( 'post_type' => 'lp_book', 'post_title' => 'The Quiet Lighthouse', 'post_author' => $editor )
);
}
private function put_stock( int $quantity ) {
$request = new WP_REST_Request( 'PUT', '/lumen/v1/books/' . self::$book_id . '/stock' );
$request->set_header( 'Content-Type', 'application/json' );
$request->set_body( wp_json_encode( array( 'quantity' => $quantity ) ) );
return rest_do_request( $request );
}
public function test_anonymous_callers_get_401(): void {
$this->assertErrorResponse( 'lp_cannot_update_stock', $this->put_stock( 40 ), 401 );
}
public function test_an_author_cannot_change_someone_elses_book(): void {
wp_set_current_user( self::$author_id );
$this->assertErrorResponse( 'lp_cannot_update_stock', $this->put_stock( 40 ), 403 );
}
public function test_a_catalogue_manager_updates_the_stock(): void {
global $wpdb;
wp_set_current_user( self::$manager_id );
$response = $this->put_stock( 40 );
$this->assertSame( 200, $response->get_status() );
$this->assertSame( array( 'book' => self::$book_id, 'quantity' => 40 ), $response->get_data() );
$this->assertSame( 40, (int) get_post_meta( self::$book_id, 'lp_stock', true ) );
$history = $wpdb->get_row( $wpdb->prepare( "SELECT delta, quantity FROM {$wpdb->prefix}lp_stock WHERE book_id = %d", self::$book_id ) );
$this->assertSame( array( '40', '40' ), array( $history->delta, $history->quantity ) );
}
public function test_negative_quantities_are_rejected_by_the_schema(): void {
wp_set_current_user( self::$manager_id );
$this->assertErrorResponse( 'rest_invalid_param', $this->put_stock( -1 ), 400 );
}
}
$ npm run test:integration -- --testdox
Installing...
PHPUnit 9.6.38 by Sebastian Bergmann and contributors.
Book Meta (Lumen\Catalog\Tests\Integration\BookMeta)
✔ Isbn is trimmed or rejected on save
✔ An author cannot set the isbn of their own draft
Stock Route (Lumen\Catalog\Tests\Integration\StockRoute)
✔ Anonymous callers get 401
✔ An author cannot change someone elses book
✔ A catalogue manager updates the stock
✔ Negative quantities are rejected by the schema
Time: 00:00.262, Memory: 46.50 MB
OK (6 tests, 23 assertions)
The first npm run test:env:start builds images (about a minute); later starts take seconds. Arguments after --
reach PHPUnit (npm run test:integration -- --filter StockRouteTest). tear_down() calls wp_set_current_user( 0 ),
so each test starts anonymous (401 rather than 403). The manager test checks both stores the route writes: the
current level in the lp_stock post meta and one history row (delta, quantity) in the table.
Block JavaScript tests with wp-scripts test-unit-js
Since @wordpress/scripts 36.0.0, wp-scripts test-unit-js runs the project’s own Vitest 5 instead of a bundled Jest
(setup, the test-unit-jest fallback and a helper test are in
Block Development Environment). The most valuable
JavaScript test of a static block is a fixture test: it parses stored markup with @wordpress/blocks as the editor
does, so a save() change without a matching deprecation fails in CI instead of in an editor’s browser. Block sources
are .js files with JSX, which Vite transforms only in .jsx files by default, and the editor packages need a DOM:
import { defineConfig } from 'vitest/config';
export default defineConfig( {
oxc: {
include: /\/src\/.*\.js$/,
exclude: /node_modules/,
lang: 'jsx',
jsx: { runtime: 'automatic' },
},
test: {
include: [ 'src/**/*.test.js' ],
environment: 'jsdom',
globals: false,
restoreMocks: true,
server: { deps: { inline: [ /@wordpress\// ] } },
},
} );
Source: Vite: Shared Options, oxc
Vite 8’s default oxc exclude is every .js file, so an include alone left save.js untransformed ("If you are
using JSX, make sure to name the file with the .jsx or .tsx extension"); without inlining, Node rejected
@wordpress/blocks ("needs an import attribute of type: json"). The test registers the real save and deprecated:
// src/blocks/book-card/fixtures.test.js: parses stored markup the way the editor does.
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { parse, registerBlockType, serialize } from '@wordpress/blocks';
import metadata from './block.json';
import save from './save';
import deprecated from './deprecated';
const v1Hidden = `<!-- wp:lumen/book-card {"isbn":"978-1-23456-790-3","showIsbn":false} -->
<article class="wp-block-lumen-book-card"><h3 class="wp-block-lumen-book-card__title">The Glass Orchard</h3></article>
<!-- /wp:lumen/book-card -->`;
const v2 = `<!-- wp:lumen/book-card {"isbn":"978-1-23456-789-7"} -->
<article class="wp-block-lumen-book-card"><figure class="wp-block-lumen-book-card__cover"><img src="/wp-content/uploads/lighthouse-ledger.jpg" alt=""/></figure><h3 class="wp-block-lumen-book-card__title">The Lighthouse Ledger</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-789-7</p></article>
<!-- /wp:lumen/book-card -->`;
describe( 'lumen/book-card stored markup', () => {
beforeAll( () => {
registerBlockType( metadata, { edit: () => null, save, deprecated } );
} );
it( 'accepts 0.2.0 markup unchanged', () => {
const [ block ] = parse( v2 );
expect( block.isValid ).toBe( true );
expect( block.attributes.title.toString() ).toBe( 'The Lighthouse Ledger' );
expect( serialize( [ block ] ) ).toBe( v2 );
} );
it( 'migrates a 0.1.0 card with a hidden ISBN to the Compact style', () => {
const info = vi.spyOn( console, 'info' ).mockImplementation( () => {} );
const [ block ] = parse( v1Hidden );
expect( block.isValid ).toBe( true );
expect( block.attributes.className ).toBe( 'is-style-compact' );
expect( block.attributes ).not.toHaveProperty( 'showIsbn' );
expect( serialize( [ block ] ) ).toContain(
'<p class="wp-block-lumen-book-card__isbn">978-1-23456-790-3</p>'
);
expect( info ).toHaveBeenCalled();
} );
} );
$ npm run test:unit
> wp-scripts test-unit-js
RUN v5.0.5 lumen-catalog
Test Files 1 passed (1)
Tests 2 passed (2)
Duration 5.37s (import 62%, transform 31%, environment 6%)
The parser logs applied deprecations with console.info (and invalid blocks with console.error); the spy silences
and asserts it, since Vitest does not fail on console output. Changing the <h3> of the stored 0.2.0 markup to
<h2> makes isValid false, which is how an unplanned save() change shows up. @wordpress/block-editor 19.0.0
and @wordpress/blocks 17.0.0 are real dependencies of the test; in the built plugin they stay externals.
End-to-end tests with Playwright
wp-scripts test-e2e (alias test-playwright) runs Playwright with the configuration bundled in @wordpress/scripts:
Chromium, one worker, a 960×700 viewport, en-US, reduced motion, failure traces and screenshots under artifacts/,
and one REST login as admin/password (WP_USERNAME, WP_PASSWORD). @wordpress/e2e-test-utils-playwright adds
the fixtures admin (admin screens, new posts), editor (blocks, publishing, canvas), pageUtils (shortcuts,
clipboard) and requestUtils (REST setup of plugins, themes, posts, users and settings). The project overrides the
test folder and the server to start (the default command, npm run wp-env start, would start the development site):
// playwright.config.ts: the @wordpress/scripts defaults, pointed at the 8889 test site.
import { defineConfig, type PlaywrightTestConfig } from '@playwright/test';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- CommonJS without type declarations.
const baseConfig: PlaywrightTestConfig = require( '@wordpress/scripts/config/playwright.config.js' );
export default defineConfig( {
...baseConfig,
testDir: './tests/e2e/specs',
webServer: {
...baseConfig.webServer,
command: 'npm run test:env:start',
port: 8889,
},
} );
// tests/e2e/specs/book-card.spec.ts: from the inserter to the published page.
import { test, expect } from '@wordpress/e2e-test-utils-playwright';
test.describe( 'Book Card block', () => {
test.beforeAll( async ( { requestUtils } ) => {
// The PHPUnit suite reinstalls this site: no active theme, no active plugin.
await requestUtils.activateTheme( 'twentytwentyfive' );
await requestUtils.activatePlugin( 'lumen-catalog' );
await requestUtils.deleteAllPosts();
} );
test( 'is inserted, published and rendered', async ( { admin, editor, page } ) => {
await admin.createNewPost( { title: 'New this autumn' } );
await editor.insertBlock( {
name: 'lumen/book-card',
attributes: { isbn: '978-1-23456-789-7' },
} );
const card = editor.canvas.getByRole( 'document', { name: 'Block: Book Card' } );
await card.getByRole( 'textbox', { name: 'Book title' } ).fill( 'The Lighthouse Ledger' );
await expect( card.locator( '.wp-block-lumen-book-card__isbn' ) ).toHaveText( '978-1-23456-789-7' );
const postId = await editor.publishPost();
await page.goto( `/?p=${ postId }` );
const published = page.locator( '.wp-block-lumen-book-card' );
await expect( published.getByRole( 'heading', { level: 3 } ) ).toHaveText( 'The Lighthouse Ledger' );
await expect( published.locator( '.wp-block-lumen-book-card__isbn' ) ).toHaveText( '978-1-23456-789-7' );
} );
} );
$ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm run test:e2e
> wp-scripts test-e2e --wp-env-config=.wp-env.test.json
Running 1 test using 1 worker
✓ 1 [chromium] › tests/e2e/specs/book-card.spec.ts:14:6 › Book Card block › is inserted, published and rendered (2.6s)
1 passed (6.1s)
Source: Playwright: Installation
-
The tests run against
WP_BASE_URLif set; otherwise--wp-env-config(new in 37.0.0) reads the port from that file, its.wp-env.test.override.jsonand theWP_ENV_*variables. -
The blocks must be built (
npm run build) first. WithoutPLAYWRIGHT_SKIP_BROWSER_DOWNLOAD, each run installs every browser;npx playwright install chromiumonce (with--with-depson CI) is enough. -
The post editor is always iframed since 7.1: blocks are in
editor.canvas, the toolbar and sidebars inpage. -
The first run failed with "Your site doesn’t include support for the "lumen/book-card" block", although the block was registered on the server. The PHPUnit run before it had reinstalled the shared 8889 site with the missing theme
default.get_block_asset_url()compares asset paths with therealpath()of the theme directory, which isfalsefor a missing theme, so every block asset URL becamehttp://localhost:8889/wp-content/themes/default/var/www/html/wp-content/plugins/lumen-catalog/build/blocks/book-card/index.js. Specs that set up their own state withrequestUtils(theme, plugin, content) do not depend on what ran before.
Page objects, flaky-test hygiene and axe-core checks apply unchanged: Test Design and Page Objects, Flaky Tests and Reliability, Accessibility Checks in E2E Tests.
Running the suites in CI across PHP and WordPress versions
The plugin header promises Requires at least: 6.9 and Requires PHP: 7.4, so CI tests that floor as well as the
current release. wp-env reads WP_ENV_CORE and WP_ENV_PHP_VERSION and lets them override the configuration file,
which turns the integration job into a matrix without extra files:
# .github/workflows/tests.yml (integration job; the unit and e2e jobs follow the same pattern)
jobs:
integration:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- { wp: '6.9', php: '7.4' } # the floor: Requires at least / Requires PHP
- { wp: '7.1.3', php: '8.3' }
- { wp: '7.1.3', php: '8.4' }
env:
WP_ENV_CORE: WordPress/WordPress#${{ matrix.wp }}
WP_ENV_PHP_VERSION: ${{ matrix.php }}
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: composer install --no-progress
- run: npm ci
- run: npm run test:env:start
- run: npm run test:integration
The full workflow passes actionlint. Its unit job runs composer test and npm run test:unit; its e2e job
builds the blocks, installs Chromium, runs npm run test:e2e and uploads artifacts/ on failure. The floor cell was
reproduced locally, and the end-to-end test passed there too:
$ export WP_ENV_CORE=WordPress/WordPress#6.9 WP_ENV_PHP_VERSION=7.4
$ npm run test:env:start
$ npx wp-env run cli --config=.wp-env.test.json bash -c 'php -r "echo PHP_VERSION;"; echo; wp core version'
7.4.33
6.9
$ npm run test:integration
OK (6 tests, 23 assertions)
An explicit include list tests the combinations that matter instead of a full cross product, and fail-fast: false
reports all of them. PHP on the runner only installs Composer packages; the tests run in the containers. The release
pipeline around these jobs is in Deployment and CI/CD.
Previews with WordPress Playground
WordPress Playground runs WordPress on PHP compiled to WebAssembly, with SQLite, in a browser or in Node.js. Its CLI,
which replaced the deprecated wp-now, starts a logged-in site with the plugin and no Docker:
$ npx @wp-playground/cli@3.1.61 server --auto-mount --wp=7.1.3 --php=8.3 --port=9400
PHP 8.3 WordPress 7.1.3
Mount <project>/lumen-catalog → /wordpress/wp-content/plugins/lumen-catalog (auto-mount)
Ready! WordPress is running on http://127.0.0.1:9400 (6 workers)
--auto-mount mounted and activated the plugin: the REST index listed lumen/v1 and /wp/v2/lp_book. On pull
requests, the WordPress/action-wp-playground-pr-preview action (v4.0.0) adds a "Preview in WordPress Playground"
button. Playground is for previews and smoke tests, not a replacement for the suites: on SQLite, SQL written for
MySQL can behave differently. Blueprints and the preview setup are in
Playground and Blueprints.
Where older books and tutorials are outdated
None of the three books in this section’s bibliography covers automated tests of WordPress code: WordPress All-in-One For Dummies has no development chapters, the Ultimate WordPress Handbook stops at snippets and manual troubleshooting, and Pro WordPress load-tests a running site (see JMeter) but writes no unit, integration or browser tests. Older tutorials age quickly:
| Outdated advice | Current state |
|---|---|
Install the newest PHPUnit for integration tests |
The WordPress test library runs on PHPUnit 9 only (7.1); the PHPUnit Polyfills are mandatory since 5.9. |
|
|
|
|
|
Since |
|
|
Travis CI and the scaffolded CI file used as is |
GitHub Actions or another CI; review the generated workflow, which WP-CLI 2.12.0 emits with a broken |
The complete list for the section is in Where Older Books and Tutorials Are Outdated.
Common pitfalls
| Symptom | Cause and fix |
|---|---|
An integration run wiped the development site |
The suite ran against the site’s own database; run it only in a dedicated |
|
A WordPress function the code calls is not stubbed; add |
|
The class extends |
Meta tests pass alone and fail in a full run |
|
A test fails because a table, role or capability is missing |
Activation hooks do not run in the test library; call the installer from the bootstrap. |
Vitest reports invalid JS syntax in a block’s |
JSX in |
An E2E test sees "Your site doesn’t include support for" a plugin block |
The block script did not load. After a PHPUnit run the test site has no valid theme, so asset URLs are wrong; activate a theme in |