Skip to content
Development
Skill

/craft-pest

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

From plugin
craftcms-claude-skills
7913 skills6 agents
Install
$ npx -y skills add michtio/craftcms-claude-skills --skill craft-pest --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/craft-pest

Context preview

The summary Claude sees to decide when to auto-load this skill.

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

SKILL.md

craft-pest.SKILL.md
name: craft-pest
description: "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."

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.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

  • Craft Pest: https://craft-pest.com
  • Pest PHP: https://pestphp.com/docs/installation
  • Codeception (Craft's native harness): https://craftcms.com/docs/5.x/extend/testing.html

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

Read more
Ships withcraftcms-claude-skills

Production-ready Claude Code skills, agents, and project templates for Craft CMS 5 development. Built and maintained by michtio.

Get the whole plugin

Other skills on craftcms-claude-skills.