Testing Craft CMS Plugins with Pest
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.11 (July 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.
| Check |
Where |
Failure if missing |
RefreshesDatabase bound alongside TestCase |
tests/Pest.php |
Every write commits permanently |
CRAFT_DB_DATABASE pinned before Craft boots |
tests/bootstrap.php |
Suite runs against the dev database |
date_default_timezone_set('UTC') after app creation |
tests/bootstrap.php |
Datetimes shift by the install's UTC offset |
Same pins present as <env> entries |
phpunit.xml.dist |
Correct-invocation path has no pins |
DB name + table prefix forced; coordinates default="true" |
phpunit.xml.dist |
A forced local hostname breaks CI runners |
Install::safeUp() guarded with createIndexIfMissing() / Db::findForeignKey() |
src/migrations/Install.php |
Duplicate keys accumulate to MySQL's 64-per-table cap |
Sites created per-test and deleted in afterEach() |
tests, tests/Pest.php |
Durable sites mutate the shared test database |
| Suite invoked from the plugin's own root |
composer test, CI, DDEV |
<env> pins silently ignored |
| Plugin under test explicitly installed |
tests/bootstrap.php |
Works only on an install that already has it |
| Edition pinned explicitly |
beforeEach() |
Passes on Pro, fails on Solo/Team |
| Count assertions scoped to test-created rows |
each test |
Passes on a seeded install, fails when clean |
| Audit/event sinks muted on every surface |
shared helper |
Tests write real audit rows |
| Queue replaced with a stub |
shared helper |
Tests 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
| Reference |
Scope |
references/isolation.md |
Database 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.md |
Craft 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.md |
Writing the tests: factories, HTTP, queue, database assertions, multi-site, mocking Craft services, console commands, events, file/test conventions |
references/shared-state.md |
Suites 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.md |
CI 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)
| Aspect |
Codeception (Craft's native) |
Pest + craft-pest-core |
| Base class |
craft\test\TestCase |
markhuot\craftpest\test\TestCase |
| Element creation |
Fixture classes + data files |
Entry::factory()->create() |
| HTTP |
FunctionalTester ($I) |
$this->get('/path')->assertOk() |
| Rollback |
Fixture teardown |
RefreshesDatabase 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.
1---2name: craft-pest3description: 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 the DB name and table prefix, default the connection coordinates so CI still connects), why --configuration= defeats DB isolation, installing the plugin under test, pinning the process timezone after app creation, 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_DB_TABLE_PREFIX, Entry::factory(), asser4---5
6# Testing Craft CMS Plugins with Pest
7
8Reference for testing Craft CMS 5 plugins and modules with Pest, primarily via `markhuot/craft-pest-core`.
9
10The 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.
11
12**Verified against `markhuot/craft-pest-core` 3.2.2 and `craftcms/cms` 5.10.11 (July 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.
13
14## Companion Skills — Load When Needed
15
16- **`craftcms`** — Plugin/module architecture, elements, controllers, events, project config. Load when the code under test is being written or changed, not just exercised.
17- **`craft-php-guidelines`** — PHP standards for the test files themselves (PHPDocs, naming, ECS).
18- **`ddev`** — Every command runs through DDEV. Load for the correct invocation of a plugin's own suite inside a host project (`ddev exec --dir …`).
19
20## Documentation
21
22- Craft Pest: https://craft-pest.com
23- Pest PHP: https://pestphp.com/docs/installation
24- Codeception (Craft's native harness): https://craftcms.com/docs/5.x/extend/testing.html
25
26Use `WebFetch` for specific pages, but prefer reading `vendor/markhuot/craft-pest-core/src/` when the question is "what does it actually do."
27
28## The Two Non-Negotiables
29
30Everything else in this skill is technique. These two are the ones that cause data loss.
31
32### 1. Rollback is opt-in — `TestCase` alone commits everything
33
34`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.
35
36So 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.
37
38```php
39// tests/Pest.php — bind BOTH
40uses(
41 \markhuot\craftpest\test\TestCase::class,
42 \markhuot\craftpest\test\RefreshesDatabase::class,
43)->in(__DIR__);
44```
45
46If 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.
47
48### 2. The env override is CWD-bound — never run a plugin suite from a shared project root
49
50`InstallsCraft::loadPhpunitXmlEnvironmentVariables()` (a Pest `HandlesArguments` plugin, so it runs before Craft boots) looks for exactly two paths:
51
52```php
53getcwd().'/phpunit.xml'
54getcwd().'/phpunit.xml.dist'
55```
56
57It does **not** parse a `--configuration=` CLI flag. There is no fallback, no search upward, no argument inspection.
58
59The consequence is the dangerous part. This invocation looks like it isolates the plugin's suite:
60
61```bash
62# UNSAFE for craft-pest-core suites
63ddev craft pest -- --configuration=vendor/acme/my-plugin/phpunit.xml.dist
64```
65
66PHPUnit 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.
67
68**Rule: run a plugin's suite from the plugin's own root.**
69
70```bash
71# From the plugin directory
72vendor/bin/pest
73composer test
74
75# From a host project, targeting the plugin's own root
76ddev exec --dir /var/www/html/vendor/acme/my-plugin vendor/bin/pest
77```
78
79Treat 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.
80
81## Isolation Checklist
82
83Run this against any plugin suite you inherit, write, or review. Each line has failed in practice.
84
85| Check | Where | Failure if missing |
86|-------|-------|--------------------|
87| `RefreshesDatabase` bound alongside `TestCase` | `tests/Pest.php` | Every write commits permanently |
88| `CRAFT_DB_DATABASE` pinned before Craft boots | `tests/bootstrap.php` | Suite runs against the dev database |
89| `date_default_timezone_set('UTC')` **after** app creation | `tests/bootstrap.php` | Datetimes shift by the install's UTC offset |
90| Same pins present as `<env>` entries | `phpunit.xml.dist` | Correct-invocation path has no pins |
91| DB name + table prefix forced; coordinates `default="true"` | `phpunit.xml.dist` | A forced local hostname breaks CI runners |
92| `Install::safeUp()` guarded with `createIndexIfMissing()` / `Db::findForeignKey()` | `src/migrations/Install.php` | Duplicate keys accumulate to MySQL's 64-per-table cap |
93| Sites created per-test and deleted in `afterEach()` | tests, `tests/Pest.php` | Durable sites mutate the shared test database |
94| Suite invoked from the plugin's own root | `composer test`, CI, DDEV | `<env>` pins silently ignored |
95| Plugin under test explicitly installed | `tests/bootstrap.php` | Works only on an install that already has it |
96| Edition pinned explicitly | `beforeEach()` | Passes on Pro, fails on Solo/Team |
97| Count assertions scoped to test-created rows | each test | Passes on a seeded install, fails when clean |
98| Audit/event sinks muted on **every** surface | shared helper | Tests write real audit rows |
99| Queue replaced with a stub | shared helper | Tests drain or grow a real backlog |
100| A Pest job actually runs in CI | `.github/workflows/` | The suite decays unnoticed |
101
102## Reference Files
103
104Read the reference file(s) your task needs — each costs input tokens on every turn.
105
106**Task examples:**
107- "Set up Pest for a new plugin" → `isolation.md` (bootstrap + phpunit.xml) then `patterns.md`
108- "Tests are writing to my dev database / created thousands of entries" → `isolation.md`
109- "Suite passes locally but fails on CI or against a fresh test DB" → `isolation.md` (Ambient state) + `shared-state.md`
110- "Write a test for a controller action / element factory / queue job" → `patterns.md`
111- "Test dynamically-registered permissions" → `craft-state.md` (Permission-tree memoization)
112- "Test something that requires a logged-in user" → `craft-state.md` (Simulating a login)
113- "Tests wrote real audit rows / fired real webhooks" → `craft-state.md` (Muting event surfaces)
114- "Raw SQL fixture isn't treated as expired" → `craft-state.md` (Fixture timestamps)
115- "Tests pollute a shared playground install" → `shared-state.md`
116- "`Install.php` changes aren't reaching the test database" → `shared-state.md` (Schema drift)
117- "Expiry/date assertions fail intermittently, or datetimes come back hours off" → `isolation.md` (Pin the process timezone)
118- "Suite connects fine locally but can't reach the database on CI" → `isolation.md` (Force the database name, default everything else) + `ci.md`
119- "Install fails with too many keys / duplicate indexes piling up" → `shared-state.md` (Install migrations must be idempotent)
120- "Set up a multi-site test / my test site's queries ignore siteId" → `craft-state.md` (Site fixtures) + the `craftcms` skill's `architecture.md`
121- "My fixture-cleanup sweep isn't deleting anything / fixtures leak into a shared install" → `craft-state.md` (Prefix-matching sweeps)
122- "Test passes alone but fails in the suite / service returns stale data" → `craft-state.md` (Service caches go stale when craft-pest swaps components)
123- "Wire tests into CI" → `ci.md`
124
125| Reference | Scope |
126|-----------|-------|
127| `references/isolation.md` | Database 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) |
128| `references/craft-state.md` | Craft 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 |
129| `references/patterns.md` | Writing the tests: factories, HTTP, queue, database assertions, multi-site, mocking Craft services, console commands, events, file/test conventions |
130| `references/shared-state.md` | Suites 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 |
131| `references/ci.md` | CI wiring: `check-cs` not `fix-cs`, a real Pest job, invocation from the plugin root, fresh-database verification |
132
133## Two Harnesses (and when Pest isn't the answer)
134
135| Aspect | Codeception (Craft's native) | Pest + craft-pest-core |
136|--------|------------------------------|------------------------|
137| Base class | `craft\test\TestCase` | `markhuot\craftpest\test\TestCase` |
138| Element creation | Fixture classes + data files | `Entry::factory()->create()` |
139| HTTP | `FunctionalTester` (`$I`) | `$this->get('/path')->assertOk()` |
140| Rollback | Fixture teardown | `RefreshesDatabase` trait (opt-in) |
141
142Use Pest for new plugin work. Use Codeception when contributing to Craft core or extending an existing Codeception suite.
143
144For **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.
145
146## What a Green Suite Does Not Prove
147
148Worth holding in mind, because each of these has shipped a real bug past a passing test run:
149
150- **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`.
151- **A green run on a seeded dev install is not authoritative.** Confirm against a freshly created test database before believing it.
152- **A console-driven harness is not a browser.** Sessions, user-agent gates, and impersonation behave differently. See `craft-state.md`.
153- **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`.
154- **A suite that isn't in CI doesn't exist.** It rots at the speed of the codebase. See `ci.md`.