WordPress Test Engineering: PHPUnit, Jest, E2E & Code Quality Pipelines
Quality assurance is what separates hobbyist code from enterprise software. This engineering guide details the full testing spectrum: setting up `WP_UnitTestCase` for backend logic, validating React components with Jest, conducting visual regression via Playwright, and enforcing strict coding standards with automated linting.
PHP Unit Testing
PHPUnit setup
PHPUnit is the standard testing framework for PHP; for WordPress plugins/themes, install via Composer and use the WP-CLI scaffold command to generate the test bootstrap and configuration that loads WordPress test libraries.
# Install PHPUnit composer require --dev phpunit/phpunit # Scaffold test files using WP-CLI wp scaffold plugin-tests my-plugin # This creates: # ├── tests/ # │ ├── bootstrap.php # Test bootstrap # │ └── test-sample.php # Sample test # ├── phpunit.xml.dist # PHPUnit config # └── bin/ # └── install-wp-tests.sh # DB setup script # Install test suite (creates test database) bash bin/install-wp-tests.sh wordpress_test root password localhost latest
<!-- phpunit.xml.dist --> <?xml version="1.0"?> <phpunit bootstrap="tests/bootstrap.php" colors="true" testdox="true"> <testsuites> <testsuite name="Plugin Tests"> <directory>tests/</directory> </testsuite> </testsuites> </phpunit>
WordPress test library
The WordPress test library provides a complete testing environment that loads WordPress core, resets the database between tests, and offers helper classes for creating posts, users, and terms; it's installed separately from WordPress core via the install script.
<?php // tests/bootstrap.php $_tests_dir = getenv('WP_TESTS_DIR') ?: '/tmp/wordpress-tests-lib'; // Load WordPress test functions require_once $_tests_dir . '/includes/functions.php'; // Load plugin before WordPress initializes tests_add_filter('muplugins_loaded', function() { require dirname(__DIR__) . '/my-plugin.php'; }); // Start WordPress testing environment require $_tests_dir . '/includes/bootstrap.php';
┌─────────────────────────────────────────────────────────┐
│ WordPress Test Library Structure │
├─────────────────────────────────────────────────────────┤
│ /tmp/wordpress-tests-lib/ │
│ ├── includes/ │
│ │ ├── bootstrap.php # Main bootstrap │
│ │ ├── functions.php # Test helper functions │
│ │ ├── factory.php # Object factories │
│ │ └── testcase.php # WP_UnitTestCase class │
│ └── data/ │
│ └── images/ # Test fixtures │
└─────────────────────────────────────────────────────────┘
WP_UnitTestCase
WP_UnitTestCase is the base test class for WordPress that extends PHPUnit's TestCase with WordPress-specific functionality including database transactions, factory methods, and automatic cleanup between tests.
<?php class Test_My_Plugin extends WP_UnitTestCase { private $plugin; public function set_up() { parent::set_up(); $this->plugin = new MyPlugin\Core\Plugin(); } public function tear_down() { // Cleanup runs automatically, but add custom cleanup here parent::tear_down(); } public function test_plugin_activates() { $this->assertTrue(class_exists('MyPlugin\Core\Plugin')); } public function test_hooks_registered() { $this->assertNotFalse( has_action('init', [$this->plugin, 'init']) ); } public function test_post_creation() { $post_id = $this->factory->post->create([ 'post_title' => 'Test Post', 'post_type' => 'my_custom_type', ]); $this->assertIsInt($post_id); $this->assertEquals('Test Post', get_the_title($post_id)); } }
Test fixtures
Test fixtures are pre-defined states or data sets used to establish a consistent test environment; in WordPress testing, fixtures include sample posts, users, terms, and options that tests can rely on without recreating them each time.
<?php class Test_With_Fixtures extends WP_UnitTestCase { protected static $admin_id; protected static $test_posts; // Runs ONCE before all tests in class public static function wpSetUpBeforeClass($factory) { self::$admin_id = $factory->user->create([ 'role' => 'administrator', ]); self::$test_posts = $factory->post->create_many(5, [ 'post_author' => self::$admin_id, 'post_status' => 'publish', ]); } // Runs ONCE after all tests in class public static function wpTearDownAfterClass() { wp_delete_user(self::$admin_id); foreach (self::$test_posts as $post_id) { wp_delete_post($post_id, true); } } public function test_posts_exist() { $this->assertCount(5, self::$test_posts); } public function test_admin_can_edit() { wp_set_current_user(self::$admin_id); $this->assertTrue( current_user_can('edit_post', self::$test_posts[0]) ); } }
Factory methods
Factory methods provide a fluent API for creating WordPress objects (posts, users, terms, comments) during tests; they handle all required fields automatically and return IDs or full objects, making test setup concise and readable.
<?php class Test_Factories extends WP_UnitTestCase { public function test_factory_examples() { // Create single objects $post_id = $this->factory->post->create(); $user_id = $this->factory->user->create(['role' => 'editor']); $term_id = $this->factory->term->create(['taxonomy' => 'category']); $comment_id = $this->factory->comment->create(['comment_post_ID' => $post_id]); // Create with specific data $page_id = $this->factory->post->create([ 'post_type' => 'page', 'post_title' => 'About Us', 'post_status' => 'publish', 'meta_input' => ['custom_field' => 'value'], ]); // Create multiple objects $post_ids = $this->factory->post->create_many(10); // Get full object instead of ID $post = $this->factory->post->create_and_get(); $this->assertInstanceOf(WP_Post::class, $post); // Create attachment $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/test-image.jpg' ); } }
Assertions
Assertions verify expected outcomes in tests; WordPress tests inherit PHPUnit assertions and add WordPress-specific ones for checking database queries, generated output, hooks, and conditions common to WordPress development.
<?php class Test_Assertions extends WP_UnitTestCase { public function test_assertion_examples() { // PHPUnit standard assertions $this->assertTrue(is_plugin_active('my-plugin/my-plugin.php')); $this->assertFalse(false); $this->assertEquals('expected', 'expected'); $this->assertSame(1, 1); // Strict type check $this->assertNull(null); $this->assertIsArray([]); $this->assertInstanceOf(WP_Post::class, get_post(1)); $this->assertContains('needle', ['needle', 'haystack']); $this->assertStringContainsString('Hello', 'Hello World'); $this->assertCount(3, [1, 2, 3]); $this->assertEmpty([]); $this->assertNotEmpty(['item']); // WordPress-specific assertions $this->assertQueryTrue('is_home', 'is_front_page'); // Exception testing $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid value'); throw new InvalidArgumentException('Invalid value'); } public function test_wp_error() { $result = wp_insert_post([]); // Missing required fields $this->assertWPError($result); } }
Mocking
Mocking replaces real objects or functions with controlled substitutes to isolate code under test; use PHPUnit's mock objects for classes and tools like Brain\Monkey or WP_Mock for WordPress functions, hooks, and global state.
composer require --dev brain/monkey
<?php use Brain\Monkey; use Brain\Monkey\Functions; class Test_With_Mocking extends WP_UnitTestCase { public function set_up() { parent::set_up(); Monkey\setUp(); } public function tear_down() { Monkey\tearDown(); parent::tear_down(); } public function test_mock_wp_function() { Functions\when('get_option') ->justReturn('mocked_value'); $this->assertEquals('mocked_value', get_option('any_key')); } public function test_mock_object() { // Create mock of class $api_mock = $this->createMock(ExternalAPI::class); $api_mock->expects($this->once()) ->method('fetch') ->with('endpoint') ->willReturn(['data' => 'mocked']); $service = new MyService($api_mock); $result = $service->getData('endpoint'); $this->assertEquals(['data' => 'mocked'], $result); } }
Test isolation
Test isolation ensures each test runs independently without affecting others; WordPress achieves this through database transactions that rollback after each test, and by resetting global state like $wp_query, hooks, and the current user.
<?php class Test_Isolation extends WP_UnitTestCase { public function test_one_creates_post() { // This post exists only for this test $post_id = $this->factory->post->create([ 'post_title' => 'Test Post One' ]); $this->assertNotEmpty($post_id); } public function test_two_no_posts_exist() { // Database was rolled back - no posts from test_one $posts = get_posts(['post_type' => 'post']); $this->assertEmpty($posts); } public function test_user_isolation() { // Set admin for this test wp_set_current_user(1); $this->assertEquals(1, get_current_user_id()); } public function test_user_reset() { // User automatically reset to 0 $this->assertEquals(0, get_current_user_id()); } public function test_hook_isolation() { add_filter('the_title', fn($t) => 'Modified: ' . $t); // Filter removed after test } }
┌─────────────────────────────────────────────────────────┐
│ Test Isolation Mechanism │
├─────────────────────────────────────────────────────────┤
│ 1. BEGIN TRANSACTION │
│ 2. setUp() - Prepare test environment │
│ 3. test_method() - Run test │
│ 4. tearDown() - Cleanup │
│ 5. ROLLBACK - Undo all database changes │
│ 6. Reset globals ($wp_query, etc.) │
└─────────────────────────────────────────────────────────┘
JavaScript Testing
Jest setup
Jest is the JavaScript testing framework used by WordPress, configured automatically when using @wordpress/scripts; it provides a fast, feature-rich environment with snapshot testing, mocking, and code coverage built-in.
# Jest included with @wordpress/scripts npm install --save-dev @wordpress/scripts # Run tests npm run test:unit # or npx wp-scripts test-unit-js
// src/utils/helpers.test.js import { formatPrice, validateEmail } from './helpers'; describe('formatPrice', () => { it('formats number as currency', () => { expect(formatPrice(1234.56)).toBe('$1,234.56'); }); it('handles zero', () => { expect(formatPrice(0)).toBe('$0.00'); }); }); describe('validateEmail', () => { it('validates correct email', () => { expect(validateEmail('test@example.com')).toBe(true); }); it('rejects invalid email', () => { expect(validateEmail('not-an-email')).toBe(false); }); });
{ "scripts": { "test:unit": "wp-scripts test-unit-js", "test:unit:watch": "wp-scripts test-unit-js --watch", "test:unit:coverage": "wp-scripts test-unit-js --coverage" } }
@wordpress/jest-preset-default
@wordpress/jest-preset-default provides pre-configured Jest settings optimized for WordPress development including transformers for JavaScript/JSX, CSS module handling, and WordPress package mocking—automatically included when using @wordpress/scripts.
// jest.config.js - Extending the preset const defaultConfig = require('@wordpress/scripts/config/jest-unit.config'); module.exports = { ...defaultConfig, // Custom settings setupFilesAfterEnv: [ '<rootDir>/tests/js/setup.js', ], moduleNameMapper: { ...defaultConfig.moduleNameMapper, '@components/(.*)': '<rootDir>/src/components/$1', }, collectCoverageFrom: [ 'src/**/*.js', '!src/**/*.test.js', ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, }, }, };
// tests/js/setup.js import '@testing-library/jest-dom'; // Global mocks global.wp = { element: require('@wordpress/element'), data: require('@wordpress/data'), };
Component testing
Component testing verifies React components render correctly and respond to user interactions; use @testing-library/react (included in WordPress) to test components the way users interact with them rather than testing implementation details.
import { render, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Button } from '../components/Button'; import { Counter } from '../components/Counter'; describe('Button Component', () => { it('renders with correct text', () => { render(<Button>Click Me</Button>); expect(screen.getByText('Click Me')).toBeInTheDocument(); }); it('calls onClick when clicked', async () => { const handleClick = jest.fn(); render(<Button onClick={handleClick}>Click</Button>); await userEvent.click(screen.getByText('Click')); expect(handleClick).toHaveBeenCalledTimes(1); }); it('can be disabled', () => { render(<Button disabled>Disabled</Button>); expect(screen.getByText('Disabled')).toBeDisabled(); }); }); describe('Counter Component', () => { it('increments count on button click', async () => { render(<Counter initialCount={0} />); expect(screen.getByText('Count: 0')).toBeInTheDocument(); await userEvent.click(screen.getByText('Increment')); expect(screen.getByText('Count: 1')).toBeInTheDocument(); }); });
Block testing
Block testing verifies Gutenberg block registration, edit components, save output, and attribute handling; test both the edit (editor) and save (frontend) functions to ensure blocks work correctly in both contexts.
import { registerBlockType, createBlock } from '@wordpress/blocks'; import { render, screen } from '@testing-library/react'; import { BlockEdit, blockSettings } from '../blocks/my-block'; // Register block for tests beforeAll(() => { registerBlockType('my-plugin/my-block', blockSettings); }); describe('My Block', () => { it('registers correctly', () => { const block = createBlock('my-plugin/my-block'); expect(block.name).toBe('my-plugin/my-block'); }); it('has correct default attributes', () => { const block = createBlock('my-plugin/my-block'); expect(block.attributes.title).toBe(''); expect(block.attributes.showDate).toBe(true); }); it('renders edit component', () => { const attributes = { title: 'Test Title' }; const setAttributes = jest.fn(); render( <BlockEdit attributes={attributes} setAttributes={setAttributes} /> ); expect(screen.getByDisplayValue('Test Title')).toBeInTheDocument(); }); it('save function outputs correct markup', () => { const attributes = { title: 'Hello', className: 'custom' }; const result = blockSettings.save({ attributes }); expect(result.props.className).toContain('custom'); }); });
Snapshot testing
Snapshot testing captures a component's rendered output and compares it against a stored reference; this quickly detects unintended changes in markup structure—update snapshots intentionally when making deliberate UI changes with npm test -- -u.
import { render } from '@testing-library/react'; import { Card } from '../components/Card'; import { getSaveContent } from '@wordpress/blocks'; describe('Card Component Snapshots', () => { it('matches snapshot with default props', () => { const { container } = render(<Card title="Test" />); expect(container).toMatchSnapshot(); }); it('matches snapshot with all props', () => { const { container } = render( <Card title="Full Card" description="Description text" imageUrl="https://example.com/image.jpg" showFooter={true} /> ); expect(container).toMatchSnapshot(); }); }); describe('Block Save Snapshots', () => { it('block output matches snapshot', () => { const attributes = { title: 'Snapshot Title', content: 'Content here', }; const saveContent = getSaveContent( 'my-plugin/my-block', attributes ); expect(saveContent).toMatchSnapshot(); }); });
tests/
└── __snapshots__/
└── Card.test.js.snap # Auto-generated snapshot files
E2E testing preparation
End-to-end testing preparation involves setting up the testing environment, installing necessary packages like Playwright, and configuring wp-env to provide a consistent WordPress instance for running browser-based tests against real WordPress functionality.
# Install E2E testing dependencies npm install --save-dev @wordpress/e2e-test-utils-playwright @playwright/test # Initialize Playwright npx playwright install # Ensure wp-env is ready npm install --save-dev @wordpress/env
{ "scripts": { "test:e2e": "wp-scripts test-playwright", "test:e2e:debug": "wp-scripts test-playwright --debug", "env:start": "wp-env start", "env:stop": "wp-env stop" } }
// playwright.config.js const { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ testDir: './tests/e2e', use: { baseURL: 'http://localhost:8888', screenshot: 'only-on-failure', video: 'retain-on-failure', }, webServer: { command: 'npm run env:start', url: 'http://localhost:8888', reuseExistingServer: !process.env.CI, }, });
End-to-End Testing
Playwright setup
Playwright is a modern E2E testing framework that automates Chromium, Firefox, and WebKit browsers; WordPress provides @wordpress/e2e-test-utils-playwright with helper functions for common WordPress interactions like logging in, creating posts, and navigating admin pages.
// playwright.config.js const { defineConfig, devices } = require('@playwright/test'); module.exports = defineConfig({ testDir: './tests/e2e', fullyParallel: true, retries: process.env.CI ? 2 : 0, reporter: [['html', { open: 'never' }]], use: { baseURL: 'http://localhost:8888', trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, ], });
// tests/e2e/example.spec.js const { test, expect } = require('@playwright/test'); const { Admin, Editor } = require('@wordpress/e2e-test-utils-playwright'); test.describe('Plugin E2E Tests', () => { let admin, editor; test.beforeEach(async ({ page }) => { admin = new Admin({ page }); editor = new Editor({ page }); await admin.visitAdminPage('/'); }); test('admin page loads', async ({ page }) => { await expect(page).toHaveTitle(/Dashboard/); }); });
wp-env for testing
wp-env provides a consistent, Docker-based WordPress environment for testing that matches across developer machines and CI; it includes a dedicated test instance on port 8889 with a separate database for running automated tests without affecting development data.
// .wp-env.json { "core": "WordPress/WordPress#6.4", "phpVersion": "8.1", "plugins": [ "." ], "themes": [], "config": { "WP_DEBUG": true, "SCRIPT_DEBUG": true }, "env": { "tests": { "phpVersion": "8.1", "config": { "WP_DEBUG": false } } }, "mappings": { "wp-content/uploads": "./tests/e2e/fixtures/uploads" } }
# Start environment npx wp-env start # Access points: # Development: http://localhost:8888 (admin/password) # Tests: http://localhost:8889 (admin/password) # Run WP-CLI in test environment npx wp-env run tests-cli wp user list # Clean test database npx wp-env clean tests # Destroy and recreate npx wp-env destroy npx wp-env start
E2E test writing
E2E tests simulate real user interactions in a browser—clicking, typing, navigating—to verify complete user workflows work correctly; use Playwright's locators and assertions to interact with and verify WordPress admin pages, the block editor, and frontend pages.
// tests/e2e/plugin-settings.spec.js const { test, expect } = require('@playwright/test'); test.describe('Plugin Settings', () => { test.beforeEach(async ({ page }) => { // Login as admin await page.goto('/wp-login.php'); await page.fill('#user_login', 'admin'); await page.fill('#user_pass', 'password'); await page.click('#wp-submit'); }); test('can access settings page', async ({ page }) => { await page.goto('/wp-admin/options-general.php?page=my-plugin'); await expect(page.locator('h1')).toHaveText('My Plugin Settings'); }); test('can save settings', async ({ page }) => { await page.goto('/wp-admin/options-general.php?page=my-plugin'); // Fill form await page.fill('#api_key', 'test-api-key-123'); await page.check('#enable_feature'); await page.selectOption('#display_mode', 'grid'); // Save await page.click('#submit'); // Verify success await expect(page.locator('.notice-success')) .toContainText('Settings saved'); // Verify values persisted await page.reload(); await expect(page.locator('#api_key')).toHaveValue('test-api-key-123'); await expect(page.locator('#enable_feature')).toBeChecked(); }); });
User flow testing
User flow testing validates complete journeys through your WordPress plugin—from visiting a page through completing an action; these tests ensure multiple features work together correctly and catch integration issues unit tests miss.
// tests/e2e/booking-flow.spec.js const { test, expect } = require('@playwright/test'); test.describe('Complete Booking Flow', () => { test('user can book an appointment', async ({ page }) => { // Step 1: Visit booking page await page.goto('/book-appointment'); await expect(page.locator('h1')).toHaveText('Book Appointment'); // Step 2: Select service await page.click('[data-service="consultation"]'); await expect(page.locator('.service-selected')).toBeVisible(); // Step 3: Choose date/time await page.click('[data-date="2024-03-15"]'); await page.click('[data-time="10:00"]'); // Step 4: Fill customer info await page.fill('#customer-name', 'John Doe'); await page.fill('#customer-email', 'john@example.com'); await page.fill('#customer-phone', '555-1234'); // Step 5: Confirm booking await page.click('#confirm-booking'); // Step 6: Verify confirmation await expect(page.locator('.booking-success')).toBeVisible(); await expect(page.locator('.confirmation-number')).toBeVisible(); // Step 7: Verify email mention await expect(page.locator('.success-message')) .toContainText('confirmation email sent to john@example.com'); }); });
┌─────────────────────────────────────────────────────────┐
│ User Flow Test Coverage │
├─────────────────────────────────────────────────────────┤
│ Visit Page → Select Options → Fill Form → Submit │
│ ↓ ↓ ↓ ↓ │
│ [Verify] [Verify] [Validate] [Confirm] │
│ loaded selection inputs result │
└─────────────────────────────────────────────────────────┘
Visual regression testing
Visual regression testing captures screenshots of pages and compares them against baseline images to detect unintended visual changes; Playwright's built-in screenshot comparison makes it easy to catch CSS regressions, layout shifts, and styling bugs.
// tests/e2e/visual.spec.js const { test, expect } = require('@playwright/test'); test.describe('Visual Regression Tests', () => { test('homepage matches snapshot', async ({ page }) => { await page.goto('/'); await expect(page).toHaveScreenshot('homepage.png', { fullPage: true, maxDiffPixelRatio: 0.01, // Allow 1% difference }); }); test('settings page matches snapshot', async ({ page }) => { await page.goto('/wp-login.php'); await page.fill('#user_login', 'admin'); await page.fill('#user_pass', 'password'); await page.click('#wp-submit'); await page.goto('/wp-admin/options-general.php?page=my-plugin'); // Wait for dynamic content await page.waitForLoadState('networkidle'); await expect(page).toHaveScreenshot('settings-page.png'); }); test('component visual test', async ({ page }) => { await page.goto('/component-demo'); const card = page.locator('.feature-card').first(); await expect(card).toHaveScreenshot('feature-card.png'); }); });
# Update baseline screenshots npx playwright test --update-snapshots # Screenshots stored in: tests/e2e/visual.spec.js-snapshots/ ├── homepage-chromium.png ├── homepage-firefox.png └── settings-page-chromium.png
Code Quality
PHP CodeSniffer
PHP CodeSniffer (PHPCS) detects coding standard violations and enforces consistent code style; use the WordPress Coding Standards ruleset to ensure your plugin/theme follows official WordPress conventions for PHP code formatting, naming, and best practices.
# Install PHPCS with WordPress standards composer require --dev squizlabs/php_codesniffer composer require --dev wp-coding-standards/wpcs composer require --dev phpcompatibility/phpcompatibility-wp # Configure installed paths ./vendor/bin/phpcs --config-set installed_paths vendor/wp-coding-standards/wpcs,vendor/phpcompatibility/phpcompatibility-wp
<!-- phpcs.xml --> <?xml version="1.0"?> <ruleset name="My Plugin Coding Standards"> <description>PHPCS rules for My Plugin</description> <file>./src</file> <file>./includes</file> <file>./my-plugin.php</file> <exclude-pattern>/vendor/*</exclude-pattern> <exclude-pattern>/node_modules/*</exclude-pattern> <exclude-pattern>/build/*</exclude-pattern> <arg name="extensions" value="php"/> <arg name="colors"/> <arg value="sp"/> <!-- Show progress and source --> <rule ref="WordPress"> <exclude name="WordPress.Files.FileName.InvalidClassFileName"/> </rule> <rule ref="PHPCompatibilityWP"/> <config name="testVersion" value="7.4-"/> <config name="minimum_supported_wp_version" value="6.0"/> </ruleset>
# Check code ./vendor/bin/phpcs # Auto-fix issues ./vendor/bin/phpcbf
ESLint
ESLint statically analyzes JavaScript code for problems and enforces coding standards; @wordpress/scripts includes ESLint pre-configured with WordPress rules, catching errors, enforcing React/JSX best practices, and ensuring accessibility in your block editor code.
# Run ESLint (via wp-scripts) npm run lint:js # Or directly npx wp-scripts lint-js src/
// .eslintrc.js module.exports = { extends: ['plugin:@wordpress/eslint-plugin/recommended'], env: { browser: true, es2021: true, }, globals: { wp: 'readonly', jQuery: 'readonly', }, rules: { // Custom overrides 'no-console': 'warn', 'prefer-const': 'error', '@wordpress/no-unsafe-wp-apis': 'warn', 'jsdoc/require-param-description': 'off', }, overrides: [ { files: ['**/*.test.js'], env: { jest: true, }, }, ], };
{ "scripts": { "lint:js": "wp-scripts lint-js src/", "lint:js:fix": "wp-scripts lint-js src/ --fix" } }
Prettier
Prettier is an opinionated code formatter that automatically formats JavaScript, CSS, JSON, and more; it eliminates style debates by enforcing consistent formatting—integrate with ESLint to handle formatting while ESLint focuses on code quality rules.
# Prettier included with @wordpress/scripts npm run format
// .prettierrc.js module.exports = { ...require('@wordpress/prettier-config'), // Custom overrides tabWidth: 4, useTabs: true, printWidth: 100, };
// .prettierignore build/ vendor/ node_modules/ *.min.js *.min.css
{ "scripts": { "format": "wp-scripts format", "format:check": "wp-scripts format --check" } }
┌─────────────────────────────────────────────────────────┐
│ ESLint vs Prettier │
├─────────────────────────────────────────────────────────┤
│ ESLint │ Prettier │
├────────────────────────────┼────────────────────────────┤
│ Code quality rules │ Code formatting │
│ Unused variables │ Indentation │
│ Undefined references │ Line length │
│ Best practices │ Bracket spacing │
│ Accessibility (a11y) │ Quote style │
└────────────────────────────┴────────────────────────────┘
Stylelint
Stylelint lints CSS and SCSS files to catch errors and enforce consistent styling conventions; WordPress provides a preset configuration that validates your stylesheets against WordPress coding standards and catches common CSS mistakes.
# Run via wp-scripts npm run lint:css # or npx wp-scripts lint-style src/
// .stylelintrc.js module.exports = { extends: ['@wordpress/stylelint-config/scss'], rules: { // Custom rules 'selector-class-pattern': null, 'no-descending-specificity': null, 'font-weight-notation': 'numeric', 'color-hex-length': 'long', 'declaration-block-no-redundant-longhand-properties': true, }, ignoreFiles: [ 'build/**', 'vendor/**', 'node_modules/**', ], };
{ "scripts": { "lint:css": "wp-scripts lint-style 'src/**/*.{css,scss}'", "lint:css:fix": "wp-scripts lint-style 'src/**/*.{css,scss}' --fix" } }
Pre-commit hooks
Pre-commit hooks run automated checks before code is committed to Git, preventing problematic code from entering the repository; they typically run linters, formatters, and tests on staged files only, providing fast feedback without slowing development.
# Using lint-staged with husky npm install --save-dev lint-staged
// package.json { "lint-staged": { "*.php": [ "composer lint:fix", "composer lint" ], "*.{js,jsx}": [ "wp-scripts format", "wp-scripts lint-js" ], "*.{css,scss}": [ "wp-scripts lint-style --fix", "wp-scripts lint-style" ], "*.json": [ "wp-scripts format" ] } }
┌─────────────────────────────────────────────────────────┐
│ Pre-commit Hook Flow │
├─────────────────────────────────────────────────────────┤
│ │
│ git commit │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Pre-commit │──▶ Get staged files │
│ │ Hook │ │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │
│ │ Lint JS │───▶│ Lint CSS │───▶│ Lint PHP │ │
│ └─────────────┘ └─────────────┘ └────────────┘ │
│ │ │
│ ▼ │
│ Pass? ──▶ Commit proceeds │
│ Fail? ──▶ Commit blocked, show errors │
│ │
└─────────────────────────────────────────────────────────┘
Husky setup
Husky manages Git hooks in a cross-platform way, making it easy to enforce code quality checks before commits and pushes; it installs hooks that survive across team members' machines through npm install, ensuring everyone runs the same validations.
# Install Husky npm install --save-dev husky # Initialize Husky npx husky init # This creates .husky/ directory with sample pre-commit hook
# .husky/pre-commit #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx lint-staged
# .husky/pre-push #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npm run test npm run build
// package.json { "scripts": { "prepare": "husky", "lint": "npm run lint:js && npm run lint:css && composer lint", "lint:js": "wp-scripts lint-js src/", "lint:css": "wp-scripts lint-style src/", "test": "wp-scripts test-unit-js", "build": "wp-scripts build" }, "devDependencies": { "husky": "^9.0.0", "lint-staged": "^15.0.0", "@wordpress/scripts": "^26.0.0" } }
project/
├── .husky/
│ ├── _/
│ │ └── husky.sh # Husky internal
│ ├── pre-commit # Runs before commit
│ └── pre-push # Runs before push
├── package.json
└── ...