Skip to content

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|tsx extension
  • Name test files after the file they test: component.jscomponent.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).

Additional Resources#