Testing Craft CMS Plugins with Pest

SkillDatabases & data

Testing Craft CMS 5 plugins and modules with Pest — test isolation, database safety, and the markhuot/craft-pest-core harness. ALWAYS load when writing, running, fixing, or reviewing tests for a Craft plugin or module, and whenever a suite touches a real Craft install. Covers why rollback is opt-in, tests/Pest.php + tests/bootstrap.php wiring, phpunit.xml.dist <env> pins (force DB name + table prefix, default connection coordinates, pin CRAFT_ENVIRONMENT against server-scoped locks), why --configuration= defeats DB isolation, throwing fail-closed DB guards, installing the plugin under test, process-timezone pinning, per-test site fixtures, idempotent Install migrations, stale service caches when components get swapped, muting audit sinks, queue stubs, factories, HTTP/DB assertions, CI test jobs. Triggers on: Pest, pestphp, craft-pest-core, markhuot, RefreshesDatabase, InstallsCraft, tests/Pest.php, phpunit.xml.dist, vendor/bin/pest, composer test, ddev craft pest, db_test, CRAFT_DB_DATABASE, CRAFT_ENVIRONMENT, BusyResourceException, GET_LOCK, Entry::factory(), assertDatabaseHas, transaction rollback, test site fixture, createIndexIfMissing, UserPermissions::reset(), TestCaseAlreadyInUse, cookieValidationKey with --filter, 'too many keys', 'tests pollute the database', 'passes on dev but fails in isolation', 'passes alone but fails in the suite', 'two suites deadlock', 'datetimes off by hours in tests', flaky order-dependent test, no Pest job in CI. Do NOT trigger for front-end/JS testing or PHP style analysis.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Testing Craft CMS Plugins with Pest skill

What this skill tells your AI

The instructions your AI receives, as published by michtio/craftcms-claude-skills in skills/craft-pest/SKILL.md and read by ahel’s review.

Reference for testing Craft CMS 5 plugins and modules with Pest, primarily via markhuot/craft-pest-core.

The dominant failure mode in Craft plugin testing is not a wrong assertion — it's a suite that writes to a database it shouldn't, or that passes only because of ambient state on the developer's install. Both are silent. Both look like a green suite. This skill leads with isolation for that reason: get the harness right first, then write tests.

Verified against markhuot/craft-pest-core 3.2.2 and craftcms/cms 5.10.12 (August 2026). Where a claim names a class or method, it was read in that package's source. craft-pest's own README and docs are not authoritative on these points — several of the behaviors below are unstated there.

Companion Skills — Load When Needed

  • craftcms — Plugin/module architecture, elements, controllers, events, project config. Load when the code under test is being written or changed, not just exercised.
  • craft-php-guidelines — PHP standards for the test files themselves (PHPDocs, naming, ECS).
  • ddev — Every command runs through DDEV. Load for the correct invocation of a plugin's own suite inside a host project (ddev exec --dir …).

Documentation

Use WebFetch for specific pages, but prefer reading vendor/markhuot/craft-pest-core/src/ when the question is "what does it actually do."

The Two Non-Negotiables

Everything else in this skill is technique. These two are the ones that cause data loss.

1. Rollback is opt-in — TestCase alone commits everything

markhuot\craftpest\test\TestCase boots Craft and mixes in ~15 traits (ActingAs, RequestBuilders, DatabaseAssertions, Queues, …). RefreshesDatabase is not one of them. Only that trait opens a transaction (setUpRefreshesDatabase()beginTransaction()) and rolls it back on teardown.

So a tests/Pest.php that binds only TestCase produces a suite where every factory call, every saveElement(), every service write commits permanently to whatever database Craft booted against. The tests pass. The database fills up.

// tests/Pest.php — bind BOTH
uses(
    \markhuot\craftpest\test\TestCase::class,
    \markhuot\craftpest\test\RefreshesDatabase::class,
)->in(__DIR__);

If a suite genuinely needs committed data (rare — usually a sign the test should be restructured), scope the exception to that one file rather than dropping the trait globally.

2. The env override is CWD-bound — never run a plugin suite from a shared project root

InstallsCraft::loadPhpunitXmlEnvironmentVariables() (a Pest HandlesArguments plugin, so it runs before Craft boots) looks for exactly two paths:

getcwd().'/phpunit.xml'
getcwd().'/phpunit.xml.dist'

It does not parse a --configuration= CLI flag. There is no fallback, no search upward, no argument inspection.

The consequence is the dangerous part. This invocation looks like it isolates the plugin's suite:

# UNSAFE for craft-pest-core suites
ddev craft pest -- --configuration=vendor/acme/my-plugin/phpunit.xml.dist

PHPUnit reads that config for test discovery, so tests are found and run — but getcwd() is the project root, so the plugin's <env> DB pins are never loaded. Craft boots against the live development database and, if RefreshesDatabase is also missing, writes to it permanently. That combination is how a suite silently creates thousands of orphaned elements in a shared install.

Rule: run a plugin's suite from the plugin's own root.

# From the plugin directory
vendor/bin/pest
composer test

# From a host project, targeting the plugin's own root
ddev exec --dir /var/www/html/vendor/acme/my-plugin vendor/bin/pest

Treat the shared-root --configuration= invocation as unsafe for any craft-pest-core suite, including in CI. See the ddev skill for the container-side invocation.

Isolation Checklist

Run this against any plugin suite you inherit, write, or review. Each line has failed in practice.

CheckWhereFailure if missing
RefreshesDatabase bound alongside TestCasetests/Pest.phpEvery write commits permanently
CRAFT_DB_DATABASE pinned before Craft bootstests/bootstrap.phpSuite runs against the dev database
Fail-closed DB guard that throws (never exit(1))tests/bootstrap.phpexit(1) hands the shell 0 under Pest — guard fails open on CI
CRAFT_ENVIRONMENT pinned uniquely per pluginphpunit.xml.distSuites share server-scoped GET_LOCK names — BusyResourceException/deadlocks even across separate databases
date_default_timezone_set('UTC') after app creationtests/bootstrap.phpDatetimes shift by the install's UTC offset
Same pins present as <env> entriesphpunit.xml.distCorrect-invocation path has no pins
DB name + table prefix forced; coordinates default="true"phpunit.xml.distA forced local hostname breaks CI runners
Install::safeUp() guarded with createIndexIfMissing() / Db::findForeignKey()src/migrations/Install.phpDuplicate keys accumulate to MySQL's 64-per-table cap
Sites created per-test and deleted in afterEach()tests, tests/Pest.phpDurable sites mutate the shared test database
Suite invoked from the plugin's own rootcomposer test, CI, DDEV<env> pins silently ignored
Plugin under test explicitly installedtests/bootstrap.phpWorks only on an install that already has it
Edition pinned explicitlybeforeEach()Passes on Pro, fails on Solo/Team
Count assertions scoped to test-created rowseach testPasses on a seeded install, fails when clean
Audit/event sinks muted on every surfaceshared helperTests write real audit rows
Queue replaced with a stubshared helperTests drain or grow a real backlog
A Pest job actually runs in CI.github/workflows/The suite decays unnoticed

Reference Files

Read the reference file(s) your task needs — each costs input tokens on every turn.

Task examples:

  • "Set up Pest for a new plugin" → isolation.md (bootstrap + phpunit.xml) then patterns.md
  • "Tests are writing to my dev database / created thousands of entries" → isolation.md
  • "Suite passes locally but fails on CI or against a fresh test DB" → isolation.md (Ambient state) + shared-state.md
  • "Write a test for a controller action / element factory / queue job" → patterns.md
  • "Test dynamically-registered permissions" → craft-state.md (Permission-tree memoization)
  • "Test something that requires a logged-in user" → craft-state.md (Simulating a login)
  • "Tests wrote real audit rows / fired real webhooks" → craft-state.md (Muting event surfaces)
  • "Raw SQL fixture isn't treated as expired" → craft-state.md (Fixture timestamps)
  • "Tests pollute a shared playground install" → shared-state.md
  • "Install.php changes aren't reaching the test database" → shared-state.md (Schema drift)
  • "Expiry/date assertions fail intermittently, or datetimes come back hours off" → isolation.md (Pin the process timezone)
  • "Suite connects fine locally but can't reach the database on CI" → isolation.md (Force the database name, default everything else) + ci.md
  • "Install fails with too many keys / duplicate indexes piling up" → shared-state.md (Install migrations must be idempotent)
  • "Set up a multi-site test / my test site's queries ignore siteId" → craft-state.md (Site fixtures) + the craftcms skill's architecture.md
  • "My fixture-cleanup sweep isn't deleting anything / fixtures leak into a shared install" → craft-state.md (Prefix-matching sweeps)
  • "Test passes alone but fails in the suite / service returns stale data" → craft-state.md (Service caches go stale when craft-pest swaps components)
  • "Wire tests into CI" → ci.md
  • "Make the bootstrap refuse to run against the wrong database" → isolation.md (fail-closed guard — throw, never exit(1))
  • "Two suites deadlock when run in parallel / BusyResourceException on a fresh empty database" → isolation.md (pin CRAFT_ENVIRONMENT)
  • "Does this suite even run in CI?" → ci.md (sweep all workflows, not just tests.yaml)
  • "--filter run fails on cookieValidationKey but the full suite passes" → isolation.md (--filter subsets)
  • "Refactor a large untested controller/service" → patterns.md (Pin behaviour before refactoring)
  • "The fix requires changing an existing test / is my new test actually testing anything?" → patterns.md (A test can encode the bug; Prove a fix by breaking it)
  • "TestCaseAlreadyInUse from my uses() rules / per-directory TestCases" → patterns.md (uses() rules)
  • "CP controller test fails on sites, URLs, or asset directories" → craft-state.md (CP-surface controller tests)
ReferenceScope
references/isolation.mdDatabase isolation: tests/bootstrap.php, process timezone, phpunit.xml.dist (force the DB name, default the connection coordinates), RefreshesDatabase, InstallsCraft boot vs plugin install, invocation paths, ambient-state assumptions (editions, counts, pre-existing fixtures)
references/craft-state.mdCraft internals that bite in tests: permission-tree memoization, login/session gates, UTC fixture timestamps, muting audit/event surfaces, per-test site fixtures, component swapping and stale service caches, queue stubs, project-config writes
references/patterns.mdWriting the tests: factories, HTTP, queue, database assertions, multi-site, mocking Craft services, console commands, events, file/test conventions
references/shared-state.mdSuites that run against a shared or long-lived install: restore-what-you-found, Install.php vs migration drift in the test DB, idempotent Install migrations, self-seeding, request-IP fixtures
references/ci.mdCI wiring: check-cs not fix-cs, a real Pest job, invocation from the plugin root, fresh-database verification

Two Harnesses (and when Pest isn't the answer)

AspectCodeception (Craft's native)Pest + craft-pest-core
Base classcraft\test\TestCasemarkhuot\craftpest\test\TestCase
Element creationFixture classes + data filesEntry::factory()->create()
HTTPFunctionalTester ($I)$this->get('/path')->assertOk()
RollbackFixture teardownRefreshesDatabase trait (opt-in)

Use Pest for new plugin work. Use Codeception when contributing to Craft core or extending an existing Codeception suite.

For pure unit tests that don't need Craft booted, skip craft-pest entirely and extend PHPUnit\Framework\TestCase — booting Craft is the expensive, stateful part, and a test that doesn't need a database shouldn't risk one. See patterns.md (Pure unit tests) for the \Craft / \Yii autoload caveat.

What a Green Suite Does Not Prove

Worth holding in mind, because each of these has shipped a real bug past a passing test run:

  • Service-layer tests never see the HTTP layer. Reserved query params, CSRF, route resolution, and response formats only fail on a real request. See the craftcms skill's controllers.md.
  • A green run on a seeded dev install is not authoritative. Confirm against a freshly created test database before believing it.
  • A console-driven harness is not a browser. Sessions, user-agent gates, and impersonation behave differently. See craft-state.md.
  • One long-lived process is not a sequence of requests. Craft invalidates many caches by ending the request. A suite that creates sites, swaps components, or mutates project config mid-process carries stale memos that no production code path would ever see. See craft-state.md.
  • A suite that isn't in CI doesn't exist. It rots at the speed of the codebase. See ci.md.

Signals

GitHub stars
78
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
craft-pest
Source
github.com/michtio/craftcms-claude-skills