Craft Pest logo

Craft Pest

Community
michtio
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 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 jobs.

Overview

Publishermichtio
Repositorycraftcms-claude-skills
Skill namecraft-pest
Stars
87
Forks
9
Bundled files
5
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 5 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by michtio on GitHub. Read the source before you install it.

Installation

Install the Craft Pest AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/michtio/craftcms-claude-skills/tree/main/skills/craft-pest
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/michtio/craftcms-claude-skills.git /tmp/craftcms-claude-skills
mkdir -p .claude/skills
cp -r /tmp/craftcms-claude-skills/skills/craft-pest .claude/skills/craft-pest
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Craft Pest in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Craft Pest on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Craft Pest is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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

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.

php
// 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:

php
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:

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

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

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Craft Pest AI skill do?

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

Why use Craft Pest on TypingMind?

Because you install it once and use it with any model. Craft Pest is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Craft Pest in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/michtio/craftcms-claude-skills/tree/main/skills/craft-pest. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Craft Pest?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Craft Pest?

As many as you like. As long as a model supports skills, you can use Craft Pest with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Craft Pest AI skill free?

Yes. It is published on GitHub by michtio under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇