Claude Code Plugins

Community-maintained marketplace

Feedback

Pest Testing Framework

@markhuot/craft-pest-core
18
0

Integration with the Pest PHP testing framework for writing and running tests

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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

  1. Test Your Logic, Not the Framework: Focus on custom validation, rendering logic, or business rules rather than testing if Craft CMS works

  2. Use Meaningful Assertions: Assert on the actual behavior that matters to your application

  3. Clean URLs: Use relative URLs starting with / for consistency

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

  1. 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');
    });
    
  2. 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'],
    ]);
    
  3. 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');
    });
    
  4. 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:

  1. Read existing tests first: Always use the Read tool to examine current test files to understand patterns and conventions used in the project

  2. Match existing style: Follow the same structure, naming conventions, and assertion patterns used in the codebase

  3. Run tests after changes: Always run the test suite after making modifications to ensure nothing breaks

  4. Check for test configuration: Look for phpunit.xml or pest.xml files 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

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

  2. Test Naming: Use descriptive test names that explain what is being tested

    it('renders hero component with correct title and description', function () {
        // ...
    });
    
  3. 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();
    });
    
  4. 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],
    ]);
    
  5. 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');
    });
    
  6. Clean Up After Tests: Use afterEach() or afterAll() to clean up resources

  7. Skip Tests When Needed: Use skip() to temporarily disable tests

    it('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