JavaScript Unit Testing in WordPress#
This guide explains how to implement unit testing for JavaScript code in WordPress projects using the @wordpress/scripts package, which provides a pre-configured Jest testing environment.
Installation and Setup#
Installing @wordpress/scripts#
First, install the @wordpress/scripts package as a development dependency:
npm install @wordpress/scripts --save-dev
This package includes Jest and all necessary testing utilities pre-configured for WordPress development.
Configuring package.json#
Add the test script to your package.json:
{
"scripts": {
"test:unit": "wp-scripts test-unit-js",
"test:unit:watch": "wp-scripts test-unit-js --watch",
"test:unit:debug": "wp-scripts --inspect-brk test-unit-js --runInBand --no-cache",
"test:unit:coverage": "wp-scripts test-unit-js --coverage"
}
}
Optional: Custom Jest Configuration#
The @wordpress/scripts comes with a default configuration using @wordpress/jest-preset-default. If you need custom Jest configuration, create a jest.config.js file in your project root:
module.exports = {
...require('@wordpress/scripts/config/jest-unit.config'),
// Add custom configuration here
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Running Tests#
Basic Test Execution#
Run all tests once:
npm run test:unit
Watch Mode#
Run tests in watch mode (automatically re-runs tests when files change):
npm run test:unit:watch
Coverage Reports#
Generate a coverage report:
npm run test:unit:coverage
Coverage reports will be generated in the coverage/ directory.
Running Specific Tests#
Run tests matching a pattern:
npm run test:unit -- --testPathPattern=ComponentName
Run a specific test file:
npm run test:unit -- path/to/test-file.test.js
Organizing Test Files#
Test files should be organized alongside the code they test, following these conventions:
File Naming Convention#
- Test files should have the
.test.js|jsx|ts|tsxextension - Name test files after the file they test:
component.js→component.test.js
Directory Structure#
src/
├── components/
│ ├── Button/
│ │ ├── Button.js
│ │ └── Button.test.js
│ └── Form/
│ ├── Form.js
│ └── Form.test.js
├── utils/
│ ├── validation.js
│ ├── validation.test.js
│ ├── formatting.js
│ └── formatting.test.js
└── services/
├── api.js
└── api.test.js
Alternatively, you can place all tests in a test or __tests__ directory:
src/
├── components/
│ └── Button/
│ ├── Button.js
│ └── __tests__/
│ └── Button.test.js
Writing Testable JavaScript Code#
Using Functions#
Write pure functions that are easy to test:
// utils/validation.js
export function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
export function validatePassword(password) {
return {
isValid: password.length >= 8,
hasUpperCase: /[A-Z]/.test(password),
hasLowerCase: /[a-z]/.test(password),
hasNumber: /\d/.test(password)
};
}
Using Classes#
Structure code using classes with single responsibilities:
// services/UserService.js
export class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
}
async fetchUser(userId) {
const response = await this.apiClient.get(`/users/${userId}`);
return response.data;
}
formatUserData(user) {
return {
id: user.id,
displayName: `${user.firstName} ${user.lastName}`,
email: user.email
};
}
}
Dependency Injection#
Use dependency injection to make classes testable:
// services/PostService.js
export class PostService {
constructor(apiClient, cache) {
this.apiClient = apiClient;
this.cache = cache;
}
async getPost(postId) {
const cached = this.cache.get(`post_${postId}`);
if (cached) {
return cached;
}
const post = await this.apiClient.get(`/posts/${postId}`);
this.cache.set(`post_${postId}`, post);
return post;
}
}
Writing Tests with Jest#
Basic Test Structure#
Use describe, test, and assertions:
// utils/validation.test.js
import { isValidEmail, validatePassword } from './validation';
describe('Email Validation', () => {
test('validates correct email addresses', () => {
expect(isValidEmail('user@example.com')).toBe(true);
expect(isValidEmail('test.user@domain.co.uk')).toBe(true);
});
test('rejects invalid email addresses', () => {
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('user@')).toBe(false);
expect(isValidEmail('@example.com')).toBe(false);
});
});
describe('Password Validation', () => {
test('validates password with all requirements', () => {
const result = validatePassword('Password123');
expect(result.isValid).toBe(true);
expect(result.hasUpperCase).toBe(true);
expect(result.hasLowerCase).toBe(true);
expect(result.hasNumber).toBe(true);
});
test('rejects short passwords', () => {
const result = validatePassword('Pass1');
expect(result.isValid).toBe(false);
});
});
Using test.each for Parameterized Tests#
The test.each method allows you to run the same test with different data sets:
// utils/formatting.test.js
import { formatCurrency, slugify } from './formatting';
describe('Currency Formatting', () => {
test.each([
[100, 'en-US', 'USD', '$100.00'],
[1234.56, 'en-US', 'USD', '$1,234.56'],
[999.99, 'en-GB', 'GBP', '£999.99'],
[0, 'en-US', 'USD', '$0.00']
])(
'formatCurrency(%p, %p, %p) returns %p',
(amount, locale, currency, expected) => {
expect(formatCurrency(amount, locale, currency)).toBe(expected);
}
);
});
describe('Slugify Function', () => {
test.each([
['Hello World', 'hello-world'],
['JavaScript Testing', 'javascript-testing'],
['Special @#$ Characters!', 'special-characters'],
[' Extra Spaces ', 'extra-spaces']
])(
'converts "%s" to "%s"',
(input, expected) => {
expect(slugify(input)).toBe(expected);
}
);
});
Using describe.each for Multiple Test Suites#
Group related tests with different parameters:
// components/Button/Button.test.js
import { Button } from './Button';
describe.each([
['primary', 'btn-primary'],
['secondary', 'btn-secondary'],
['danger', 'btn-danger']
])('Button with %s variant', (variant, expectedClass) => {
test('applies correct CSS class', () => {
const button = new Button({ variant });
expect(button.getClassName()).toContain(expectedClass);
});
test('renders with correct aria attributes', () => {
const button = new Button({ variant, label: 'Click me' });
expect(button.render()).toContain('aria-label="Click me"');
});
});
Testing Classes with Mocks#
Mock dependencies when testing classes:
// services/UserService.test.js
import { UserService } from './UserService';
describe('UserService', () => {
let userService;
let mockApiClient;
beforeEach(() => {
// Create a mock API client
mockApiClient = {
get: jest.fn()
};
userService = new UserService(mockApiClient);
});
describe('fetchUser', () => {
test('fetches user data from API', async () => {
const mockUser = { id: 1, firstName: 'John', lastName: 'Doe' };
mockApiClient.get.mockResolvedValue({ data: mockUser });
const result = await userService.fetchUser(1);
expect(mockApiClient.get).toHaveBeenCalledWith('/users/1');
expect(result).toEqual(mockUser);
});
test('handles API errors', async () => {
mockApiClient.get.mockRejectedValue(new Error('Network error'));
await expect(userService.fetchUser(1)).rejects.toThrow('Network error');
});
});
describe('formatUserData', () => {
test('formats user data correctly', () => {
const user = {
id: 1,
firstName: 'Jane',
lastName: 'Smith',
email: 'jane@example.com'
};
const result = userService.formatUserData(user);
expect(result).toEqual({
id: 1,
displayName: 'Jane Smith',
email: 'jane@example.com'
});
});
});
});
Testing with Multiple Mock Scenarios#
Use test.each with complex mock scenarios:
// services/PostService.test.js
import { PostService } from './PostService';
describe('PostService', () => {
let postService;
let mockApiClient;
let mockCache;
beforeEach(() => {
mockApiClient = { get: jest.fn() };
mockCache = {
get: jest.fn(),
set: jest.fn()
};
postService = new PostService(mockApiClient, mockCache);
});
describe('getPost', () => {
test('returns cached post if available', async () => {
const cachedPost = { id: 1, title: 'Cached Post' };
mockCache.get.mockReturnValue(cachedPost);
const result = await postService.getPost(1);
expect(result).toEqual(cachedPost);
expect(mockApiClient.get).not.toHaveBeenCalled();
});
test('fetches and caches post if not in cache', async () => {
const freshPost = { id: 1, title: 'Fresh Post' };
mockCache.get.mockReturnValue(null);
mockApiClient.get.mockResolvedValue(freshPost);
const result = await postService.getPost(1);
expect(mockApiClient.get).toHaveBeenCalledWith('/posts/1');
expect(mockCache.set).toHaveBeenCalledWith('post_1', freshPost);
expect(result).toEqual(freshPost);
});
test.each([
[1, '/posts/1', 'post_1'],
[42, '/posts/42', 'post_42'],
[999, '/posts/999', 'post_999']
])(
'handles post ID %i correctly',
async (postId, expectedEndpoint, expectedCacheKey) => {
mockCache.get.mockReturnValue(null);
const post = { id: postId, title: `Post ${postId}` };
mockApiClient.get.mockResolvedValue(post);
await postService.getPost(postId);
expect(mockApiClient.get).toHaveBeenCalledWith(expectedEndpoint);
expect(mockCache.set).toHaveBeenCalledWith(expectedCacheKey, post);
}
);
});
});
Best Practices#
1. Test Behavior, Not Implementation#
Focus on what the code does, not how it does it:
// Good
test('displays error message when email is invalid', () => {
const result = validateEmail('invalid');
expect(result.error).toBe('Please enter a valid email address');
});
// Avoid
test('calls validateEmail function', () => {
// Testing implementation details
});
2. Keep Tests Independent#
Each test should be able to run independently:
describe('ShoppingCart', () => {
let cart;
beforeEach(() => {
// Fresh cart for each test
cart = new ShoppingCart();
});
test('adds item to cart', () => {
cart.addItem({ id: 1, name: 'Product' });
expect(cart.getItemCount()).toBe(1);
});
test('removes item from cart', () => {
cart.addItem({ id: 1, name: 'Product' });
cart.removeItem(1);
expect(cart.getItemCount()).toBe(0);
});
});
3. Use Descriptive Test Names#
Test names should clearly describe what is being tested, using a structure of Function returns value when condition.
// Good
test('getUserByID() returns null when user is not found', () => {
// ...
});
// Less clear
test('getUserById test', () => {
// ...
});
4. Aim for Good Coverage#
Strive for at least 50% code coverage, but focus on testing critical paths:
npm run test:unit:coverage
Running tests in CI#
The tests should be running in CI using Github actions or Gitlab pipeline.
They require node installed, and the dependencies should be installed using npm ci (instead of npm install).