Jest Testing Best Practices
This guide covers testing patterns and best practices for Jest and React Testing Library in the Wonder Blocks codebase.
Core Testing Principles
⚠️ Critical Setup Rules
Test Workflow Priority:
- ✅ ALWAYS fix failing tests BEFORE fixing linting errors
- ✅ Focus on underlying errors, not
Unhandled console.error callmessages - ⚠️ When tests fail with
Unhandled console.error call, look for the root cause error (e.g.,ReferenceError: window is not defined) - ⚠️ The console.error messages are symptoms, not the actual problem - fix the underlying issue
File Structure:
- ✅ Name test files with
.test.tsor.test.tsxsuffix - ✅ Place in
__tests__/directory OR colocate with source files (follow local conventions)
Test Framework Setup:
- ✅ Additional matchers from React Testing Library (RTL) and
jest-extendedare available - ✅ Use
describe/itpattern for test organization - ✅ Use
globalThisprefix when accessing global objects - ✅ Prioritize testing non-trivial business logic over trivial implementations
Arrange-Act-Assert Pattern
⚠️ ALWAYS use this three-section structure:
describe("Calculator", () => {
it("should add two numbers correctly", () => {
// Arrange
const a = 5;
const b = 3;
// Act
const result = add(a, b);
// Assert
expect(result).toBe(8);
});
});
Rules:
- ✅ ALWAYS divide tests into Arrange, Act, Assert sections with comments
- ✅ Each section gets exactly one comment label (
// Arrange,// Act,// Assert) — no additional comments within a section - ❌ NEVER combine sections (e.g., don't write
// Act & Assert) - ❌ NEVER use multiple Act or Assert sections in a single test (split into separate tests instead)
- ❌ NEVER remove Arrange, Act, Assert comments
Exception - Testing Thrown Errors:
When testing errors, use an underTest variable in the Act section:
it("should throw an error when input is invalid", () => {
// Arrange
const invalidInput = "invalid";
// Act
const underTest = () => {
processInput(invalidInput);
};
// Assert
expect(underTest).toThrow("Invalid input");
});
Be Concise and Avoid Over-Testing
⚠️ Focus on what matters - don't overdo it:
DO Test:
- ✅ Non-trivial business logic - Complex calculations, data transformations, validation rules
- ✅ User interactions - Click handlers, form submissions, keyboard navigation
- ✅ Accessibility - ARIA attributes, keyboard support, focus management
- ✅ Edge cases and error conditions - Null values, empty states, error handling
- ✅ Integration points - API calls, event callbacks, state changes
- ✅ Bug fixes - Add a test that reproduces the bug to prevent regressions
DON'T Test:
- ❌ Trivial implementations - Simple getters/setters, pass-through functions
- ❌ Style-only props - Visual appearance is covered by visual regression tests in Storybook
- ❌ Third-party libraries - Assume they work; test your usage of them
- ❌ Implementation details - Internal state that doesn't affect output/behavior
- ❌ Additional logic in tests - Use existing utility functions instead of reimplementing logic in tests
// ❌ DON'T: Testing style-only props (use visual regression tests instead)
it("should apply primary color when kind is primary", () => {
render(<Button kind="primary" />);
expect(screen.getByRole("button")).toHaveStyle({ backgroundColor: "blue" });
});
// ✅ DO: Test meaningful behavior
it("should call onClick when clicked", async () => {
// Arrange
const handleClick = jest.fn();
render(<Button me</Button>);
// Act
await userEvent.click(screen.getByRole("button"));
// Assert
expect(handleClick).toHaveBeenCalledTimes(1);
});
// ✅ DO: Test non-trivial logic
it("should validate email format and return error message", () => {
// Arrange
const invalidEmail = "not-an-email";
// Act
const result = validateEmail(invalidEmail);
// Assert
expect(result).toBe("Please enter a valid email address");
});
Key Principles:
- ✅ Test behavior, not implementation - Focus on what the component does, not how
- ✅ Prioritize critical paths - Test the most important user flows first
- ✅ Keep tests simple and readable - Each test should have a clear, single purpose
- ✅ Don't add logic to tests - Tests should only test the component/function; use existing utility functions from the codebase instead of reimplementing logic in tests
- ✅ Use visual regression tests for styling - Storybook snapshot tests handle visual appearance
- ✅ Balance coverage with maintainability - More tests ≠ better tests
Assertions
Best Practices:
- ✅ Use specific matchers when possible (e.g.,
toBe,toEqual,toHaveBeenCalledWith) - ✅ Prefer explicit assertions over implicit ones
- ✅ Use semantic matchers from RTL:
toBeInTheDocument(),toBeVisible(),toHaveAttribute() - ❌ Avoid Jest snapshots (
.toMatchSnapshot(),.toMatchInlineSnapshot()) - use Chromatic + Storybook for visual regression tests, or use specific attribute assertions instead
One Expect Per Test
⚠️ Each test should have exactly one expect. If you need to assert multiple things, split them into separate tests. Multiple assertions hide which behavior actually broke when the test fails.
Parameterized Tests with it.each
When to use: Testing the same logic with multiple input/output combinations
✅ DO: Use it.each for data-driven tests
describe("Calculator", () => {
it.each([
[2, 3, 5],
[0, 0, 0],
[-1, 1, 0],
[10, -5, 5],
])("should add %i and %i to equal %i", (a, b, expected) => {
// Arrange
// (inputs come from it.each)
// Act
const result = add(a, b);
// Assert
expect(result).toBe(expected);
});
});
Benefits:
- ✅ Reduces Duplication: Test same logic with different inputs
- ✅ Clear Test Names: Each test shows specific values being tested
- ✅ Easy to Extend: Simply add new arrays to test data
- ✅ Better Coverage: Test edge cases and boundary conditions efficiently
- ✅ Comprehensive Testing: Essential for testing all prop combinations and states in Wonder Blocks components
Mocking and Spying
⚠️ Critical Rules - ALWAYS Follow These
- NEVER mock
console.error- This hides real implementation issues and errors - ALWAYS use
jest.spyOn()to create spies - Never treat the original function as though it were a spy - Store spy return values in variables ONLY when asserting on them - Avoids unused variable linter errors
- NEVER mock outside of tests - Even if it means code duplication, keep mocks inside test cases
Method Spying - Correct Pattern
✅ DO: Use jest.spyOn and store the result when asserting
import * as SomeFile from "./some-file.ts";
describe("MyComponent", () => {
it("should call someMethod with correct args", () => {
// Arrange
// Store spy because we'll assert on it later
const spy = jest.spyOn(SomeFile, "someMethod").mockReturnValue(mockValue);
// Act
myFunction();
// Assert
expect(spy).toHaveBeenCalledWith(expectedArgs);
});
});
❌ DON'T: Treat the original function as a spy without jest.spyOn()
// ❌ WRONG - This will fail because someMethod is not a spy
import * as SomeFile from "./some-file.ts";
describe("MyComponent", () => {
it("should call someMethod", () => {
// Act
myFunction();
// Assert
expect(SomeFile.someMethod).toHaveBeenCalled(); // ❌ ERROR! Not a spy
});
});
When to Store Spies in Variables
Spies serve two purposes:
- Mocking behavior - Replace function implementation or return value
- Verification - Assert the function was called with correct arguments
✅ Mocking only (no variable needed):
it("should process user data", () => {
// Arrange
// Mock the API call to return test data, but don't store it
jest.spyOn(API, "fetchUser").mockResolvedValue(mockUserData);
// Act
const result = processUserProfile();
// Assert
// We're testing processUserProfile's logic, not that fetchUser was called
expect(result.displayName).toBe("John Doe");
// No spy variable = no unused variable linter error
});
✅ Mocking AND verification (store in variable):
it("should call analytics when button is clicked", () => {
// Arrange
// Store the spy because we'll assert on it
const trackEventSpy = jest
.spyOn(Analytics, "trackEvent")
.mockReturnValue(undefined);
// Act
userEvent.click(screen.getByRole("button"));
// Assert
// We're testing that the analytics call happens correctly
expect(trackEventSpy).toHaveBeenCalledWith("button_click", {
buttonId: "submit",
});
});
Key point: Only store the spy in a variable if you're going to assert on it. This avoids unused variable linter errors while still allowing you to verify calls when needed.
Common Spy Patterns
Mock only (no variable):
// When you only need to control the return value
jest.spyOn(module, "functionName").mockReturnValue(mockValue);
jest.spyOn(module, "asyncFunction").mockResolvedValue(mockValue);
jest.spyOn(module, "asyncFunction").mockRejectedValue(new Error("Test error"));
Mock and verify (store in variable):
// When you need to assert the spy was called
const spy = jest.spyOn(module, "functionName").mockReturnValue(mockValue);
// ... later in Assert section:
expect(spy).toHaveBeenCalledWith(expectedArgs);
Spy with mock implementation:
// Store only if you'll verify it was called
const spy = jest.spyOn(module, "functionName").mockImplementation((arg) => {
return processedValue;
});
Mocking Guidelines
DO:
- ✅ Use
jest.spyOn()for mocking functions and tracking calls - ✅ Store spy return values in variables ONLY when you need to assert on them
- ✅ Chain
.mockReturnValue()or similar directly onjest.spyOn()when only mocking behavior - ✅ Keep mocks and spies inside test cases when possible
- ✅ Use mocking and spies to isolate the code under test at boundaries with other code
- ✅ Clean up spies after tests (Jest does this automatically with
clearAllMocks)
DON'T:
- ❌ Never mock
console.error- this hides real implementation issues - ❌ Never treat original functions as spies without
jest.spyOn() - ❌ Never store spies in variables if you won't assert on them (causes unused variable linter errors)
- ❌ Avoid mocking outside of tests, even if it means code duplication
Hook Testing
✅ Use renderHook:
import {renderHook} from "@testing-library/react";
// Direct for simple hooks
const {result} = renderHook(() => useMyHook(params));
User Interactions
✅ ALWAYS use userEvent for interactions:
import userEvent from "@testing-library/user-event";
// ✅ DO: Use userEvent (realistic, includes focus/blur/typing)
await userEvent.click(screen.getByRole("button"));
await userEvent.type(screen.getByRole("textbox"), "hello");
// ❌ DON'T: Use fireEvent (low-level, less realistic)
fireEvent.click(button);
Browser Behavior and jsdom Limitations
⚠️ jsdom does not fully implement all browser behaviors. Common limitations include: getBoundingClientRect(), scroll positions, offsetWidth/offsetHeight, clipboard API, CSS animations, and Intersection/Resize Observers.
✅ Mock browser APIs when testing in unit tests:
// Mock scrollIntoView
const scrollIntoViewMock = jest.fn();
Element.prototype.scrollIntoView = scrollIntoViewMock;
// Mock getBoundingClientRect
jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
top: 100, left: 100, bottom: 200, right: 200,
width: 100, height: 100, x: 100, y: 100, toJSON: () => {},
});
✅ Use Storybook interaction tests for behavior that's difficult to mock accurately (scroll, layout, clipboard, complex focus management). See .agents/skills/storybook/SKILL.md for details.
Element Selection
Query priority (use in this order):
- ✅ Semantic queries (best):
getByRole,getByLabelText,getByText - ✅ Test IDs (fallback):
getByTestIdwithdata-testidattribute - ❌ NEVER use CSS selectors, direct DOM traversal, or raw node access
❌ NEVER access DOM nodes directly. Always use Testing Library queries. Direct node access couples tests to implementation structure, not behavior.
// ✅ DO: Testing Library queries
screen.getByRole("button", {name: /submit/i});
screen.getByLabelText("Email address");
screen.findByText("Welcome back");
screen.getByTestId("custom-widget");
// ❌ DON'T: CSS selectors
container.querySelector(".my-class");
container.querySelector("#my-id");
// ❌ DON'T: Direct node traversal
element.parentElement;
element.children[0];
element.firstChild;
element.nextSibling;
Wonder Blocks Component Testing
Test Organization
✅ Group related tests using describe blocks:
describe("MyComponent", () => {
describe("Props", () => { /* prop tests */ });
describe("Event Handlers", () => { /* onClick, onChange tests */ });
describe("Accessibility", () => {
describe("axe", () => { /* toHaveNoA11yViolations tests */ });
describe("ARIA", () => { /* aria attribute tests */ });
describe("Focus", () => { /* focus management tests */ });
describe("Keyboard Interactions", () => { /* keyboard nav tests */ });
});
});
Test Coverage
Unit tests for a component should cover:
Base Tests
- ref is forwarded
Props
- Cover expected behaviour when certain props are set.
- Exclude tests for props that are related to styles only, since this should be covered by visual regression tests instead
- Cover expected behaviour with default prop values
- Use
it.eachwhen there are multiple combinations of things you want to test together
Event Handlers
- Check that any event handlers are triggered by the expected conditions
- Verify callbacks are called with correct arguments
Accessibility
- Confirm that roles, semantics, and aria attributes are correctly set and wired together
- Use the
.toHaveNoA11yViolationsjest matcher to confirm that a component doesn't have accessibility warnings - Confirm keyboard interactions and navigation
- Focus management
- Confirm accessible names
- Check for
aria-disabled="true"for determining disabled state (not thedisabledattribute)
Tools and Commands
Terminal Commands
# Run all tests
pnpm jest
# Run tests in watch mode
pnpm jest --watch
# Update snapshots
pnpm jest -u
# Run tests with coverage
pnpm jest --coverage
# Run specific test file
pnpm jest path/to/test-file.test.ts
# Debug with verbose output
pnpm jest --verbose --runInBand
Debugging Priority Order
Terminal Commands
- Add
console.logstatements for debugging - Use
--verboseflag for detailed output - Use
--runInBandfor sequential execution (easier to debug) - Use Node debugger with
debuggerstatements
Best Practices Summary
- ✅ Structure: Use Arrange-Act-Assert pattern with comments
- ✅ Focus: Test behavior, not implementation details
- ✅ Queries: Use semantic queries (
getByRole,getByLabelText) over test IDs - ✅ Interactions: Use
userEventinstead offireEvent - ✅ Spies: Use
jest.spyOn()and only store in variables when asserting - ✅ Accessibility: Include
toHaveNoA11yViolationstests - ✅ Organization: Group related tests with
describeblocks - ✅ Assertions: One
expectper test — split into separate tests if you need more - ✅ Parameterized: Use
it.eachfor testing multiple input/output combinations - ✅ Browser APIs: Mock jsdom limitations properly, or use Storybook interaction tests for browser-specific behavior
- ✅ Bug Fixes: Add tests that reproduce bugs to prevent regressions
- ❌ Avoid: Testing style-only props, mocking
console.error, over-testing trivial code, adding logic to tests