| name | Pest Testing Framework |
| description | Integration with the Pest PHP testing framework for writing and running tests |
| color | pink |
- Running tests:
php ./vendor/bin/pest - Running specific tests:
php ./vendor/bin/pest tests/MyTest.php
Overview
Pest is an elegant PHP testing framework with a focus on simplicity. It's built on top of PHPUnit but provides a more expressive and developer-friendly syntax.
Running Tests
Basic Commands
# Run all tests
php ./vendor/bin/pest
# Run tests in a specific directory
php ./vendor/bin/pest tests/Unit
# Run a specific test file
php ./vendor/bin/pest tests/HeroComponentsTest.php
# Run tests with coverage
php ./vendor/bin/pest --coverage
# Run tests with coverage and minimum threshold
php ./vendor/bin/pest --coverage --min=80
# Run tests in parallel (faster execution)
php ./vendor/bin/pest --parallel
# Run tests with verbose output
php ./vendor/bin/pest -v
# Run tests and stop on first failure
php ./vendor/bin/pest --stop-on-failure
# Run tests matching a filter
php ./vendor/bin/pest --filter="HeroComponents"
Test Structure
Basic Test File
<?php
// tests/ExampleTest.php
test('example test', function () {
expect(true)->toBeTrue();
});
it('can perform assertions', function () {
$result = 2 + 2;
expect($result)->toBe(4);
});
Organized Tests with describe()
<?php
describe('HeroComponents', function () {
it('renders correctly', function () {
// Test implementation
});
it('handles empty state', function () {
// Test implementation
});
});
Using beforeEach() and afterEach()
<?php
beforeEach(function () {
// Setup code that runs before each test
$this->user = createUser();
});
afterEach(function () {
// Cleanup code that runs after each test
$this->user = null;
});
test('user can login', function () {
expect($this->user)->not->toBeNull();
});
Common Expectations
Value Assertions
expect($value)->toBe($expected); // Strict equality (===)
expect($value)->toEqual($expected); // Loose equality (==)
expect($value)->toBeTrue();
expect($value)->toBeFalse();
expect($value)->toBeNull();
expect($value)->toBeEmpty();
expect($value)->toBeGreaterThan(5);
expect($value)->toBeLessThan(10);
Type Assertions
expect($value)->toBeInt();
expect($value)->toBeString();
expect($value)->toBeArray();
expect($value)->toBeObject();
expect($value)->toBeInstanceOf(MyClass::class);
Array/Collection Assertions
expect($array)->toHaveCount(3);
expect($array)->toContain('value');
expect($array)->toHaveKey('key');
expect($array)->each->toBeString();
String Assertions
expect($string)->toStartWith('Hello');
expect($string)->toEndWith('World');
expect($string)->toContain('test');
expect($string)->toMatch('/regex/');
Negation
expect($value)->not->toBe('wrong');
expect($array)->not->toBeEmpty();
Datasets
Use datasets to run the same test with different inputs:
<?php
it('can add numbers', function (int $a, int $b, int $expected) {
expect($a + $b)->toBe($expected);
})->with([
[1, 2, 3],
[5, 5, 10],
[10, 20, 30],
]);
// Named datasets
it('validates email', function (string $email) {
expect(isValidEmail($email))->toBeTrue();
})->with([
'valid email' => 'test@example.com',
'another valid' => 'user@domain.co.uk',
]);
HTTP Requests
Pest provides methods to test HTTP endpoints by simulating requests and asserting on responses.
Basic GET Requests
<?php
it('loads the homepage', function () {
$this->get('/')
->assertOk();
});
// Can be chained in a fluent style
it('returns json data')
->get('/api/data')
->assertOk()
->assertJson(['status' => 'success']);
POST Requests
<?php
it('posts to an action', function () {
$this->post('/post-data', ['foo' => 'bar'])
->assertOk()
->assertSee('"foo":"bar"');
});
// Post JSON data
it('posts json to an action', function () {
$response = $this->postJson('/post-data', ['foo' => 'bar'])
->assertHeader('content-type', 'application/json')
->assertOk();
expect($response->json()->json())->foo->toBe('bar');
});
Authenticated Requests
Use actingAs() to make requests as a specific user:
<?php
use markhuot\craftpest\factories\User;
it('allows authenticated users to access protected pages', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/admin/settings')
->assertOk();
});
// Shorthand for admin users
it('allows admins to access settings')
->actingAsAdmin()
->get('/admin/settings')
->assertOk();
Common Response Assertions
<?php
// Status codes
->assertOk() // 200
->assertCreated() // 201
->assertForbidden() // 403
->assertNotFound() // 404
// Content assertions
->assertSee('text') // Response contains text
->assertDontSee('text') // Response doesn't contain text
// JSON assertions
->assertJson(['key' => 'value']) // Contains JSON subset
->assertExactJson(['key' => 'value']) // Exact JSON match
->assertJsonPath('foo', 'bar') // Assert value at path
->assertJsonCount(2) // Assert JSON array count
->assertJsonStructure(['foo', 'bar']) // Assert JSON structure
->assertJsonFragment(['baz' => 'qux']) // Contains JSON fragment
->assertJsonMissing(['missing']) // JSON doesn't contain value
->assertJsonMissingPath('qux') // Path doesn't exist
// Header assertions
->assertHeader('x-foo') // Header exists
->assertHeader('x-foo', 'bar') // Header has value
->assertHeaderMissing('x-qux') // Header doesn't exist
// Cookie assertions
->assertCookie('cookieName') // Cookie exists
->assertCookie('cookieName', 'cookieValue') // Cookie has value
->assertCookieMissing('foo') // Cookie doesn't exist
->assertCookieExpired('cookieName') // Cookie is expired
->assertCookieNotExpired('cookieName') // Cookie is valid
// Other assertions
->assertDownload('file.jpg') // Response is a download
->assertCacheTag('foo', 'baz') // Response has cache tags
Working with Response Data
<?php
it('processes json response data', function () {
$response = $this->get('/api/users');
// Access JSON data
$data = $response->json();
expect($data)->toHaveKey('users');
// Access response content
$content = $response->content;
expect($content)->toContain('expected text');
});
Testing Forms and Links
<?php
it('clicks links and follows redirects', function () {
$this->get('/links')
->querySelector('a')
->click()
->assertOk()
->assertSee('Hello World');
});
Best Practices for HTTP Testing
Test Your Logic, Not the Framework: Focus on custom validation, rendering logic, or business rules rather than testing if Craft CMS works
Use Meaningful Assertions: Assert on the actual behavior that matters to your application
Clean URLs: Use relative URLs starting with
/for consistencyChain Assertions: Take advantage of fluent chaining for readable tests
Example of a well-focused HTTP test:
<?php
it('validates that blog posts require a title', function () {
$this->actingAsAdmin()
->post('/actions/entries/save', [
'sectionId' => 1,
'typeId' => 1,
'title' => '', // Empty title
'slug' => 'test-post',
])
->assertSessionHasErrors('title');
});
Rendering Templates Directly
Use ->renderTemplate() to render Twig templates directly without the overhead of a full HTTP request. This is faster and more focused than ->get() when you only need to test template output.
Basic Template Rendering
<?php
it('renders a template', function () {
$this->renderTemplate('pages/home')
->assertSee('Welcome');
});
// Can be chained in a fluent style
it('renders the hero component')
->renderTemplate('_components/hero')
->assertSee('Hero Content');
Passing Variables to Templates
Pass variables as the second parameter, just like you would in Twig:
<?php
it('renders a template with variables', function () {
$this->renderTemplate('_components/card', [
'title' => 'My Card Title',
'description' => 'Card description text',
])
->assertSee('My Card Title')
->assertSee('Card description text');
});
// Pass complex data
it('renders a list with entries', function () {
$entries = Entry::factory()
->section('posts')
->count(3)
->create();
$this->renderTemplate('_partials/entry-list', [
'entries' => $entries,
])
->assertSee($entries[0]->title)
->assertSee($entries[1]->title)
->assertSee($entries[2]->title);
});
All HTTP Assertions Work
Since renderTemplate() returns a TestableResponse object (same as ->get() and ->post()), all the same assertions are available:
<?php
it('uses various assertions on rendered templates', function () {
$response = $this->renderTemplate('_components/hero', [
'heading' => 'Welcome',
'showCta' => true,
]);
// Content assertions
$response->assertSee('Welcome');
$response->assertDontSee('Hidden Content');
// Access the raw content
expect($response->content)->toContain('<h1>');
});
DOM Selection and Testing
Use ->querySelector() to select and test specific elements. Because Pest's browser testing uses Playwright under the hood we should use data-testid attributes for reliable selection in complex templates. This also allows Playwright to find the same elements during browser tests. For example, in your Twig template:
<button data-testid="submit-button" class="btn-primary">Submit</button>
Then in your Pest test:
<?php
it('selects and tests specific elements using data-testid', function () {
$this->renderTemplate('_components/button', ['type' => 'primary'])
->querySelector('[data-testid="submit-button"]')
->assertSee('Submit')
->assertAttribute('class', 'btn-primary');
});
You can also query any standard CSS selector, but be cautious with this because CSS classes may change as the site evolves or dynamically as JavaScript modifies the DOM. Using data-testid attributes provides a stable way to select elements specifically for testing purposes.
<?php
it('selects and tests specific elements', function () {
$this->renderTemplate('_components/navigation')
->querySelector('nav ul')
->assertSee('Home')
->assertSee('About');
});
it('tests element attributes', function () {
$this->renderTemplate('_components/button', ['type' => 'primary'])
->querySelector('button')
->assertAttribute('class', 'btn-primary');
});
it('tests nested elements')
->renderTemplate('_layouts/page')
->querySelector('header h1')
->assertSee('Page Title');
Snapshot Testing
Templates work great with snapshot testing:
<?php
it('matches template snapshot', function () {
$this->renderTemplate('_components/card', [
'title' => 'Test Card',
'content' => 'Test content',
])
->assertMatchesSnapshot();
});
it('matches element snapshot', function () {
$this->renderTemplate('_partials/footer')
->querySelector('.copyright')
->assertMatchesSnapshot();
});
When to Use renderTemplate() vs get()
Use ->renderTemplate() when:
- ✅ Testing template logic in isolation
- ✅ Testing components or partials that don't have their own routes
- ✅ You need faster tests (no HTTP overhead)
- ✅ Testing pure template rendering without controllers or routing
Use ->get() when:
- ✅ Testing full request/response cycles
- ✅ Testing routing, controllers, or middleware
- ✅ Testing with authentication or session state
- ✅ The route performs logic before rendering
Use browser testing (visit() or visitTemplate()) when:
- ✅ Testing JavaScript interactions
- ✅ Testing visual properties (CSS, layout, spacing)
- ✅ Testing dynamic behavior that requires a browser
See Browser Testing Documentation for comprehensive information on browser testing with visit() and visitTemplate().
Best Practices
Test Template Logic, Not Framework Features: Focus on your custom Twig logic, conditionals, loops, and output
<?php // ✅ Good - Testing custom conditional logic it('shows CTA when flag is enabled', function () { $this->renderTemplate('_components/hero', ['showCta' => true]) ->assertSee('Call to Action'); $this->renderTemplate('_components/hero', ['showCta' => false]) ->assertDontSee('Call to Action'); }); // ❌ Bad - Just testing that Twig works it('renders a template', function () { $this->renderTemplate('_components/hero') ->assertSee('Hero'); });Use for Component Testing: Perfect for testing reusable components in isolation
<?php it('renders alert component with different types', function ($type, $expected) { $this->renderTemplate('_components/alert', ['type' => $type]) ->assertSee($expected); })->with([ ['success', 'alert-success'], ['error', 'alert-error'], ['warning', 'alert-warning'], ]);Combine with Factories: Use factories to create realistic test data
<?php it('renders entry card with all fields', function () { $entry = Entry::factory() ->section('articles') ->title('Test Article') ->description('Article description') ->featuredImage($imageId) ->create(); $this->renderTemplate('_components/entry-card', ['entry' => $entry]) ->assertSee('Test Article') ->assertSee('Article description') ->querySelector('img') ->assertAttribute('src'); });Template Paths: Use relative paths from your templates directory
<?php // Correct paths $this->renderTemplate('pages/home') // templates/pages/home.twig $this->renderTemplate('_components/hero') // templates/_components/hero.twig $this->renderTemplate('_partials/nav') // templates/_partials/nav.twig
Factories
See Factories Documentation for comprehensive information on creating test data with factories, including:
- Basic entry creation with
Entry::factory() - Setting field values and working with different field types
- Advanced techniques like sequences and custom authors
- Complete examples combining multiple field types
Test Organization
Pest.php Configuration
The tests/Pest.php file is used for global configuration:
<?php
uses()->in('Feature');
uses()->in('Unit');
// Set up global expectations
expect()->extend('toBeWithinRange', function (int $min, int $max) {
return $this->toBeGreaterThanOrEqual($min)
->toBeLessThanOrEqual($max);
});
Working with Existing Tests
When modifying or creating tests:
Read existing tests first: Always use the Read tool to examine current test files to understand patterns and conventions used in the project
Match existing style: Follow the same structure, naming conventions, and assertion patterns used in the codebase
Run tests after changes: Always run the test suite after making modifications to ensure nothing breaks
Check for test configuration: Look for
phpunit.xmlorpest.xmlfiles that may contain important configuration
Debugging Tests
Dumping Values
test('debugging example', function () {
$value = ['key' => 'value'];
\markhuot\craftpest\helpers\test\dump($value); // Output value and continue
\markhuot\craftpest\helpers\test\dd($value); // Output value and die
expect($value)->toHaveKey('key');
});
Using --filter
# Run only tests with "HeroComponents" in the name
php ./vendor/bin/pest --filter="HeroComponents"
# Run tests in a specific file
php ./vendor/bin/pest tests/HeroComponentsTest.php
Verbose Output
# Show more details about test execution
php ./vendor/bin/pest -v
php ./vendor/bin/pest -vv
php ./vendor/bin/pest -vvv
Test Coverage
Generating Coverage Reports
# HTML coverage report (opens in browser)
php ./vendor/bin/pest --coverage --coverage-html=coverage
# Terminal coverage report
php ./vendor/bin/pest --coverage
# Enforce minimum coverage
php ./vendor/bin/pest --coverage --min=80
Best Practices
Focus on User Logic, Not Framework Functionality: Do not write tests that only verify core Craft CMS or Pest functionality. Tests should focus on your custom logic, business rules, or validation.
❌ Bad - Testing framework functionality:
it('can create a hero component entry', function () { $entry = Entry::factory() ->section('heroComponents') ->heading('Test Hero Heading') ->description('<p>Test hero description content</p>') ->create(); // These only test that Craft and Pest work, not your code expect($entry)->toBeInstanceOf(Entry::class); expect($entry->section->handle)->toBe('heroComponents'); expect((string)$entry->heading)->toBe('Test Hero Heading'); expect((string)$entry->description)->toContain('Test hero description'); });✅ Good - Testing custom validation or business logic:
it('validates that heading cannot exceed 100 characters', function () { $entry = Entry::factory() ->section('heroComponents') ->heading(str_repeat('a', 101)) ->create(); expect($entry->getErrors('heading'))->not->toBeEmpty(); }); it('renders hero component with formatted output', function () { $entry = Entry::factory() ->section('heroComponents') ->heading('Test Heading') ->create(); $output = renderHeroComponent($entry); expect($output)->toContain('<h1>Test Heading</h1>'); expect($output)->toContain('hero-component-wrapper'); });Only create entries with factories as setup for testing your actual logic. If you're not testing custom validation, rendering logic, or business rules, you probably don't need the test.
Test Naming: Use descriptive test names that explain what is being tested
it('renders hero component with correct title and description', function () { // ... });Single Assertion Per Test: When possible, test one thing at a time
it('has a title', function () { expect($component->title)->not->toBeNull(); }); it('has a description', function () { expect($component->description)->not->toBeNull(); });Use Datasets for Similar Tests: Reduce code duplication with datasets
it('validates input', function ($input, $expected) { expect(validate($input))->toBe($expected); })->with([ ['valid', true], ['invalid', false], ]);Arrange-Act-Assert Pattern: Structure tests clearly
it('creates a user', function () { // Arrange $data = ['name' => 'John', 'email' => 'john@example.com']; // Act $user = createUser($data); // Assert expect($user->name)->toBe('John'); expect($user->email)->toBe('john@example.com'); });Clean Up After Tests: Use
afterEach()orafterAll()to clean up resourcesSkip Tests When Needed: Use
skip()to temporarily disable testsit('has a pending feature', function () { // Test implementation })->skip('Waiting for API changes');
Common Issues
Memory Limit
For large test suites, you may need to increase PHP memory limit:
php -d memory_limit=512M ./vendor/bin/pest
Xdebug
If tests are slow, check if Xdebug is enabled. Disable it for faster execution:
php -d xdebug.mode=off ./vendor/bin/pest
Additional Resources
- Official Pest Documentation: https://pestphp.com
- Craft specific Documentation: https://craft-pest.com
- Expectation API: https://pestphp.com/docs/expectations