diff --git a/README.md b/README.md index 1bbebd3..7c1a411 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ The slic (**S**tellarWP **L**ocal **I**nteractive **C**ontainers) CLI command pr * [Xdebug and `slic`](#xdebug-and-slic) * [Configuring IDEs for Xdebug](/docs/xdebug.md) * [Releasing a new version of `slic`](/CONTRIBUTING.md) +* [Agent Skills (AI-assisted testing)](#agent-skills-ai-assisted-testing) * [Update guide](#update-guide) * [From 1.0 to 2.0](#from-10-to-20) @@ -359,6 +360,42 @@ When using these commands, `slic` will prompt you to restart the containers. - `xon` - Enable Xdebug - `xoff` - Disable Xdebug +## Agent Skills (AI-assisted testing) + +This repository includes an [Agent Skill](https://agentskills.io) in `skills/slic/` that teaches AI coding assistants (Claude Code, Cursor, Copilot, Gemini CLI, and [30+ other tools](https://agentskills.io)) how to create and run WordPress integration tests with slic. + +### What it provides + +The skill gives agents structured context about: + +- The slic CLI workflow (`here`, `use`, `init`, `run`, `shell`) +- WPUnit test structure, naming conventions, and namespaces +- `setUp()` / `tearDown()` environment tiers (minimal, standard, full isolation) +- HTTP mocking patterns for WordPress (`pre_http_request` filter) +- Assertion helpers and WordPress test factories +- Advanced patterns (REST API dispatch, Reflection, custom tables) +- An 11-item test isolation checklist for preventing flaky tests + +### How agents discover it + +Agent Skills-compatible tools discover the skill automatically when working inside this repository or any project that references it. The entry point is `skills/slic/SKILL.md`, which links to detailed sub-documents that agents load on demand. + +For manual installation in other projects, use the [skills CLI](https://agentskills.io): + +```bash +# Install globally (available to all projects): +npx skills add stellarwp/slic -g + +# Install for a specific project: +npx skills add stellarwp/slic + +# Install for a specific agent: +npx skills add stellarwp/slic --agent cursor + +# List available skills before installing: +npx skills add stellarwp/slic --list +``` + ## Update Guide This guide covers the steps needed when upgrading `slic` between major versions. diff --git a/skills/slic/SKILL.md b/skills/slic/SKILL.md new file mode 100644 index 0000000..7262e97 --- /dev/null +++ b/skills/slic/SKILL.md @@ -0,0 +1,139 @@ +--- +name: slic +description: >- + Guide for creating, modifying, and running WPUnit integration tests with + slic (StellarWP Local Interactive Containers). Covers slic workflow, test + structure, environment setup, HTTP mocking, WordPress factories, assertions, + and Codeception/wp-browser patterns. Use when writing or debugging WordPress + plugin integration tests. +license: MIT +compatibility: Requires Docker and slic CLI on PATH +metadata: + author: stellarwp + version: "1.0" +--- + +# slic — WPUnit Integration Testing Guide + +## When to use this skill + +Activate this skill when: + +- Writing new WPUnit integration tests for a WordPress plugin or theme +- Modifying or debugging existing Codeception/wp-browser tests +- Running test suites through slic (the `slic run` workflow) +- Setting up a project for slic-based testing for the first time +- Diagnosing flaky or order-dependent test failures + +## Quick-start workflow + +slic orchestrates Docker containers (MariaDB, Redis, WordPress, Chrome, and a Codeception runner) so you never configure a local test environment manually. + +### 1. Point slic at your code + +```bash +# From a plugins directory (e.g. wp-content/plugins): +slic here + +# Or from a full WordPress root (where wp-config.php lives): +slic here +``` + +### 2. Select the target project + +```bash +slic use my-plugin + +# Subdirectory targets are supported: +slic use event-tickets/common +``` + +### 3. Initialize (first time only) + +```bash +slic init my-plugin +``` + +This generates three files in the plugin root: + +| File | Purpose | +|------|---------| +| `.env.testing.slic` | Database credentials, WordPress URL, container paths | +| `codeception.slic.yml` | Loads `.env.testing.slic` as Codeception params | +| `test-config.slic.php` | Optional WPLoader custom configuration | + +### 4. Run tests + +```bash +slic run # all suites, sequentially +slic run wpunit # one suite +slic run tests/wpunit/FooTest.php # one file +slic run tests/wpunit/FooTest::test_something # one method +``` + +### 5. Interactive shell (optional) + +```bash +slic shell +# Inside the container: +> cr wpunit # shorthand for codecept run +``` + +## Test creation rules + +When creating or modifying a test file, follow these rules: + +1. **Extend `WPTestCase`** — every WPUnit test class extends `\Codeception\TestCase\WPTestCase` (wp-browser v3) or `lucatume\WPBrowser\TestCase\WPTestCase` (wp-browser v4). +2. **Use the AAA pattern** — Arrange, Act, Assert. Keep each section visually distinct. +3. **Name clearly** — file: `Test.php`; methods: `test_` (preferred over `@test` annotations). +4. **Isolate** — every test must pass in any order. Clean up in `tearDown()`. +5. **Use factories** — prefer `$this->factory()->post->create()` over raw SQL or `wp_insert_post()` in test setup. +6. **Follow WordPress coding standards** — tabs for indentation, spaces inside parentheses. + +See [test-anatomy.md](test-anatomy.md) for the complete file skeleton and naming rules. + +## Environment setup tiers + +Choose the right level of setUp/tearDown for your test: + +| Tier | When to use | Guide | +|------|------------|-------| +| Minimal | Tests that only need WordPress loaded | [environment-setup.md](environment-setup.md#tier-1-minimal) | +| Standard | Tests that create posts, users, or terms | [environment-setup.md](environment-setup.md#tier-2-standard-with-factories) | +| Full isolation | Tests that mock HTTP, change globals, or modify options | [environment-setup.md](environment-setup.md#tier-3-full-isolation) | + +## Testing patterns + +| Pattern | Guide | +|---------|-------| +| HTTP mocking (3 approaches) | [http-mocking.md](http-mocking.md) | +| Assertions and WordPress factories | [assertions.md](assertions.md) | +| REST dispatch, Reflection, custom tables | [advanced-patterns.md](advanced-patterns.md) | +| Test isolation checklist (11 items) | [test-isolation-checklist.md](test-isolation-checklist.md) | + +## Verification workflow + +After writing or modifying tests, follow this sequence: + +1. **Write the code** under test (or confirm it exists). +2. **Create or update tests** following the patterns above. +3. **Run the targeted test** — `slic run tests/wpunit/YourTest.php`. +4. **Run the full suite** — `slic run wpunit` — to catch side effects. +5. **Fix any failures** and re-run. +6. **Verify isolation** — run the single test again to confirm it passes independently. +7. **Check the [isolation checklist](test-isolation-checklist.md)** before committing. + +## Reference material + +| Topic | File | +|-------|------| +| Complete slic CLI command reference | [references/slic-commands.md](references/slic-commands.md) | +| Installation, setup, env files, CI | [references/slic-setup.md](references/slic-setup.md) | +| WPLoader config and WPTestCase API | [references/wp-browser-wploader.md](references/wp-browser-wploader.md) | + +## External resources + +- [slic repository](https://github.com/stellarwp/slic) +- [wp-browser documentation](https://wpbrowser.wptestkit.dev/) +- [Codeception documentation](https://codeception.com/docs/Introduction) +- [WordPress PHPUnit test utilities](https://make.wordpress.org/core/handbook/testing/automated-testing/phpunit/) diff --git a/skills/slic/advanced-patterns.md b/skills/slic/advanced-patterns.md new file mode 100644 index 0000000..f081083 --- /dev/null +++ b/skills/slic/advanced-patterns.md @@ -0,0 +1,320 @@ +# Advanced Patterns + +This document covers testing patterns for REST API endpoints, private methods, custom database tables, and other advanced scenarios in WPUnit integration tests. + +## REST API dispatch testing + +Test REST endpoints without making actual HTTP requests by using `rest_do_request()`. This dispatches the request through WordPress's REST infrastructure internally. + +```php +class REST_Items_Test extends \Codeception\TestCase\WPTestCase { + + private int $admin_id; + + protected function setUp(): void { + parent::setUp(); + + $this->admin_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + + // Register routes if your plugin registers them on rest_api_init. + do_action( 'rest_api_init' ); + } + + protected function tearDown(): void { + parent::tearDown(); + } + + public function test_it_should_return_items_for_authenticated_user(): void { + // Arrange. + wp_set_current_user( $this->admin_id ); + $this->factory()->post->create_many( 3, [ + 'post_type' => 'post', + 'post_status' => 'publish', + ] ); + + // Act. + $request = new \WP_REST_Request( 'GET', '/my-plugin/v1/items' ); + $request->set_param( 'per_page', 10 ); + $response = rest_do_request( $request ); + + // Assert. + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 3, $response->get_data() ); + } + + public function test_it_should_reject_unauthenticated_requests(): void { + // Arrange — no user set (logged out). + wp_set_current_user( 0 ); + + // Act. + $request = new \WP_REST_Request( 'GET', '/my-plugin/v1/items' ); + $response = rest_do_request( $request ); + + // Assert. + $this->assertSame( 401, $response->get_status() ); + } + + public function test_it_should_create_an_item_via_post(): void { + // Arrange. + wp_set_current_user( $this->admin_id ); + + // Act. + $request = new \WP_REST_Request( 'POST', '/my-plugin/v1/items' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( [ + 'title' => 'New Item', + 'status' => 'active', + ] ) ); + $response = rest_do_request( $request ); + + // Assert. + $this->assertSame( 201, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'New Item', $data['title'] ); + } +} +``` + +### Key `WP_REST_Request` methods + +| Method | Purpose | +|--------|---------| +| `set_param( $key, $value )` | Set a query/body parameter | +| `set_header( $key, $value )` | Set a request header | +| `set_body( $body )` | Set the raw request body | +| `set_method( $method )` | Override the HTTP method | +| `set_query_params( $params )` | Set multiple query params at once | +| `set_body_params( $params )` | Set multiple body params at once | + +### Key `WP_REST_Response` methods + +| Method | Purpose | +|--------|---------| +| `get_status()` | HTTP status code (int) | +| `get_data()` | Decoded response data (array/object) | +| `get_headers()` | Response headers (array) | +| `get_links()` | HAL links (array) | + +## Testing private/protected methods with Reflection + +Use Reflection only when there's no reasonable way to test behavior through the public API. Prefer testing through public methods whenever possible. + +```php +public function test_it_should_format_price_correctly(): void { + $instance = new \My_Plugin\Pricing(); + + // Access a private method via Reflection. + $method = new \ReflectionMethod( $instance, 'format_price' ); + $method->setAccessible( true ); + + $result = $method->invoke( $instance, 1234.5 ); + + $this->assertSame( '$1,234.50', $result ); +} + +public function test_it_should_have_correct_default_config(): void { + $instance = new \My_Plugin\Settings(); + + // Access a private property via Reflection. + $property = new \ReflectionProperty( $instance, 'defaults' ); + $property->setAccessible( true ); + + $defaults = $property->getValue( $instance ); + + $this->assertArrayHasKey( 'enabled', $defaults ); + $this->assertTrue( $defaults['enabled'] ); +} +``` + +### When to use Reflection + +- Testing a complex private algorithm that's hard to exercise through the public API. +- Verifying internal state after a series of operations. +- **Not recommended** for simple getters/setters — those should be testable through public methods. + +## Custom database tables + +When your plugin creates custom tables, tests need to create and drop them to match production behavior. + +```php +class Custom_Table_Test extends \Codeception\TestCase\WPTestCase { + + protected function setUp(): void { + parent::setUp(); + + $this->create_custom_table(); + } + + protected function tearDown(): void { + $this->drop_custom_table(); + + parent::tearDown(); + } + + private function create_custom_table(): void { + global $wpdb; + + $table_name = $wpdb->prefix . 'my_plugin_logs'; + + $wpdb->query( "CREATE TABLE IF NOT EXISTS {$table_name} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + message TEXT NOT NULL, + level VARCHAR(20) NOT NULL DEFAULT 'info', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY level (level) + ) {$wpdb->get_charset_collate()}" ); + } + + private function drop_custom_table(): void { + global $wpdb; + + $table_name = $wpdb->prefix . 'my_plugin_logs'; + $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); + } + + public function test_it_should_insert_a_log_entry(): void { + global $wpdb; + + my_plugin_log( 'Something happened', 'warning' ); + + $row = $wpdb->get_row( + "SELECT * FROM {$wpdb->prefix}my_plugin_logs ORDER BY id DESC LIMIT 1" + ); + + $this->assertSame( 'Something happened', $row->message ); + $this->assertSame( 'warning', $row->level ); + } +} +``` + +**Important**: Custom table operations (CREATE, DROP, INSERT) happen outside the test transaction managed by `WPTestCase`. You must explicitly drop the table in `tearDown()` to avoid leaking state. + +If your plugin has its own table-creation method, use it instead of duplicating the schema: + +```php +private function create_custom_table(): void { + \My_Plugin\Database::create_tables(); +} +``` + +## Mid-test option and filter changes + +Sometimes you need to test how code behaves when options or filters change during execution: + +```php +public function test_it_should_respect_feature_flag_toggle(): void { + // Start with feature disabled. + update_option( 'my_plugin_feature_x', false ); + $this->assertFalse( my_plugin_is_feature_x_enabled() ); + + // Enable mid-test. + update_option( 'my_plugin_feature_x', true ); + $this->assertTrue( my_plugin_is_feature_x_enabled() ); + + // Clean up — the transaction rollback handles option changes in the + // wp_options table, so explicit cleanup is optional here. But if the + // plugin caches the value in a static property, you must reset that. +} + +public function test_it_should_allow_filter_override(): void { + // Default behavior. + $default = my_plugin_get_limit(); + $this->assertSame( 10, $default ); + + // Override via filter. + $filter = static function () { + return 50; + }; + add_filter( 'my_plugin_limit', $filter ); + + $this->assertSame( 50, my_plugin_get_limit() ); + + // Clean up. + remove_filter( 'my_plugin_limit', $filter ); +} +``` + +## Testing cron schedules and events + +```php +public function test_it_should_schedule_daily_sync(): void { + // Act. + my_plugin_activate(); + + // Assert. + $timestamp = wp_next_scheduled( 'my_plugin_daily_sync' ); + $this->assertNotFalse( $timestamp, 'Expected cron event to be scheduled.' ); + + $schedule = wp_get_schedule( 'my_plugin_daily_sync' ); + $this->assertSame( 'daily', $schedule ); +} + +public function test_it_should_unschedule_on_deactivate(): void { + // Arrange — schedule the event first. + wp_schedule_event( time(), 'daily', 'my_plugin_daily_sync' ); + + // Act. + my_plugin_deactivate(); + + // Assert. + $timestamp = wp_next_scheduled( 'my_plugin_daily_sync' ); + $this->assertFalse( $timestamp ); +} +``` + +## Testing with custom post types and taxonomies + +If your test needs a custom post type that isn't registered by the plugin's bootstrap, register it in setUp and unregister in tearDown: + +```php +protected function setUp(): void { + parent::setUp(); + + register_post_type( 'event', [ + 'public' => true, + 'label' => 'Events', + ] ); +} + +protected function tearDown(): void { + unregister_post_type( 'event' ); + + parent::tearDown(); +} + +public function test_it_should_query_events(): void { + $this->factory()->post->create_many( 2, [ + 'post_type' => 'event', + 'post_status' => 'publish', + ] ); + + $query = new \WP_Query( [ 'post_type' => 'event' ] ); + + $this->assertSame( 2, $query->found_posts ); +} +``` + +**Note**: If the plugin registers the post type during its normal activation (and the WPLoader module activates the plugin), you don't need to register it manually. Only use this pattern for post types not covered by the plugin's activation flow. + +## Resetting static and singleton state + +Some plugins use static properties or singletons that persist across tests because they live in PHP memory, not the database. + +```php +protected function tearDown(): void { + // Reset a singleton instance. + $property = new \ReflectionProperty( \My_Plugin\Container::class, 'instance' ); + $property->setAccessible( true ); + $property->setValue( null, null ); + + // Reset a static cache property. + $cache = new \ReflectionProperty( \My_Plugin\Cache::class, 'store' ); + $cache->setAccessible( true ); + $cache->setValue( null, [] ); + + parent::tearDown(); +} +``` + +If the class provides a public reset method (e.g., `Container::reset()`), prefer that over Reflection. diff --git a/skills/slic/assertions.md b/skills/slic/assertions.md new file mode 100644 index 0000000..4bcae19 --- /dev/null +++ b/skills/slic/assertions.md @@ -0,0 +1,265 @@ +# Assertions and WordPress Factories + +This document covers assertion patterns and factory usage for WPUnit integration tests with Codeception/wp-browser. + +## WordPress factories + +Factories create WordPress objects (posts, users, terms, comments, attachments) inside the test's database transaction so they are automatically cleaned up on rollback. + +### Creating objects + +```php +// Create and return the ID. +$post_id = $this->factory()->post->create( [ + 'post_title' => 'My Post', + 'post_status' => 'publish', + 'post_type' => 'post', +] ); + +// Create and return the full WP_Post object. +$post = $this->factory()->post->create_and_get( [ + 'post_title' => 'My Post', + 'post_status' => 'publish', +] ); + +// Create multiple — returns array of IDs. +$post_ids = $this->factory()->post->create_many( 5, [ + 'post_status' => 'publish', +] ); +``` + +### Available factories + +| Factory | Creates | Common args | +|---------|---------|-------------| +| `$this->factory()->post` | `WP_Post` | `post_title`, `post_status`, `post_type`, `post_author`, `post_content`, `post_date` | +| `$this->factory()->user` | `WP_User` | `role`, `user_login`, `user_email`, `display_name` | +| `$this->factory()->term` | `WP_Term` | `taxonomy`, `name`, `slug`, `parent`, `description` | +| `$this->factory()->comment` | `WP_Comment` | `comment_post_ID`, `user_id`, `comment_content`, `comment_approved` | +| `$this->factory()->attachment` | Attachment `WP_Post` | `file`, `post_parent`, `post_mime_type` | +| `$this->factory()->category` | Category `WP_Term` | `name`, `slug`, `parent` | +| `$this->factory()->tag` | Tag `WP_Term` | `name`, `slug` | + +### Common factory recipes + +**Post with meta:** + +```php +$post_id = $this->factory()->post->create( [ + 'post_type' => 'product', +] ); +update_post_meta( $post_id, '_price', '29.99' ); +update_post_meta( $post_id, '_sku', 'WIDGET-001' ); +``` + +**User with specific role and meta:** + +```php +$user_id = $this->factory()->user->create( [ + 'role' => 'editor', + 'user_email' => 'editor@example.com', +] ); +update_user_meta( $user_id, 'preferred_language', 'en' ); +``` + +**Term hierarchy:** + +```php +$parent_id = $this->factory()->term->create( [ + 'taxonomy' => 'category', + 'name' => 'Parent Category', +] ); +$child_id = $this->factory()->term->create( [ + 'taxonomy' => 'category', + 'name' => 'Child Category', + 'parent' => $parent_id, +] ); +``` + +**Post with terms assigned:** + +```php +$post_id = $this->factory()->post->create(); +$term_id = $this->factory()->term->create( [ + 'taxonomy' => 'category', + 'name' => 'News', +] ); +wp_set_object_terms( $post_id, [ $term_id ], 'category' ); +``` + +## Assertion patterns + +### WP_Error assertions + +```php +// Assert a value is a WP_Error. +$result = my_plugin_validate( '' ); +$this->assertWPError( $result ); + +// Assert a value is NOT a WP_Error. +$result = my_plugin_validate( 'valid-input' ); +$this->assertNotWPError( $result ); + +// Assert the error code. +$result = my_plugin_validate( '' ); +$this->assertSame( 'empty_input', $result->get_error_code() ); + +// Assert the error message. +$this->assertSame( 'Input cannot be empty.', $result->get_error_message() ); +``` + +### Post meta assertions + +```php +$post_id = $this->factory()->post->create(); +update_post_meta( $post_id, '_my_key', 'expected_value' ); + +// Assert meta value. +$this->assertSame( 'expected_value', get_post_meta( $post_id, '_my_key', true ) ); + +// Assert meta does not exist. +$this->assertEmpty( get_post_meta( $post_id, '_nonexistent_key', true ) ); +``` + +### User and user meta assertions + +```php +$user_id = $this->factory()->user->create( [ 'role' => 'editor' ] ); +$user = get_userdata( $user_id ); + +// Assert the role. +$this->assertContains( 'editor', $user->roles ); + +// Assert capabilities. +$this->assertTrue( $user->has_cap( 'edit_posts' ) ); +$this->assertFalse( $user->has_cap( 'manage_options' ) ); + +// Assert user meta. +update_user_meta( $user_id, 'score', 42 ); +$this->assertEquals( 42, get_user_meta( $user_id, 'score', true ) ); +``` + +### Hook assertions (actions and filters) + +```php +public function test_it_should_fire_custom_action(): void { + $fired = false; + add_action( 'my_plugin_after_save', static function () use ( &$fired ) { + $fired = true; + } ); + + my_plugin_save_settings( [ 'key' => 'value' ] ); + + $this->assertTrue( $fired, 'Expected my_plugin_after_save to fire.' ); +} + +public function test_it_should_count_action_calls(): void { + $call_count = did_action( 'my_plugin_init' ); + + my_plugin_initialize(); + + $this->assertSame( + $call_count + 1, + did_action( 'my_plugin_init' ), + 'Expected my_plugin_init to fire exactly once more.' + ); +} + +public function test_it_should_apply_title_filter(): void { + add_filter( 'my_plugin_title', static function ( $title ) { + return 'Filtered: ' . $title; + } ); + + $result = apply_filters( 'my_plugin_title', 'Original' ); + + $this->assertSame( 'Filtered: Original', $result ); +} +``` + +### Option assertions + +```php +public function test_it_should_save_settings_to_options(): void { + my_plugin_save_settings( [ 'color' => 'blue' ] ); + + $saved = get_option( 'my_plugin_settings' ); + $this->assertIsArray( $saved ); + $this->assertSame( 'blue', $saved['color'] ); +} +``` + +### HTML output assertions + +```php +public function test_it_should_render_widget_html(): void { + ob_start(); + my_plugin_render_widget( [ 'title' => 'Hello' ] ); + $html = ob_get_clean(); + + $this->assertStringContainsString( '

Hello

', $html ); + $this->assertStringContainsString( 'class="my-widget"', $html ); + $this->assertStringNotContainsString( 'class="error"', $html ); +} +``` + +### REST response assertions + +```php +public function test_it_should_return_items_via_rest(): void { + // Arrange — create test data. + $this->factory()->post->create_many( 3, [ 'post_status' => 'publish' ] ); + + // Act — dispatch a REST request internally (no HTTP needed). + $request = new \WP_REST_Request( 'GET', '/my-plugin/v1/items' ); + $response = rest_do_request( $request ); + + // Assert. + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertCount( 3, $data ); +} +``` + +### Exception assertions + +```php +public function test_it_should_throw_on_invalid_input(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'ID must be positive' ); + + my_plugin_get_item( -1 ); +} +``` + +## wp-browser v4 differences + +In wp-browser v4, the base class namespace changes: + +```php +// wp-browser v3 +use Codeception\TestCase\WPTestCase; + +// wp-browser v4 +use lucatume\WPBrowser\TestCase\WPTestCase; +``` + +The factory API remains the same (`$this->factory()->post->create()`, etc.). The key difference is the import path. Check your project's `composer.json` for which version is installed: + +```bash +slic composer show lucatume/wp-browser | grep versions +``` + +## Assertion tips + +1. **Prefer `assertSame()` over `assertEquals()`** — `assertSame()` checks type and value; `assertEquals()` does loose comparison. Use `assertSame()` by default and `assertEquals()` only when you intentionally want loose comparison (e.g., comparing int and numeric string). + +2. **Use descriptive failure messages** — the third argument to most assertions is a message shown on failure: + ```php + $this->assertTrue( $result, 'Expected the sync to succeed for published posts.' ); + ``` + +3. **Assert one behavior per test** — if you have multiple unrelated assertions, split them into separate test methods. Related assertions (e.g., checking status code and response body of the same request) belong together. + +4. **Use factories instead of raw SQL** — factories are transaction-safe and express intent clearly. Only use `$wpdb` directly when testing database-specific behavior. diff --git a/skills/slic/environment-setup.md b/skills/slic/environment-setup.md new file mode 100644 index 0000000..f645994 --- /dev/null +++ b/skills/slic/environment-setup.md @@ -0,0 +1,303 @@ +# Environment Setup + +This document describes three tiers of `setUp()` / `tearDown()` complexity for WPUnit integration tests. Choose the tier that matches your test's needs — avoid over-engineering setup when a simpler tier suffices. + +## Tier 1: Minimal + +Use when your test only needs WordPress loaded and makes assertions against built-in functions or your plugin's API without creating custom data. + +```php +class SimpleTest extends \Codeception\TestCase\WPTestCase { + + protected function setUp(): void { + parent::setUp(); + } + + protected function tearDown(): void { + parent::tearDown(); + } + + public function test_it_should_return_site_title(): void { + $this->assertNotEmpty( get_bloginfo( 'name' ) ); + } +} +``` + +**What `parent::setUp()` does for you:** + +- Starts a database transaction (rolled back in `tearDown()`). +- Resets WordPress global state (`$wp_actions`, `$wp_filters`, etc.). +- Clears the object cache. +- Sets the current user to `0` (logged out). + +**What `parent::tearDown()` does for you:** + +- Rolls back the database transaction — any rows inserted during the test disappear. +- Restores global state snapshots. + +Even if your test does nothing special, always call both parent methods. They are the foundation of test isolation. + +## Tier 2: Standard (with factories) + +Use when your test creates posts, users, terms, or other WordPress objects. Factories create data inside the database transaction, so it's automatically rolled back. + +```php +class PostFeatureTest extends \Codeception\TestCase\WPTestCase { + + private int $editor_id; + + /** + * @var int[] + */ + private array $post_ids; + + protected function setUp(): void { + parent::setUp(); + + // Create a user with the editor role. + $this->editor_id = $this->factory()->user->create( [ + 'role' => 'editor', + ] ); + + // Create 3 published posts authored by the editor. + $this->post_ids = $this->factory()->post->create_many( 3, [ + 'post_author' => $this->editor_id, + 'post_status' => 'publish', + ] ); + } + + protected function tearDown(): void { + // Factory-created data is cleaned up by the transaction rollback. + // Only clean non-transactional side effects here. + + parent::tearDown(); + } + + public function test_it_should_return_posts_by_editor(): void { + $query = new \WP_Query( [ + 'author' => $this->editor_id, + 'post_status' => 'publish', + ] ); + + $this->assertSame( 3, $query->found_posts ); + } + + public function test_it_should_set_correct_author(): void { + $post = get_post( $this->post_ids[0] ); + + $this->assertEquals( $this->editor_id, $post->post_author ); + } +} +``` + +### Factory quick reference + +```php +// Single objects — returns ID. +$post_id = $this->factory()->post->create( [ 'post_title' => 'Hello' ] ); +$user_id = $this->factory()->user->create( [ 'role' => 'subscriber' ] ); +$term_id = $this->factory()->term->create( [ 'taxonomy' => 'category', 'name' => 'News' ] ); + +// Get the full object instead of just the ID. +$post = $this->factory()->post->create_and_get( [ 'post_title' => 'Hello' ] ); + +// Create multiple — returns array of IDs. +$post_ids = $this->factory()->post->create_many( 5, [ 'post_status' => 'publish' ] ); +``` + +All factory-created data lives inside the test's database transaction and is rolled back automatically. + +## Tier 3: Full isolation + +Use when your test modifies global state beyond the database: options, filters, actions, transients, HTTP mocking, or static/singleton properties. + +```php +class FullIsolationTest extends \Codeception\TestCase\WPTestCase { + + /** + * @var string|false Original option value to restore. + */ + private $original_option; + + private \Closure $http_filter; + + protected function setUp(): void { + parent::setUp(); + + // Save and override an option. + $this->original_option = get_option( 'my_plugin_setting' ); + update_option( 'my_plugin_setting', 'test_value' ); + + // Add an HTTP mock — block all external requests with a fake response. + $this->http_filter = static function () { + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => wp_json_encode( [ 'success' => true ] ), + 'headers' => [], + 'cookies' => [], + ]; + }; + add_filter( 'pre_http_request', $this->http_filter, 10, 3 ); + + // Add a custom action for testing. + add_action( 'my_plugin_event', [ $this, 'track_event' ] ); + } + + protected function tearDown(): void { + // Restore the original option value. + if ( false === $this->original_option ) { + delete_option( 'my_plugin_setting' ); + } else { + update_option( 'my_plugin_setting', $this->original_option ); + } + + // Remove the HTTP mock filter. + remove_filter( 'pre_http_request', $this->http_filter, 10 ); + + // Remove the custom action. + remove_action( 'my_plugin_event', [ $this, 'track_event' ] ); + + // Always call parent last. + parent::tearDown(); + } + + // ...test methods... +} +``` + +### Checklist for Tier 3 tearDown + +| Side effect | How to undo | +|-------------|-------------| +| `update_option()` | Restore original value or `delete_option()` | +| `add_filter()` / `add_action()` | `remove_filter()` / `remove_action()` with same priority | +| `set_transient()` | `delete_transient()` | +| Global variable changed | Restore from saved copy | +| Static property set | Reset to original value | +| `wp_cache_set()` | `wp_cache_flush()` or `wp_cache_delete()` | + +Every item you touch in `setUp()` must have a corresponding undo in `tearDown()`. + +## One-time setup with `setUpBeforeClass` / `tearDownAfterClass` + +For expensive operations that don't change between tests (e.g., importing a large fixture file, registering a custom post type for the entire class), use the static class-level hooks: + +```php +class ExpensiveSetupTest extends \Codeception\TestCase\WPTestCase { + + private static int $fixture_page_id; + + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + + // Expensive one-time setup. + self::$fixture_page_id = $this->factory()->post->create( [ + 'post_type' => 'page', + 'post_title' => 'Fixture Page', + 'post_status' => 'publish', + ] ); + } + + public static function tearDownAfterClass(): void { + // Clean up the shared fixture. + wp_delete_post( self::$fixture_page_id, true ); + + parent::tearDownAfterClass(); + } + + protected function setUp(): void { + parent::setUp(); + // Per-test setup can reference self::$fixture_page_id. + } + + protected function tearDown(): void { + parent::tearDown(); + } +} +``` + +**Important**: Data created in `setUpBeforeClass()` lives outside the per-test transaction. You must clean it up explicitly in `tearDownAfterClass()`. + +## Custom base TestCase with Container integration + +For projects that use a Dependency Injection container (e.g., di52, lucatume/di52, or PHP-DI), create a base test class that gives all your tests easy access to the container: + +```php +container = tribe( Container::class ); + // Or however your plugin exposes its container, e.g.: + // $this->container = my_plugin()->container(); + } + + protected function tearDown(): void { + unset( $this->container ); + + parent::tearDown(); + } +} +``` + +Then extend your custom base class instead of `WPTestCase` directly: + +```php +container->get( \My_Plugin\Gateway::class ); + + $this->assertInstanceOf( \My_Plugin\Gateway::class, $gateway ); + } + + public function test_it_should_process_payment(): void { + $processor = $this->container->get( \My_Plugin\Payment_Processor::class ); + + $result = $processor->charge( 100, 'USD' ); + + $this->assertTrue( $result->is_successful() ); + } +} +``` + +**Benefits:** + +- Every test gets a consistent container reference via `$this->container`. +- Shared setup logic (container initialization, common fixtures) lives in one place. +- Adding new shared utilities (helper methods, additional fixtures) only requires updating the base class. + +## Common pitfalls + +1. **Calling `parent::setUp()` last instead of first** — WordPress state is not clean when your setup code runs, leading to hard-to-debug failures. + +2. **Calling `parent::tearDown()` first instead of last** — the transaction rolls back before your cleanup code runs, so you may be trying to undo changes that are already gone (or worse, you skip cleanup that happens outside the transaction). + +3. **Forgetting to remove filters added in setUp** — even though `parent::tearDown()` resets some global state, filters added at specific priorities may not be fully cleaned up. Always explicitly remove them. + +4. **Using `setUpBeforeClass` for data that should be per-test** — if tests modify the shared fixture, they'll interfere with each other. Use `setUp()` for anything tests might mutate. + +5. **Not storing the original value before overriding** — if you change an option or global, you need the original value to restore it. Save it at the top of `setUp()`. diff --git a/skills/slic/http-mocking.md b/skills/slic/http-mocking.md new file mode 100644 index 0000000..4781371 --- /dev/null +++ b/skills/slic/http-mocking.md @@ -0,0 +1,259 @@ +# HTTP Mocking + +WordPress plugins frequently make HTTP requests (API calls, license checks, remote data fetches). In integration tests you need to control these requests so tests are fast, deterministic, and don't depend on external services. + +This document covers three patterns for intercepting WordPress HTTP requests, from simplest to most flexible. + +## The slic testing environment + +By default, slic containers can reach external networks. If you need total network isolation, use the airplane-mode command: + +```bash +slic airplane-mode on # blocks all external HTTP from WordPress +slic airplane-mode off # restores normal networking +``` + +Regardless of the airplane-mode setting, you should mock HTTP in your tests to avoid depending on network availability or external API state. + +## Pattern A: Simple mock response + +Use the `pre_http_request` filter to short-circuit `wp_remote_get()`, `wp_remote_post()`, and friends. When this filter returns a non-false value, WordPress skips the actual HTTP request entirely. + +```php +class ApiClientTest extends \Codeception\TestCase\WPTestCase { + + private \Closure $mock_filter; + + protected function setUp(): void { + parent::setUp(); + + // Return a fake 200 response for all HTTP requests. + $this->mock_filter = static function ( $preempt, $parsed_args, $url ) { + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => wp_json_encode( [ + 'status' => 'active', + 'items' => [ 'one', 'two', 'three' ], + ] ), + 'headers' => [], + 'cookies' => [], + ]; + }; + + add_filter( 'pre_http_request', $this->mock_filter, 10, 3 ); + } + + protected function tearDown(): void { + remove_filter( 'pre_http_request', $this->mock_filter, 10 ); + + parent::tearDown(); + } + + public function test_it_should_parse_api_response(): void { + // Act — this calls wp_remote_get() internally. + $result = my_plugin_fetch_items(); + + // Assert. + $this->assertCount( 3, $result ); + $this->assertContains( 'one', $result ); + } +} +``` + +### Response array structure + +The mock response must match the structure WordPress expects from `WP_Http::request()`: + +```php +[ + 'response' => [ + 'code' => 200, // HTTP status code (int). + 'message' => 'OK', // HTTP reason phrase (string). + ], + 'body' => '...', // Response body (string). Use wp_json_encode() for JSON. + 'headers' => [], // Response headers (array or Requests_Utility_CaseInsensitiveDictionary). + 'cookies' => [], // Response cookies (array of WP_Http_Cookie). +] +``` + +**Common mistake**: Returning only the `body` without the `response` key — this causes `wp_remote_retrieve_response_code()` to return `''` instead of the expected status code. + +### URL-specific mocking + +To mock different responses for different URLs: + +```php +$this->mock_filter = static function ( $preempt, $parsed_args, $url ) { + if ( str_contains( $url, 'api.example.com/items' ) ) { + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => wp_json_encode( [ 'items' => [] ] ), + 'headers' => [], + 'cookies' => [], + ]; + } + + if ( str_contains( $url, 'api.example.com/auth' ) ) { + return [ + 'response' => [ 'code' => 401, 'message' => 'Unauthorized' ], + 'body' => wp_json_encode( [ 'error' => 'Invalid token' ] ), + 'headers' => [], + 'cookies' => [], + ]; + } + + // Return false to let other URLs through (or mock them too). + return false; +}; +``` + +## Pattern B: Request capture + +Sometimes you need to verify that your code sends the right HTTP request (correct URL, method, headers, body) rather than testing how it handles the response. Capture requests in an array and assert against them. + +```php +class WebhookSenderTest extends \Codeception\TestCase\WPTestCase { + + private array $captured_requests = []; + + private \Closure $capture_filter; + + protected function setUp(): void { + parent::setUp(); + + $this->captured_requests = []; + + $this->capture_filter = function ( $preempt, $parsed_args, $url ) { + $this->captured_requests[] = [ + 'url' => $url, + 'args' => $parsed_args, + ]; + + // Return a success response so the code continues normally. + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => '', + 'headers' => [], + 'cookies' => [], + ]; + }; + + add_filter( 'pre_http_request', $this->capture_filter, 10, 3 ); + } + + protected function tearDown(): void { + remove_filter( 'pre_http_request', $this->capture_filter, 10 ); + + parent::tearDown(); + } + + public function test_it_should_send_webhook_with_correct_payload(): void { + // Arrange. + $event_data = [ 'event' => 'order.completed', 'order_id' => 42 ]; + + // Act. + my_plugin_send_webhook( 'https://hooks.example.com/notify', $event_data ); + + // Assert — verify exactly one request was made. + $this->assertCount( 1, $this->captured_requests ); + + // Assert — verify the URL. + $this->assertSame( + 'https://hooks.example.com/notify', + $this->captured_requests[0]['url'] + ); + + // Assert — verify the body. + $sent_body = json_decode( $this->captured_requests[0]['args']['body'], true ); + $this->assertSame( 'order.completed', $sent_body['event'] ); + $this->assertSame( 42, $sent_body['order_id'] ); + + // Assert — verify it was a POST. + $this->assertSame( 'POST', $this->captured_requests[0]['args']['method'] ); + } +} +``` + +## Pattern C: Response queue + +When the code under test makes multiple sequential HTTP requests and you need each to return a different response, use a queue: + +```php +class PaginatedFetcherTest extends \Codeception\TestCase\WPTestCase { + + private array $response_queue = []; + + private \Closure $queue_filter; + + protected function setUp(): void { + parent::setUp(); + + $this->queue_filter = function ( $preempt, $parsed_args, $url ) { + if ( empty( $this->response_queue ) ) { + $this->fail( 'Unexpected HTTP request: no more queued responses.' ); + } + + return array_shift( $this->response_queue ); + }; + + add_filter( 'pre_http_request', $this->queue_filter, 10, 3 ); + } + + protected function tearDown(): void { + remove_filter( 'pre_http_request', $this->queue_filter, 10 ); + + parent::tearDown(); + } + + public function test_it_should_fetch_all_pages(): void { + // Arrange — queue two pages of results. + $this->response_queue = [ + // Page 1. + [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => wp_json_encode( [ + 'items' => [ 'a', 'b' ], + 'has_more' => true, + ] ), + 'headers' => [], + 'cookies' => [], + ], + // Page 2 (final page). + [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => wp_json_encode( [ + 'items' => [ 'c' ], + 'has_more' => false, + ] ), + 'headers' => [], + 'cookies' => [], + ], + ]; + + // Act. + $all_items = my_plugin_fetch_all_items(); + + // Assert. + $this->assertSame( [ 'a', 'b', 'c' ], $all_items ); + $this->assertEmpty( $this->response_queue, 'All queued responses should have been consumed.' ); + } +} +``` + +## Cleanup rules + +1. **Always remove your filter in `tearDown()`** — store the callback reference in a property so you can pass the exact same callable to `remove_filter()`. + +2. **Match the priority** — `remove_filter( 'pre_http_request', $this->mock_filter, 10 )` must use the same priority (here `10`) that was used in `add_filter()`. + +3. **Reset captured data** — if you capture requests in an array property, reset it to `[]` in `setUp()` to prevent data leaking from a prior test method. + +## Common pitfalls + +- **Using an anonymous function without storing it** — you won't be able to remove it in `tearDown()`. Always assign the closure to a property. +- **Forgetting the `cookies` key** — some WordPress internals expect all four top-level keys. Missing `cookies` can cause notices. +- **Returning `true` instead of a response array** — `true` short-circuits the request but code calling `wp_remote_retrieve_body()` gets an empty string, which may cause confusing failures. +- **Filter priority conflicts** — if the plugin itself adds a `pre_http_request` filter, your test's filter priority matters. Use a lower number (higher priority) to intercept first, or a higher number to let the plugin's filter run and then override. diff --git a/skills/slic/references/slic-commands.md b/skills/slic/references/slic-commands.md new file mode 100644 index 0000000..fe02d45 --- /dev/null +++ b/skills/slic/references/slic-commands.md @@ -0,0 +1,413 @@ +# slic CLI Command Reference + +Complete reference for all slic commands. Run `slic help` to see the list, or `slic help` for detailed usage of any command. + +## Project selection + +### `slic here` + +Sets the directory where slic looks for plugins, themes, and WordPress. + +```bash +# From a plugins directory (e.g. wp-content/plugins): +cd /path/to/wp-content/plugins +slic here + +# From a WordPress root (where wp-config.php lives): +cd /path/to/wordpress +slic here + +# Reset to slic's default directories: +slic here reset +``` + +**Two modes:** + +- **Plugins directory mode** — run from a directory containing plugin folders. slic will test plugins found here. +- **WordPress root mode** — run from a WordPress installation root. slic can test plugins, themes, or the site itself. It detects WordPress by looking for `wp-config.php` or a `wp/` subdirectory. + +### `slic use ` + +Selects which plugin, theme, or site to run tests against. + +```bash +slic use the-events-calendar +slic use event-tickets/common # subdirectory target +slic use starter-theme # theme target (if slic here pointed at wp root) +``` + +The target must exist in the directory set by `slic here`. Use `slic using` to see the current target. + +### `slic using` + +Displays the currently selected target. + +```bash +slic using +# Output: Using the-events-calendar +``` + +### `slic init []` + +Initializes a plugin for slic-based testing. Creates configuration files and optionally runs dependency installs. + +```bash +slic init the-events-calendar +slic init event-tickets release/B20.04 # checkout a specific branch +``` + +**Generated files:** + +| File | Purpose | +|------|---------| +| `.env.testing.slic` | Database credentials, WordPress URL, container paths | +| `codeception.slic.yml` | Loads `.env.testing.slic` as Codeception params | +| `test-config.slic.php` | Optional WPLoader custom config (only created if needed) | + +After generating files, slic prompts to run `composer install` and `npm install`. + +## Testing + +### `slic run [suite] [file_path_no_ext]::[method]` + +Runs Codeception tests. Wraps `vendor/bin/codecept run`. + +```bash +slic run # all suites, sequentially +slic run wpunit # one suite +slic run tests/wpunit/FooTest.php # one file +slic run tests/wpunit/FooTest::test_bar # one method +slic run wpunit -- --debug # pass flags to codecept +``` + +**Codeception config precedence** (cascading, later overrides earlier): + +1. `codeception.dist.yml` (if exists) +2. `codeception.yml` (if exists) +3. `codeception.slic.yml` (if exists, loaded via `-c`) +4. `codeception.tric.yml` (backwards compatibility, only if no `.slic.yml`) + +### `slic cc ` + +Runs any Codeception command (not just `run`). Wraps `vendor/bin/codecept`. + +```bash +slic cc generate:wpunit wpunit "FooTest" +slic cc generate:wpunit wpunit "Admin/SettingsTest" +slic cc run wpunit --coverage +slic cc clean +slic cc build +``` + +### `slic shell []` + +Opens an interactive shell in a container. Default service is `slic` (the test runner). + +```bash +slic shell # slic container (Codeception runner) +slic shell wordpress # WordPress container +slic shell db # Database container +``` + +Inside the slic shell, shorthand commands are available: + +| Shell command | Equivalent to | +|--------------|---------------| +| `cr ` | `codecept run ` | +| `xon` | Enable Xdebug (immediate, no restart) | +| `xoff` | Disable Xdebug (immediate, no restart) | + +**Alias:** `slic ssh` is equivalent to `slic shell`. + +### `slic playwright ` + +Runs Playwright commands in the stack for browser-based testing. + +> **Note:** Available since slic 2.x. Requires Playwright to be installed in the target project (`slic playwright install`). + +```bash +slic playwright install # install Playwright + Chromium +slic playwright test # run all Playwright tests +slic playwright test tests/e2e/my-test.spec.ts # run a specific test file +``` + +## Stack management + +### `slic start` / `slic up` + +Starts the Docker containers (db, redis, wordpress, chrome, slic). + +```bash +slic start +slic up # alias +``` + +### `slic stop` / `slic down` + +Stops and removes the Docker containers. + +```bash +slic stop +slic down # alias +``` + +### `slic restart` + +Restarts the stack containers. + +```bash +slic restart +``` + +### `slic ps` + +Lists running containers in the slic stack. + +```bash +slic ps +``` + +### `slic logs` + +Displays container logs. + +```bash +slic logs +``` + +### `slic build-stack` + +Rebuilds Docker images for the stack. + +```bash +slic build-stack +slic build-stack slic # rebuild specific service +``` + +## PHP version management + +### `slic php-version` + +Shows or sets the PHP version used by the stack. + +```bash +slic php-version # show current version +slic php-version set 8.1 # set PHP 8.1 (prompts to rebuild) +slic php-version set 8.4 --skip-rebuild # stage for next `slic use` +slic php-version reset # reset to default (7.4) +``` + +**Version auto-detection priority:** + +1. `SLIC_PHP_VERSION` environment variable override +2. Staged version (from `--skip-rebuild`) +3. Project's `.env.slic.local` file +4. `slic.json` → `phpVersion` field +5. `composer.json` → `config.platform.php` field + +## Development tools + +### `slic composer ` + +Runs Composer inside the container. + +```bash +slic composer install +slic composer update +slic composer require vendor/package +slic composer set-version 2 # switch to Composer v2 +slic composer get-version # show Composer version +``` + +### `slic composer-cache` + +Sets or shows the composer cache directory mapping. + +```bash +slic composer-cache set $HOME/.cache/composer +slic composer-cache show +``` + +### `slic npm ` + +Runs npm inside the container, using the Node version from `.nvmrc`. + +```bash +slic npm install +slic npm run build +``` + +### `slic wp ` / `slic cli ` + +Runs wp-cli commands in the WordPress container. + +```bash +slic wp plugin list +slic wp option get blogname +slic wp user create testuser test@example.com --role=editor +``` + +`slic wp` is an alias for `slic cli`. + +### `slic phpcs` / `slic phpcbf` + +Runs PHP_CodeSniffer or Code Beautifier and Fixer on the current target. + +```bash +slic phpcs +slic phpcbf +``` + +### `slic mysql` + +Opens a MySQL shell connected to the test database. + +```bash +slic mysql +``` + +### `slic exec ` + +Runs an arbitrary bash command in the slic container. + +```bash +slic exec "php -v" +slic exec "ls -la /var/www/html" +``` + +## Debug and configuration + +### `slic xdebug` + +Manages Xdebug in the stack. + +```bash +slic xdebug on # enable (requires restart) +slic xdebug off # disable (requires restart) +slic xdebug status # show current state +slic xdebug host # set host IP for IDE connection +slic xdebug port # set port (default 9001) +slic xdebug key # set IDE key (default slic) +``` + +### `slic debug` + +Toggles debug output for slic commands. + +```bash +slic debug on +slic debug off +slic debug status +``` + +### `slic interactive` + +Toggles interactive mode. Disable for CI environments. + +```bash +slic interactive on +slic interactive off +slic interactive status +``` + +### `slic cache` + +Toggles WordPress object cache support. + +```bash +slic cache on +slic cache off +slic cache status +``` + +### `slic airplane-mode` + +Toggles the airplane-mode plugin to block external HTTP requests from WordPress. + +```bash +slic airplane-mode on +slic airplane-mode off +``` + +### `slic config` + +Prints the Docker Compose configuration with interpolated environment variables. + +```bash +slic config +``` + +### `slic info` + +Displays information about the slic installation. + +```bash +slic info +``` + +## Utilities + +### `slic target ` + +Runs commands against multiple targets. + +```bash +slic target run wpunit +``` + +### `slic group` + +Creates or removes groups of targets for batch operations. + +```bash +slic group +``` + +### `slic update` + +Updates slic and its Docker images. + +```bash +slic update +``` + +### `slic upgrade` + +Upgrades the slic repository itself (git pull). + +```bash +slic upgrade +``` + +### `slic update-dump` + +Updates a SQL dump file for acceptance testing by importing, upgrading, and re-exporting. + +```bash +slic update-dump +``` + +### `slic reset` + +Resets slic to its initial state as configured by the env files. + +```bash +slic reset +``` + +### `slic host-ip` + +Returns the IP address of the host machine from the container's perspective. + +```bash +slic host-ip +``` + +### `slic dc ` + +Runs a raw `docker compose` command in the slic stack context. + +```bash +slic dc ps +slic dc exec slic bash +``` diff --git a/skills/slic/references/slic-setup.md b/skills/slic/references/slic-setup.md new file mode 100644 index 0000000..47c69e2 --- /dev/null +++ b/skills/slic/references/slic-setup.md @@ -0,0 +1,302 @@ +# slic Setup and Configuration + +This document covers installing slic, configuring projects for testing, environment file management, and CI usage. + +## Installation + +### Prerequisites + +- **Docker** — the only hard requirement. Install and ensure the Docker daemon is running. +- **PHP 7.4+** — required on the host machine to run the slic CLI itself. + +### Clone and add to PATH + +```bash +# Clone the repository (example: into ~/projects). +git clone git@github.com:stellarwp/slic.git ~/projects/slic + +# Add to PATH — bash: +echo 'export PATH="$HOME/projects/slic:$PATH"' >> ~/.bashrc +source ~/.bashrc + +# Add to PATH — zsh: +echo 'export PATH="$HOME/projects/slic:$PATH"' >> ~/.zshrc +source ~/.zshrc +``` + +Verify the installation: + +```bash +slic info +slic help +``` + +## First-time workflow + +### Step 1: Set the working directory + +```bash +# Option A — point at a plugins directory: +cd /path/to/wp-content/plugins +slic here + +# Option B — point at a WordPress root: +cd /path/to/wordpress +slic here +``` + +**How slic detects the mode:** + +- If `wp-config.php` or a `wp/` subdirectory exists at the current path, slic enters **WordPress root mode**. It will look for plugins in `wp-content/plugins/` and themes in `wp-content/themes/` relative to the root. +- Otherwise, slic enters **plugins directory mode**. It treats the current directory as a plugins directory and uses its own internal WordPress installation. + +Running `slic here reset` restores the default directories inside the slic installation. + +### Step 2: Select a target + +```bash +slic use my-plugin + +# With a subdirectory (e.g., a package inside a plugin): +slic use event-tickets/common +``` + +This stores the target in `.env.slic.run` as the `SLIC_CURRENT_PROJECT` variable. + +### Step 3: Initialize the target + +```bash +slic init my-plugin +``` + +This generates three files in the plugin root: + +#### `.env.testing.slic` + +Contains environment variables for the slic Docker stack: + +```env +WP_ROOT_FOLDER=/var/www/html +WP_URL=http://wordpress.test +WP_DOMAIN=wordpress.test +DB_HOST=db +DB_NAME=test +DB_PASSWORD=password +DB_PORT=3306 +TEST_TABLE_PREFIX=wp_ +CHROMEDRIVER_HOST=chrome +USING_CONTAINERS=1 +``` + +slic generates this by reading your existing `.env.testing`, `.env`, or `.env.dist` file and replacing database/URL values with the Docker container values. + +#### `codeception.slic.yml` + +A minimal Codeception configuration that loads the slic env file: + +```yaml +params: + - .env.testing.slic +``` + +This is loaded via the `-c` flag on top of the project's existing `codeception.dist.yml` or `codeception.yml`. + +#### `test-config.slic.php` (optional) + +Created only if custom WPLoader configuration is needed. Contains PHP defines or setup code loaded by the WPLoader module's `configFile` option. + +### Step 4: Install dependencies + +After `slic init`, slic prompts to run `composer install` and `npm install` inside the container: + +```bash +slic composer install +slic npm install +``` + +### Step 5: Run tests + +```bash +slic run wpunit +``` + +## Codeception configuration precedence + +When `slic run` executes, Codeception loads configuration files in this order (later files override earlier ones): + +1. **`codeception.dist.yml`** — the project's distributed configuration (committed to version control). +2. **`codeception.yml`** — local overrides (often gitignored). +3. **`codeception.slic.yml`** — slic-specific overrides, loaded via the `-c` flag. This is what `slic init` generates. + +For backwards compatibility, if `codeception.slic.yml` does not exist but `codeception.tric.yml` does, slic uses the tric version instead. + +The cascading behavior means your `codeception.dist.yml` defines the base configuration (suites, modules, paths), and `codeception.slic.yml` only overrides the parameters needed for the Docker environment (database host, WordPress URL, etc.). + +## Environment file cascade + +slic loads environment files in this order (later files override earlier ones): + +| Order | File | Location | Purpose | +|-------|------|----------|---------| +| 1 | `.env.slic` | slic repo root | Default configuration (version-controlled, do not edit) | +| 2 | `.env.slic.local` | slic repo root | Machine-specific overrides for all projects | +| 3 | `.env.slic.local` | Target plugin/theme root | Project-specific overrides | +| 4 | `.env.slic.run` | slic repo root | Runtime state (set by slic commands, auto-generated) | + +### Key environment variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `SLIC_PHP_VERSION` | `7.4` | PHP version for containers | +| `SLIC_CURRENT_PROJECT` | (none) | Currently selected target | +| `MYSQL_ROOT_PASSWORD` | `password` | Database root password | +| `WORDPRESS_HTTP_PORT` | `8888` | WordPress HTTP port on localhost | +| `SLIC_GIT_HANDLE` | (none) | GitHub handle for cloning plugins | +| `XDEBUG_DISABLE` | `0` | Set to `1` to disable Xdebug extension | +| `SLIC_WP_DIR` | slic's `_wordpress/` | Path to WordPress installation | +| `SLIC_PLUGINS_DIR` | slic's `_plugins/` | Path to plugins directory | + +## Container paths + +Inside the Docker containers, paths are mapped as follows: + +| Host path | Container path | +|-----------|---------------| +| WordPress root | `/var/www/html` | +| Plugins directory | `/var/www/html/wp-content/plugins` | +| Themes directory | `/var/www/html/wp-content/themes` | +| MU-plugins directory | `/var/www/html/wp-content/mu-plugins` | + +## Database connection + +Tests connect to the database with these defaults: + +| Setting | Value | +|---------|-------| +| Host | `db` | +| Port | `3306` | +| Database name | `test` | +| User | `root` | +| Password | `password` (from `MYSQL_ROOT_PASSWORD`) | +| Table prefix | `wp_` | + +These values are set in `.env.testing.slic` and injected into Codeception via the `params` key in `codeception.slic.yml`. + +## Project defaults with `slic.json` + +A `slic.json` file in the project root lets you define default settings that apply when `slic use` targets the project: + +```json +{ + "phpVersion": "8.1" +} +``` + +When `slic use my-plugin` runs and finds this file, it automatically switches the PHP version to 8.1 (prompting for a stack rebuild if needed). + +**Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `phpVersion` | string | PHP version to use (e.g., `"8.1"`, `"8.4"`) | + +## CI configuration + +### Disabling interactivity + +slic prompts for confirmations in interactive mode. For CI, disable this: + +```bash +slic interactive off +``` + +Or set the environment variable: + +```bash +export SLIC_INTERACTIVE=0 +``` + +### GitHub Actions example + +This example assumes the project already has `codeception.slic.yml` and `.env.testing.slic` committed (generated by `slic init` during local development). CI only needs to install dependencies and run tests. + +```yaml +name: Tests +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.4', '8.1', '8.4'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + + - name: Clone slic + run: git clone https://github.com/stellarwp/slic.git ~/slic + + - name: Add slic to PATH + run: echo "$HOME/slic" >> $GITHUB_PATH + + - name: Set up slic + run: | + slic here + slic interactive off + slic php-version set ${{ matrix.php }} --skip-rebuild + slic use ${{ github.event.repository.name }} + slic start + + - name: Install dependencies + run: slic composer install + + - name: Run tests + run: slic run wpunit +``` + +### Composer authentication in CI + +For private packages, set the `COMPOSER_AUTH` environment variable: + +```yaml +env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_BOT_TOKEN }}"}}' +``` + +## Database dumps (`dump.sql`) + +Some test suites (particularly acceptance tests) use a `dump.sql` file to initialize the database with a known state before tests run. This file is typically located at `tests/_data/dump.sql`. + +### How it works + +The WPDb module (used by acceptance/functional suites) imports `dump.sql` before each test to reset the database to a clean baseline state. The WPLoader module (used by wpunit suites) does not use `dump.sql` — it bootstraps WordPress directly. + +### Best practices + +1. **Keep the dump minimal** — it should contain only the bare WordPress installation data (default tables, default options, admin user). Do not pre-seed test-specific data in the dump. + +2. **Tests should create their own data** — each test is responsible for arranging the data it needs using factories (`$this->factory()->post->create()`) or direct API calls. This makes tests self-contained and easy to understand. + +3. **Never commit data that tests rely on** — if a test needs a specific post, user, or option, create it in `setUp()` or in the test method itself. Data baked into `dump.sql` creates hidden dependencies that break when the dump is regenerated. + +4. **Regenerate with `slic update-dump`** — when your WordPress version changes or you need a fresh baseline: + ```bash + slic update-dump + ``` + This imports the current dump, runs WordPress upgrades, and re-exports a clean version. + +5. **Watch for collation issues** — when upgrading MySQL/MariaDB versions, you may need to update collations in the dump file. See the slic [Update Guide](https://github.com/stellarwp/slic#from-10-to-20) for details. + +## Updating slic + +```bash +slic update # updates slic and Docker images +slic upgrade # git pulls the latest slic code +``` diff --git a/skills/slic/references/wp-browser-wploader.md b/skills/slic/references/wp-browser-wploader.md new file mode 100644 index 0000000..66c7fb2 --- /dev/null +++ b/skills/slic/references/wp-browser-wploader.md @@ -0,0 +1,344 @@ +# wp-browser and WPLoader Reference + +This document covers the WPLoader Codeception module, the WPTestCase base class, and the suite types available when testing WordPress with wp-browser and slic. + +## What is wp-browser? + +[wp-browser](https://wpbrowser.wptestkit.dev/) is a Codeception module that integrates WordPress testing into the Codeception framework. It provides: + +- **WPLoader** — bootstraps a full WordPress installation inside the test process. +- **WPTestCase** — base test class with WordPress-specific utilities (factories, assertions, transaction isolation). +- **Additional modules** — WPDb, WPWebDriver, WPFilesystem, etc. + +## Suite types + +Codeception organizes tests into suites. Each suite has its own configuration and module set. The most common suites for WordPress plugins: + +### wpunit (integration tests) + +**WordPress is fully loaded.** Tests run inside a database transaction that rolls back after each test. + +```yaml +# codeception.dist.yml +suites: + wpunit: + actor: WpunitTester + path: wpunit + modules: + enabled: + - WPLoader +``` + +Use for: testing plugin code that interacts with WordPress APIs (posts, users, options, hooks, REST endpoints, database). + +### unit (pure PHP tests) + +**WordPress is NOT loaded.** Tests run in isolation with no database, no WordPress functions. + +```yaml +suites: + unit: + actor: UnitTester + path: unit +``` + +Use for: testing pure PHP classes, utilities, data transformations, algorithms — anything that doesn't depend on WordPress. + +### functional (WordPress loaded, HTTP simulation) + +WordPress is loaded. Tests simulate HTTP requests through WordPress using the `WPBrowser` module. + +Use for: testing page rendering, form submissions, redirects — behavior that depends on the WordPress request lifecycle. + +### acceptance (real browser) + +Uses a real browser (via Chrome/Selenium) to test the application. The slic stack includes a Chrome service for this. + +Use for: end-to-end testing of admin screens, JavaScript interactions, visual verification. + +**For most plugin development, `wpunit` is the primary suite.** + +## WPLoader module configuration + +The WPLoader module bootstraps WordPress inside the test process. Configuration lives in `codeception.dist.yml` (or `codeception.yml`), with slic overrides in `codeception.slic.yml`. + +### Key configuration options + +```yaml +modules: + enabled: + - WPLoader + config: + WPLoader: + wpRootFolder: "%WP_ROOT_FOLDER%" + dbName: "%DB_NAME%" + dbHost: "%DB_HOST%" + dbUser: root + dbPassword: "%DB_PASSWORD%" + tablePrefix: "%TEST_TABLE_PREFIX%" + domain: "%WP_DOMAIN%" + adminEmail: "admin@wordpress.test" + plugins: + - my-plugin/my-plugin.php + activatePlugins: + - my-plugin/my-plugin.php + configFile: test-config.slic.php +``` + +### Option reference + +| Option | Type | Description | +|--------|------|-------------| +| `wpRootFolder` | string | Path to WordPress root in the container (`/var/www/html`) | +| `dbName` | string | Test database name (`test`) | +| `dbHost` | string | Database host (`db` in slic) | +| `dbUser` | string | Database user (`root` in slic) | +| `dbPassword` | string | Database password (from `MYSQL_ROOT_PASSWORD`) | +| `tablePrefix` | string | WordPress table prefix (`wp_`). **Important:** Use different prefixes for each suite (e.g., `wp_` for wpunit, `acc_` for acceptance) to prevent table collisions when running suites in parallel or sequentially. | +| `domain` | string | WordPress domain (`wordpress.test`) | +| `adminEmail` | string | Admin email for the test installation | +| `plugins` | array | Plugin files to load (relative to plugins dir) | +| `activatePlugins` | array | Plugins to activate during bootstrap | +| `configFile` | string | Path to a custom PHP config file loaded during bootstrap | +| `theme` | string | Theme to activate (slug) | +| `multisite` | bool | Whether to run in multisite mode (default: `false`) | +| `dbCharset` | string | Database charset (default: `utf8`) | +| `dbCollate` | string | Database collation | +| `wpDebug` | bool | Enable `WP_DEBUG` (default: `true`) | +| `language` | string | WordPress locale | + +### How slic overrides work + +The `codeception.slic.yml` file generated by `slic init` uses `params` to load environment variables from `.env.testing.slic`: + +```yaml +# codeception.slic.yml +params: + - .env.testing.slic +``` + +The `%VARIABLE%` placeholders in `codeception.dist.yml` are replaced with values from `.env.testing.slic`. This is how slic injects Docker-specific values (database host, WordPress URL, etc.) without modifying the project's main Codeception configuration. + +**This means:** + +- `codeception.dist.yml` uses placeholders: `dbHost: "%DB_HOST%"` +- `.env.testing.slic` defines: `DB_HOST=db` +- At runtime, `dbHost` resolves to `db` + +## WPTestCase API + +`WPTestCase` (or its v4 equivalent) is the base class for wpunit tests. It extends PHPUnit's `TestCase` and adds WordPress-specific functionality. + +### Import paths + +```php +// wp-browser v3 +use Codeception\TestCase\WPTestCase; + +// wp-browser v4 +use lucatume\WPBrowser\TestCase\WPTestCase; +``` + +Check your installed version: + +```bash +slic composer show lucatume/wp-browser | grep versions +``` + +### Lifecycle methods + +| Method | Visibility | When it runs | What it does | +|--------|-----------|-------------|--------------| +| `setUp(): void` | `protected` | Before each test method | Starts database transaction, resets global state, clears caches | +| `tearDown(): void` | `protected` | After each test method | Rolls back transaction, restores state | +| `setUpBeforeClass(): void` | `public static` | Before the first test in the class | One-time class-level setup (static) | +| `tearDownAfterClass(): void` | `public static` | After the last test in the class | One-time class-level cleanup (static) | + +**Critical rules:** + +- `parent::setUp()` must be the first call in your `setUp()`. +- `parent::tearDown()` must be the last call in your `tearDown()`. +- `parent::setUpBeforeClass()` must be the first call in your `setUpBeforeClass()`. +- `parent::tearDownAfterClass()` must be the last call in your `tearDownAfterClass()`. + +### Factory methods + +Access factories via `$this->factory()`: + +```php +$this->factory()->post->create( $args ); // returns int (ID) +$this->factory()->post->create_and_get( $args ); // returns WP_Post +$this->factory()->post->create_many( $count, $args ); // returns int[] + +$this->factory()->user->create( $args ); // returns int (ID) +$this->factory()->term->create( $args ); // returns int (ID) +$this->factory()->comment->create( $args ); // returns int (ID) +$this->factory()->attachment->create( $args ); // returns int (ID) +$this->factory()->category->create( $args ); // returns int (ID) +$this->factory()->tag->create( $args ); // returns int (ID) +``` + +Factory-created objects live inside the test's database transaction and are automatically removed when the transaction rolls back. + +### WordPress-specific assertions + +Available on `WPTestCase` (inherited from WordPress's `WP_UnitTestCase_Base`): + +| Method | Asserts | +|--------|---------| +| `assertWPError( $actual )` | `$actual` is a `WP_Error` instance | +| `assertNotWPError( $actual )` | `$actual` is NOT a `WP_Error` instance | +| `assertQueryTrue( ...$props )` | Given WP_Query conditional methods return true | +| `assertEqualFields( $object, $fields )` | Object properties match expected fields | +| `assertDiscardWhitespace( $expected, $actual )` | Strings match ignoring whitespace differences | +| `assertEqualSets( $expected, $actual )` | Arrays contain the same elements (order-independent) | +| `assertEqualSetsWithIndex( $expected, $actual )` | Associative arrays have same key-value pairs (order-independent) | +| `assertSameSets( $expected, $actual )` | Like `assertEqualSets` but with strict type comparison | +| `assertSameSetsWithIndex( $expected, $actual )` | Like `assertEqualSetsWithIndex` but with strict type comparison | + +### Utility methods + +| Method | Purpose | +|--------|---------| +| `set_current_screen( $screen )` | Set the current admin screen | +| `go_to( $url )` | Simulate navigating to a URL (sets up query vars, loads template) | +| `set_permalink_structure( $structure )` | Set the permalink structure (e.g., `'/%postname%/'`) | + +### PHPUnit assertions (commonly used) + +These are standard PHPUnit assertions, available on all test cases: + +| Method | Purpose | +|--------|---------| +| `assertSame( $expected, $actual )` | Strict equality (type + value) | +| `assertEquals( $expected, $actual )` | Loose equality | +| `assertTrue( $condition )` | Value is `true` | +| `assertFalse( $condition )` | Value is `false` | +| `assertNull( $actual )` | Value is `null` | +| `assertNotNull( $actual )` | Value is not `null` | +| `assertEmpty( $actual )` | Value is empty | +| `assertNotEmpty( $actual )` | Value is not empty | +| `assertCount( $expected, $haystack )` | Array/Countable has expected count | +| `assertContains( $needle, $haystack )` | Array contains value | +| `assertArrayHasKey( $key, $array )` | Array has key | +| `assertIsArray( $actual )` | Value is an array | +| `assertIsString( $actual )` | Value is a string | +| `assertStringContainsString( $needle, $haystack )` | String contains substring | +| `assertStringNotContainsString( $needle, $haystack )` | String does not contain substring | +| `assertMatchesRegularExpression( $pattern, $string )` | String matches regex | +| `assertInstanceOf( $expected, $actual )` | Object is instance of class | +| `expectException( $class )` | Next code should throw this exception | +| `expectExceptionMessage( $message )` | Exception should have this message | + +## wp-browser v3 vs v4 differences + +### Namespace change + +The most visible change in wp-browser v4 is the namespace: + +```php +// v3 +use Codeception\TestCase\WPTestCase; + +// v4 +use lucatume\WPBrowser\TestCase\WPTestCase; +``` + +### Configuration changes in v4 + +wp-browser v4 uses a different module naming scheme: + +```yaml +# v3 +modules: + enabled: + - WPLoader + +# v4 +modules: + enabled: + - "lucatume\\WPBrowser\\Module\\WPLoader" +``` + +However, most projects using `codeception.dist.yml` with v4 will have this configured correctly already. + +### Factory access in v4 + +The factory API remains the same in v4: + +```php +// Same in both v3 and v4: +$this->factory()->post->create( [ ... ] ); +``` + +### Migration notes + +If you're migrating from v3 to v4: + +1. Update the `use` statements in all test files. +2. Update module names in `codeception.dist.yml` if using fully qualified names. +3. Run `slic cc build` to regenerate Codeception support files. +4. Run your test suite to verify everything works. + +Most StellarWP projects currently use wp-browser v3. Check `composer.json` or `composer.lock` for the installed version. + +## Codeception annotations + +These annotations can be added to test methods or classes: + +| Annotation | Purpose | Example | +|-----------|---------|---------| +| `@test` | Marks a method as a test (alternative to `test_` prefix) | `/** @test */` | +| `@dataProvider method` | Runs the test with data from the provider method | `/** @dataProvider status_provider */` | +| `@depends other_test` | Test runs only after the dependency passes | `/** @depends test_create */` | +| `@group name` | Assigns the test to a group for selective running | `/** @group slow */` | +| `@covers ClassName::method` | Documents which code the test covers | `/** @covers Foo::bar */` | + +### Running by group + +```bash +slic run wpunit --group=slow +slic run wpunit --exclude-group=slow +``` + +## Codeception configuration example + +A typical `codeception.dist.yml` for a WordPress plugin: + +```yaml +actor_suffix: Tester +paths: + tests: tests + output: tests/_output + data: tests/_data + support: tests/_support + envs: tests/_envs +params: + - .env.testing +settings: + colors: true + memory_limit: 1024M +suites: + wpunit: + actor: WpunitTester + path: wpunit + modules: + enabled: + - WPLoader + config: + WPLoader: + wpRootFolder: "%WP_ROOT_FOLDER%" + dbName: "%TEST_SITE_DB_NAME%" + dbHost: "%TEST_SITE_DB_HOST%" + dbUser: root + dbPassword: "%TEST_SITE_DB_PASSWORD%" + tablePrefix: "%TEST_TABLE_PREFIX%" + domain: "%WP_DOMAIN%" + adminEmail: "admin@wordpress.test" + plugins: + - my-plugin/my-plugin.php + activatePlugins: + - my-plugin/my-plugin.php +``` + +When slic runs tests, `codeception.slic.yml` adds `params: [.env.testing.slic]`, which provides the Docker-specific values for all the `%PLACEHOLDER%` variables. diff --git a/skills/slic/test-anatomy.md b/skills/slic/test-anatomy.md new file mode 100644 index 0000000..c8799ef --- /dev/null +++ b/skills/slic/test-anatomy.md @@ -0,0 +1,247 @@ +# Test Anatomy + +This document covers the structure, naming conventions, and skeleton for WPUnit integration tests used with slic and Codeception/wp-browser. + +## File location and naming + +Test files live inside the suite directory configured in your `codeception.dist.yml` or `codeception.yml`. The most common suite for integration tests is `wpunit`. + +``` +tests/ +└── wpunit/ + ├── SomeFeatureTest.php + ├── AnotherFeatureTest.php + └── SubNamespace/ + └── DeeperTest.php +``` + +**Naming rules:** + +- File name: `Test.php` — always ends with `Test`. +- Class name: matches the file name exactly (PSR-4 autoloading). +- One test class per file. + +## Namespace conventions + +Namespaces typically mirror the directory structure under the suite root. Common patterns: + +```php +// tests/wpunit/SomeFeatureTest.php +namespace Starter_Plugin\Tests\WPUnit; + +// tests/wpunit/Admin/SettingsTest.php +namespace Starter_Plugin\Tests\WPUnit\Admin; +``` + +Check your project's `codeception.dist.yml` for the `namespace` key — it sets the root namespace for generated tests. + +## Complete test skeleton + +```php +post_id = $this->factory()->post->create( [ + 'post_title' => 'Test Post', + 'post_status' => 'publish', + ] ); + } + + /** + * Runs after every test method. + */ + protected function tearDown(): void { + // Clean up anything not handled by factories. + + parent::tearDown(); + } + + public function test_it_should_return_the_post_title(): void { + // Act. + $title = get_the_title( $this->post_id ); + + // Assert. + $this->assertSame( 'Test Post', $title ); + } + + public function test_it_should_have_publish_status(): void { + // Act. + $post = get_post( $this->post_id ); + + // Assert. + $this->assertSame( 'publish', $post->post_status ); + } +} +``` + +### Key points + +- **`parent::setUp()` is always the first call** in `setUp()`. +- **`parent::tearDown()` is always the last call** in `tearDown()`. +- The parent class handles database transaction rollback, so factory-created data is automatically cleaned up between tests. + +## The AAA pattern + +Every test method should follow Arrange, Act, Assert: + +```php +public function test_discount_is_applied(): void { + // Arrange — set up the conditions. + $product_id = $this->factory()->post->create( [ + 'post_type' => 'product', + ] ); + update_post_meta( $product_id, '_price', '100' ); + update_post_meta( $product_id, '_discount', '10' ); + + // Act — execute the behavior being tested. + $final_price = my_plugin_get_final_price( $product_id ); + + // Assert — verify the outcome. + $this->assertEquals( 90, $final_price ); +} +``` + +Keep each section visually separated with a comment. If Arrange is complex, consider moving it to `setUp()` or a private helper method. + +## Test method naming + +Two styles are accepted. Pick one and stay consistent within a file: + +### Style 1: `test_` prefix (preferred) + +```php +public function test_it_should_create_a_user(): void { + // ... +} +``` + +### Style 2: `@test` annotation + +```php +/** + * @test + */ +public function it_should_create_a_user(): void { + // ... +} +``` + +Both are equivalent to Codeception/PHPUnit. The `test_` prefix is preferred — it's less noise and avoids needing a docblock just for the annotation. + +## Data providers + +Use data providers to run the same test logic with multiple inputs: + +```php +/** + * @dataProvider status_provider + */ +public function test_it_should_accept_valid_statuses( string $status, bool $expected ): void { + $result = my_plugin_is_valid_status( $status ); + + $this->assertSame( $expected, $result ); +} + +/** + * Data provider for valid statuses. + * + * @return array + */ +public function status_provider(): array { + return [ + 'publish is valid' => [ 'publish', true ], + 'draft is valid' => [ 'draft', true ], + 'invalid is invalid' => [ 'banana', false ], + ]; +} +``` + +**Rules for data providers:** + +- Method must be `public` and return an `array` (or `Generator`). +- Use descriptive string keys — they appear in test output on failure. +- The provider method name goes in the `@dataProvider` annotation. + +## Setting up composer.json test namespaces + +Tests should follow PSR-4 autoloading, configured in `composer.json`. Each suite gets its own namespace: + +```json +{ + "autoload-dev": { + "psr-4": { + "My_Plugin\\Tests\\Unit\\": "tests/unit/", + "My_Plugin\\Tests\\WPUnit\\": "tests/wpunit/", + "My_Plugin\\Tests\\Functional\\": "tests/functional/", + "My_Plugin\\Tests\\Acceptance\\": "tests/acceptance/" + } + } +} +``` + +**Key rules:** + +- Each suite directory maps to its own namespace root. +- Test file namespaces must match the directory structure under the suite root (PSR-4). +- Run `slic composer dump-autoload` after modifying `autoload-dev` to regenerate the autoloader. + +**Example mapping:** + +| File path | Namespace | +|-----------|-----------| +| `tests/wpunit/FooTest.php` | `My_Plugin\Tests\WPUnit` | +| `tests/wpunit/Admin/SettingsTest.php` | `My_Plugin\Tests\WPUnit\Admin` | +| `tests/unit/HelperTest.php` | `My_Plugin\Tests\Unit` | + +The namespace in `codeception.dist.yml` must match: + +```yaml +suites: + wpunit: + actor: WpunitTester + path: wpunit + namespace: My_Plugin\Tests\WPUnit + modules: + enabled: + - WPLoader +``` + +## Generating test files with slic + +slic wraps the Codeception `generate` commands: + +```bash +# Generate a WPUnit test class: +slic cc generate:wpunit wpunit "FooTest" + +# Generate inside a subdirectory: +slic cc generate:wpunit wpunit "Admin/SettingsTest" +``` + +This creates a skeleton file in the correct location with the proper namespace and base class. You can then fill in `setUp()`, `tearDown()`, and test methods. + +## Common mistakes to avoid + +1. **Forgetting `parent::setUp()`** — WordPress won't be in a clean state; tests will interfere with each other. +2. **Forgetting `parent::tearDown()`** — the database transaction won't roll back; leftover data leaks into subsequent tests. +3. **Hardcoding post/user IDs** — IDs are auto-incremented and differ between runs. Always use factory return values. +4. **Putting test logic in the constructor** — use `setUp()` instead. Constructors run at class load time, not per-test. +5. **Multiple assertions testing unrelated things** — each test method should verify one behavior. Split if needed. +6. **Missing `void` return type** — while not strictly required, adding `: void` to test methods follows modern PHP conventions and makes intent clear. diff --git a/skills/slic/test-isolation-checklist.md b/skills/slic/test-isolation-checklist.md new file mode 100644 index 0000000..f78c4c0 --- /dev/null +++ b/skills/slic/test-isolation-checklist.md @@ -0,0 +1,59 @@ +# Test Isolation Checklist + +Run through this checklist before committing new or modified tests. Each item prevents a class of flaky or order-dependent test failures. + +## Checklist + +1. **`parent::setUp()` is the first call in `setUp()`** — ensures WordPress starts in a clean, known state with a fresh database transaction. + +2. **`parent::tearDown()` is the last call in `tearDown()`** — ensures the database transaction rolls back after all your cleanup code has run. + +3. **Factory-created data is used for test objects** — `$this->factory()->post->create()` etc. lives inside the transaction and is automatically cleaned up. Avoid `wp_insert_post()` directly unless testing the insert function itself. + +4. **All added filters and actions are removed in `tearDown()`** — store callbacks in properties and call `remove_filter()` / `remove_action()` with the same priority. + +5. **Changed options are restored** — save the original value in `setUp()`, restore it in `tearDown()`. The transaction rollback covers `wp_options` table changes, but if the plugin caches option values in static properties, those need manual reset. + +6. **Transients are deleted if set** — `delete_transient()` in `tearDown()` for any transient your test creates. + +7. **Global variables are restored** — if you modify `$_GET`, `$_POST`, `$_SERVER`, or any plugin-specific global, save and restore the original value. + +8. **No reliance on test execution order** — each test must pass when run alone (`slic run tests/wpunit/YourTest::test_one_method`) and when run in any order within the suite. + +9. **No hardcoded IDs** — post IDs, user IDs, and term IDs are auto-incremented and differ between runs. Always use the return value of factory methods. + +10. **HTTP mock filters are removed** — `pre_http_request` filters must be removed in `tearDown()` with the exact same callable and priority. + +11. **Static and singleton state is reset** — if any test modifies a static property or singleton instance, reset it in `tearDown()` (via a public reset method or Reflection as a last resort). + +## Quick reference + +| # | Item | Risk if skipped | How to fix | +|---|------|----------------|------------| +| 1 | `parent::setUp()` first | WordPress state is dirty; tests see leftover data | Move to first line of `setUp()` | +| 2 | `parent::tearDown()` last | Transaction rollback happens before cleanup; cleanup may error or be skipped | Move to last line of `tearDown()` | +| 3 | Use factories | Manually inserted data persists if transaction fails to roll back | Replace `wp_insert_post()` with `$this->factory()->post->create()` | +| 4 | Remove filters/actions | Filters leak into subsequent tests, causing phantom failures | Store callback in `$this->callback`, remove in `tearDown()` | +| 5 | Restore options | Tests that read the option later get unexpected values | Save original in `setUp()`, restore in `tearDown()` | +| 6 | Delete transients | Cached values leak across tests | `delete_transient( 'key' )` in `tearDown()` | +| 7 | Restore globals | Request superglobals affect routing, authentication, and plugin behavior | Save `$_GET` etc. in `setUp()`, restore in `tearDown()` | +| 8 | No order dependence | Tests pass in CI but fail locally (or vice versa) when execution order changes | Run each test in isolation to verify | +| 9 | No hardcoded IDs | Tests fail on fresh databases or after other tests create objects | Use factory return values: `$id = $this->factory()->post->create()` | +| 10 | Remove HTTP mocks | Subsequent tests get mocked responses instead of real ones (or vice versa) | `remove_filter( 'pre_http_request', $this->mock, 10 )` | +| 11 | Reset static state | Singleton or cached values leak between tests | Call `Class::reset()` or use Reflection to null out the property | + +## How to verify isolation + +Run the specific test file in isolation to confirm it passes independently: + +```bash +slic run tests/wpunit/YourTest.php +``` + +Then run the full suite to confirm no side effects: + +```bash +slic run wpunit +``` + +If a test passes alone but fails in the suite (or vice versa), it has an isolation problem — work through this checklist to find the leak.