Testing in Simple History
SkillDev toolsGuidance for writing and running tests in Simple History, including the Premium add-on. Covers which framework to use, how to run existing tests, how to create new ones (codegen recording workflow), and how to test Premium (whose PHP tests live in this repo, not the add-ons repo).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Testing in Simple History skill
What this skill tells your AI
The instructions your AI receives, as published by bonny/wordpress-simple-history in .claude/skills/testing/SKILL.md and read by ahel’s review.
Which framework to use
| What you're testing | Framework | Why |
|---|---|---|
| Browser UI, admin pages, visual behaviour | Playwright | Fast, visible, modern — new default for UI tests |
| PHP logic, WordPress integration, database queries | Codeception / WPUnit | Full WordPress environment loaded in PHP |
| HTTP-level WordPress behaviour | Codeception / Functional | No browser needed |
Rule of thumb: If a human would test it by clicking around in a browser, use Playwright. If it's PHP logic, use Codeception.
Running tests
# Playwright (UI tests) — runs on host machine against the dev WordPress
npm run test:playwright # headless, output in playwright-report/
npm run test:playwright:ui # interactive UI mode (recommended for writing/debugging)
# Codeception (PHP tests) — runs inside Docker
npm run test:wpunit # PHP unit + WordPress integration
npm run test:functional # HTTP-level tests
npm run test:acceptance # legacy browser tests (Selenium — prefer Playwright for new ones)
# Full PHP suite (Codeception only — does NOT include Playwright)
npm test
Note: npm test runs only the Codeception suite. To get full coverage, run both npm run test:playwright and npm test separately.
Testing Simple History Premium
Premium is covered by PHP tests — but they live in this (core) repo, not in the add-ons repo. One harness, in core; Premium borrows it.
| Location | What's there |
|---|---|
tests/wpunit/premium/ | 11 test files, ~229 tests. Alerts (evaluator/logger/module), custom rules, destination senders, formatters, extended settings, WP-CLI alerts command, both REST controllers, core-vs-premium behaviour |
tests/functional/premium/ | AlertsCliCest.php |
tests/playwright/ | premium-settings-logging.spec.js, license-reminder.spec.js, plus premium-helpers.js |
tests/_support/Helper/PremiumTestCase.php | Base class — call $this->activate_premium(); it skips (not fails) when Premium isn't installed |
Run them:
docker compose run --rm php-cli vendor/bin/codecept run wpunit premium # ~15s
How Premium gets loaded
Premium is mounted into the test WordPress at tests/plugins/simple-history-premium,
a symlink to the local add-ons checkout, wired up in compose.yaml. The symlink is
not tracked in git — on a fresh machine it's absent and every Premium test silently
skips. A green run therefore does not prove Premium passed; check the skip count.
Create the link with npm run pair -- link in the add-ons repo (it is what CI runs too).
It is deliberately not in wpunit.suite.yml's plugins list — tests activate it
on demand via activate_premium().
Where to put a new Premium test
- PHP logic (loggers, senders, formatters, REST, WP-CLI, settings) → wpunit in
tests/wpunit/premium/. This is the default. ExtendHelper\PremiumTestCase. - Browser-visible behaviour → Playwright in
tests/playwright/. The dev WordPress runs core + Premium together. Use this only when a human would need a browser to check it — wpunit runs in seconds, Playwright doesn't. - Cross-repo changes (a core hook/filter + a Premium consumer): unit-test the mechanism on the core side, add the Premium-side test under
tests/wpunit/premium/, and runphpcs/phpstanin both repos.
What the add-ons repo does and doesn't have
The add-ons repo has no test runner of its own — no Codeception config, no tests/
dir. Its npm run test:wpunit runs codecept run wpunit premium through core's
Docker stack (SH_CORE_PATH, default ../WordPress-Simple-History). Its other local
gates are phpcs and phpstan (npm run addons:lint / addons:phpstan from core).
Don't add a Codeception stack there — add the test to tests/wpunit/premium/ here
instead.
What runs in CI
- Core
.github/workflows/test.yml— wpunit, functional and acceptance, one job each, on every push, via the samedocker compose run --rm php-cli vendor/bin/codecept run <suite>you run locally. Shared setup (composer install,npm run build,scripts/install-test-plugins.sh) is the composite action.github/actions/setup-tests. Premium is absent there, so the ~255 premium tests show up as skipped, not passed. A failed job uploadstests/_outputas an artifact — for acceptance that is the screenshot and page HTML of each failure. - Add-ons
.github/workflows/test.yml— checks out public core (same branch name if it exists, elsemain), runspair.sh link, thencodecept run wpunit premium. This is the only place premium tests run automatically.
The database fixture is generated, not committed. functional and acceptance import
tests/_data/dump.sql before every test. scripts/build-test-fixture.sh builds it from
the WordPress in the container (wp core install, empty site, only Simple History
active, backfill marked done, Simple History tables truncated) and downloads the plugin
and theme zips the acceptance tests upload. CI runs it every time; locally, rerun it
after bumping WORDPRESS_VERSION — that is what used to make the dump drift and send
every admin request to upgrade.php. It resets wp_test_site and empties
data/wp-uploads, and backs up the previous dump first.
A fresh environment also needs the tests_db database for wpunit;
docker/db-init/tests-db.sql creates it when the db container first starts on an empty
data directory.
Playwright setup
- Config:
playwright.config.js - Tests:
tests/playwright/*.spec.js - Auth: login cached in
tests/playwright/.auth/admin.json(gitignored).auth.setup.jslogs in and writes this file only when it is absent — an existing file is reused as-is and is never refreshed, even after its WordPress session expires. So a stale/old cache makes every test silently redirect towp-login.php, which shows up aswaitForSelectortimeouts and fields/tabs that "don't exist". Fix: deletetests/playwright/.auth/admin.jsonand re-run —auth.setupthen logs in fresh. Delete it too if credentials change or the dev site was unreachable. Tip: if a brand-new spec fails on selectors that are definitely on the page, suspect stale auth first. - Target: dev WordPress at
http://wordpress-stable-docker-mariadb.test:8282(override withPLAYWRIGHT_BASE_URLenv var) - Admin credentials:
claude/claude(override withWP_ADMIN_USER/WP_ADMIN_PASSWORD) - HTML report: written to
playwright-report/after each run — open it to debug failures
Running against wp-env instead of the dev WordPress
The dev WordPress is shared and long-lived, so tests that need to change global site state — the timezone, say — are better run against the disposable wp-env install:
npm run wp-env:start # http://localhost:8888, admin/password
npm run test:playwright:wp-env # same suite, pointed at wp-env
npm run test:playwright:wp-env -- tests/playwright/my-feature.spec.js
test:playwright:wp-env sets PLAYWRIGHT_BASE_URL, WP_ADMIN_USER,
WP_ADMIN_PASSWORD and PLAYWRIGHT_STORAGE_STATE. The separate storage-state
path matters: without it the wp-env login would overwrite
tests/playwright/.auth/admin.json and every later run against the dev
WordPress would silently redirect to wp-login.php.
wp-env mounts this repo as the plugin, so run npm run build first — it
serves whatever is in build/, not the source.
CLI shortcuts
# Record a test by clicking through the browser — outputs ready-to-paste code
npx playwright codegen http://wordpress-stable-docker-mariadb.test:8282/wp-admin/
# Run a single spec file (faster iteration than the full suite)
npx playwright test tests/playwright/my-feature.spec.js
See https://playwright.dev/docs/getting-started-cli for the full CLI reference.
Creating a new test (codegen workflow)
When the user asks for help creating a new Playwright test, walk them through this flow:
1. Record by clicking through the browser:
npx playwright codegen --load-storage=tests/playwright/.auth/admin.json http://wordpress-stable-docker-mariadb.test:8282/wp-admin/
--load-storage reuses the cached admin session, so codegen lands straight in wp-admin without making the user log in again. Without it, the recorded test will include the login form fill — noise you'd delete anyway.
In the inspector toolbar, use the Pick locator (cursor icon) and assertion tools (eye / ab / form) to add expect() calls — clicking through alone produces a click log, not a test.
2. Clean up the codegen output. The raw spec looks like this:
import { test, expect } from '@playwright/test';
test.use( { storageState: 'tests/playwright/.auth/admin.json' } ); // remove
test( 'test', async ( { page } ) => {
// rename
await page.goto( 'http://wordpress-stable-docker-mariadb.test:8282/...' ); // make relative
// ...
} );
Apply these conventions to match the rest of the suite:
- Use
require()(CommonJS), notimport— matcheslog-page.spec.js,post-logging.spec.js. - Drop
test.use({ storageState })— the chromium project inplaywright.config.jsalready sets it. - Use relative URLs (
/wp-admin/...) —baseURLis configured. - Give the test a descriptive name — it shows up in reports.
- Group related tests with
test.describe()and share setup inbeforeEach(). - Always wait for
.SimpleHistoryLogitems.is-loadedbefore asserting on log rows — the list renders empty first, then hydrates from the REST API.
3. Save to tests/playwright/<feature-name>.spec.js — testDir picks it up automatically.
4. Run just that file while iterating:
npx playwright test tests/playwright/<feature-name>.spec.js
Or use UI mode for fast edit-and-rerun: npm run test:playwright:ui.
5. Debug failures with npx playwright show-report — frame-by-frame trace replay.
Writing a new Playwright test
Basic test (no test data needed) — import from @playwright/test:
const { test, expect } = require( '@playwright/test' );
test( 'my test', async ( { page } ) => {
await page.goto(
'/wp-admin/admin.php?page=simple_history_admin_menu_page'
);
// Always wait for the log list to finish loading before asserting.
await page.waitForSelector( '.SimpleHistoryLogitems.is-loaded' );
await expect(
page.locator( '.SimpleHistoryLogitem__text' ).first()
).toBeVisible();
} );
Test that needs to create WordPress data — import from ./fixtures to get requestUtils:
const { test, expect } = require( './fixtures' );
test.beforeEach( async ( { requestUtils } ) => {
post = await requestUtils.createPost( {
title: 'Test post',
status: 'publish',
} );
} );
test.afterEach( async ( { requestUtils } ) => {
// Delete by ID — never use deleteAllPosts() against the live dev site (see warning below).
await requestUtils.rest( {
path: `/wp/v2/posts/${ post.id }`,
method: 'DELETE',
params: { force: true },
} );
} );
test( 'logs post creation', async ( { page } ) => {
await page.goto(
'/wp-admin/admin.php?page=simple_history_admin_menu_page'
);
await page.waitForSelector( '.SimpleHistoryLogitems.is-loaded' );
await expect(
page
.locator( '.SimpleHistoryLogitem__text', { hasText: 'Test post' } )
.first()
).toBeVisible();
} );
requestUtils uses the saved admin session (cookie auth) — no application password needed.
Test data and state
- Tests run against the live dev WordPress — your existing log events and content stay untouched
- Write assertions that are true regardless of existing data ("at least one event", not "exactly 5 events")
- When a test needs specific data, create it in
beforeEachviarequestUtilsand clean up inafterEach - Cleanup deletions are also logged by Simple History — that's expected and correct, just accept it
- Two specs must not run in parallel with the rest.
privacy-data.spec.jsandhide-event-type.spec.jsflip the site-wide experimental-features option through the dev-tools endpoint, soplaywright.config.jsruns them as their own chained projects beforetests. Add any new spec that toggles a site-wide option to that chain, not totests. - The dashboard widget shows only five events. A spec that reads a row from the widget must not run its tests in parallel with each other:
event-date-timezone.spec.jssetstest.describe.configure( { mode: 'default' } )because every test there creates and deletes a post, and one test's clean-up pushed another's row out of the widget.
Available requestUtils methods (selection)
requestUtils.createPost( payload ); // returns post object with .id
requestUtils.createPage( payload ); // returns page object with .id
requestUtils.createUser( payload ); // returns user object
requestUtils.rest( { path, method, params, data } ); // arbitrary REST API call
Warning: Do NOT use
deleteAllPosts(),deleteAllPages(), or similar bulk-delete methods. Tests run against the live dev WordPress — bulk deletes will wipe real content. Always delete by ID usingrequestUtils.rest().
Codeception PHP tests
- Config:
codeception.dist.yml,tests/*.suite.yml - Tests:
tests/wpunit/,tests/functional/,tests/acceptance/ - Environment:
.env.testingin the repo root (params for the suite ymls) - All PHP tests run inside Docker via
docker compose run --rm php-cli wpis not on the php-cli image's PATH. Usevendor/bin/wp --allow-root --path=/wordpress/.
When functional or acceptance tests fail, rebuild the fixture first
The functional and acceptance suites load tests/_data/dump.sql into the test
database before every test. That file is gitignored and is generated by
scripts/build-test-fixture.sh from the WordPress in the container. Pär's rule
is that a version does not ship with failing tests, so if a suite that passed
on the last release fails on a different machine, rebuild the fixture before
suspecting the plugin.
Symptoms that meant a stale hand-made dump before the script existed (2026-09-03, 35 of 50 functional tests "failed"), kept here so they are recognised if a dump is ever edited by hand again:
- Every admin-page test fails with "Form field … not found" and the saved
tests/_output/*.fail.htmlpages are titled "WordPress › Update": the dump'sdb_versionis older than the test container's WordPress. - Wrong fixture values: stray events (
WPCliCestexpects event id 1 in the ten-rowlistoutput), a tagline, a theme the container does not ship (SimpleThemeLoggerCestasserts "from Twenty Twenty-Five"). - Tests that land on the dashboard or read the previous event. A
never-visited install does its one-time work on the first admin request of
every test, which widens two races the helpers now absorb:
loginAsAdmin()waits for the dashboard, andseeLogMessage()/seeLogContext()/seeLogInitiator()retry for up to 5s until the newest row matches.
To tell a fixture problem from a real regression, run the failing Cests against
the previous release tag: git checkout <tag>, run them, git checkout -.
The test site serves the working directory, so nothing else changes.
npm test stops at the first failing suite
It chains wpunit && functional && acceptance. A functional failure means the
acceptance suite never ran. Look for the last suite name in the output before
calling a run green.
Acceptance suite: it is green, keep it that way
As of 2026-09-12 all 50 acceptance tests pass, twice in a row, on a fixture
built by scripts/build-test-fixture.sh with the plugins pinned by
scripts/install-test-plugins.sh. Every "known failure" listed here before
had an environmental cause: PluginDuplicatePostLoggerCest needed
Duplicate Post 4.6 (4.5 never fires duplicate_post_after_duplicated),
SimplePluginLoggerCest needed Akismet 5.5 (the local directory was empty),
SimpleMediaLoggerCest and SimpleOptionsLoggerCest hard-coded post ids and
now read them back, and the rest were the fixture races above. One genuine
flake remains: ChromeDriver's "Node with given id does not belong to the
document" during waitForText right after a form submit, roughly once per
fifty runs; CI reruns failed tests once (codecept run acceptance -g failed).
Treat any other failure as real.
tests/plugins/* (Redirection, Akismet, Jetpack, the premium symlink, …) is
gitignored and bind-mounted into the test site, so third-party plugin
versions are machine-local too. A Redirection test that times out on
"Start Setup" with "Problem starting Redirection" in the saved page means the
local copy is broken, not the logger. Replace the directory with the release
zip from wordpress.org and recreate the container
(docker compose up -d --force-recreate wordpress) so the mount follows.
Redirection is 5.10.0 on this machine as of 2026-09-05 (was 5.3.2), and
scripts/install-test-plugins.sh pins the same version for CI. Bump
was forced by issue 308: Redirection 5.10.0 moved its REST callbacks from
legacy classes (Redirection_Api_Redirect) into namespaced ones
(Redirection\Api\Route\Redirect), so Plugin_Redirection_Logger needed to
match both spellings — a fixture pinned at 5.3.2 could never have caught that.
Both PluginRedirectionLoggerCest tests pass against 5.10.0. Its UI also
relabelled the redirect submit button from "Add Redirect" to "Add redirect"
and reused that same text on a page-title toggle button and a form heading,
so testRedirects now targets the submit button by CSS
(.add-new .table-actions button[type=submit]) instead of by text.
Migrating old acceptance tests to Playwright
Don't migrate proactively. When you're already working on a feature that has a Codeception acceptance test (tests/acceptance/*Cest.php), migrate it to Playwright at that point. Leave the rest as-is.
Signals
- GitHub stars
- 317
- Forks
- 74
- Last commit
- Sep 2026
- Hacker News mentions
- 20
Advanced
- Catalog kind
- skill
- Gateway key
testing-bonny- Source
- github.com/bonny/wordpress-simple-history