This document provides best practices and guidelines for writing frontend tests in the Firefox Relay project.
- Testing Stack
- Quick Start
- Test File Organization
- Test Helper Files
- Writing Tests
- Best Practices
- Common Patterns
- Troubleshooting
We use the following testing tools:
- Jest (v30.2.0) - Test runner and assertion library
- React Testing Library (v16.3.0) - Component testing with accessibility-first queries
- MSW (Mock Service Worker) (v2.12.2) - API mocking
- jest-axe (v10.0.0) - Accessibility testing
- @testing-library/user-event (v14.6.1) - Realistic user interactions
jest.config.js- Jest configuration with Next.js integrationjest.setup.ts- Global test setup, mocks, and utilities
npm test # Run all tests- Branches: 70%
- Functions: 70%
- Lines: 80%
- Statements: 80%
Tests live alongside the code they test, using the .test.tsx or .test.ts extension:
frontend/
src/
components/
Button.tsx
Button.test.tsx
hooks/
useAliases.ts
useAliases.test.ts
pages/
home.page.tsx
home.page.test.tsx
All test helpers are organized in the __mocks__/ directory:
| File | Purpose |
|---|---|
initialise.ts |
Entry point for MSW setup |
browser.ts |
MSW browser worker for component tests |
server.ts |
MSW Node.js server for unit tests |
handlers.ts |
HTTP request handlers for all API endpoints |
mockData.ts |
Pre-defined mock data for all user states |
Mock Users Available: "demo", "empty", "onboarding", "some", "full"
| File | Purpose |
|---|---|
l10n.ts |
Localization mock with test matchers |
api/profile.ts |
Profile data mock factory |
api/aliases.ts |
Alias data mock factory |
api/runtimeData.ts |
Runtime data mock factory |
api/realPhone.ts |
Real phone number mocks |
api/relayNumber.ts |
Relay number mocks |
api/inboundContact.ts |
Inbound contact mocks |
api/user.ts |
User data mocks |
| File | Purpose |
|---|---|
Localized.tsx |
Mockable localization component |
ImageMock.tsx |
Next.js Image component mock |
IconsMock.tsx |
SVG icon mocks |
| File | Purpose |
|---|---|
flags.ts |
Feature flag testing utilities |
getLocale.ts |
Locale function mock |
getPlan.ts |
Plan availability mock |
cookies.ts |
Cookie handling mock |
| File | Purpose |
|---|---|
renderWithProviders.tsx |
Custom render with all providers |
next__router.ts |
Next.js router mock |
import { render, screen } from "@testing-library/react";
import { Button } from "./Button";
describe("Button", () => {
it("renders with text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("can be disabled", () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});If your component needs providers (localization, overlay provider), use renderWithProviders:
import { renderWithProviders } from "__mocks__/modules/renderWithProviders";
import { MyComponent } from "./MyComponent";
describe("MyComponent", () => {
it("renders correctly", () => {
renderWithProviders(<MyComponent />);
expect(screen.getByRole("main")).toBeInTheDocument();
});
});Always use userEvent for realistic user interactions:
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
it("handles user input", async () => {
render(<Input />);
const input = screen.getByRole("textbox");
await userEvent.type(input, "Hello World");
expect(input).toHaveValue("Hello World");
await userEvent.clear(input);
expect(input).toHaveValue("");
});
it("handles clicks", async () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click</Button>);
await userEvent.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(1);
});Use renderHook from React Testing Library:
import { renderHook, waitFor } from "@testing-library/react";
import { useAliases } from "./useAliases";
import { setMockAliasesData } from "__mocks__/hooks/api/aliases";
describe("useAliases", () => {
it("fetches aliases", async () => {
const mockAliases = [{ id: 1, address: "test@relay.com" }];
setMockAliasesData({ random: mockAliases });
const { result } = renderHook(() => useAliases());
await waitFor(() => {
expect(result.current.randomAliasData.data).toEqual(mockAliases);
});
});
});Use mock factory functions for consistent, configurable data:
import { setMockProfileData } from "__mocks__/hooks/api/profile";
import { setMockRuntimeData, getMockRuntimeDataWithPhones } from "__mocks__/hooks/api/runtimeData";
import { getMockRandomAlias } from "__mocks__/hooks/api/aliases";
describe("Premium features", () => {
it("shows premium UI for premium users", () => {
setMockProfileData({ has_premium: true });
setMockRuntimeData(getMockRuntimeDataWithPhones());
render(<Dashboard />);
expect(screen.getByText(/premium/i)).toBeInTheDocument();
});
it("customizes alias with partial override", () => {
const customAlias = getMockRandomAlias({
enabled: false,
description: "My custom description"
});
// Use customAlias in your test
});
});Use *Once() methods when you need different data for a single test:
import { setMockAliasesDataOnce } from "__mocks__/hooks/api/aliases";
it("handles empty state", () => {
setMockAliasesDataOnce({ random: [] });
render(<AliasList />);
expect(screen.getByText("No aliases yet")).toBeInTheDocument();
});Use the flags utilities for testing conditional features:
import { setFlags, resetFlags, withFlag } from "__mocks__/functions/flags";
describe("Feature flag dependent component", () => {
beforeEach(() => {
resetFlags();
});
it("shows feature when flag is active", () => {
setFlags({ new_feature: true });
render(<Component />);
expect(screen.getByTestId("new-feature")).toBeInTheDocument();
});
it("hides feature when flag is inactive", () => {
setFlags({ new_feature: false });
render(<Component />);
expect(screen.queryByTestId("new-feature")).not.toBeInTheDocument();
});
it("uses withFlag for isolated flag testing", async () => {
await withFlag("new_feature", true, () => {
render(<Component />);
expect(screen.getByTestId("new-feature")).toBeInTheDocument();
});
});
});Use the l10n mock matchers for testing localized content:
import { byMsgId } from "__mocks__/hooks/l10n";
it("displays localized text", () => {
render(<Component />);
expect(screen.getByText(byMsgId("welcome-message"))).toBeInTheDocument();
});
it("finds button by localized label", () => {
render(<Component />);
const button = screen.getByRole("button", { name: byMsgId("submit-button") });
expect(button).toBeInTheDocument();
});The mock l10n returns identifiable strings like "l10n string: [message-id], with vars: {...}" for easy testing.
Every component should have an accessibility test:
import { axe } from "jest-axe";
it("passes axe accessibility testing", async () => {
const { baseElement } = render(<Component />);
const results = await axe(baseElement);
expect(results).toHaveNoViolations();
});Prefer queries that reflect how users interact with your app:
// Good - accessible queries
screen.getByRole("button", { name: "Submit" });
screen.getByLabelText("Email address");
screen.getByRole("heading", { name: "Dashboard" });
// Avoid - implementation details
screen.getByTestId("submit-btn");
screen.getByClassName("email-input");// Good - configurable, maintainable
const alias = getMockRandomAlias({ enabled: false });
setMockProfileData({ has_premium: true });
// Avoid - brittle, hard to maintain
const alias = {
id: 1,
address: "test@relay.com",
enabled: false,
// ... 20 more required fields
};import { resetFlags } from "__mocks__/functions/flags";
describe("MyComponent", () => {
beforeEach(() => {
resetFlags();
// Reset other global state
});
});Jest automatically clears all mocks between tests (clearMocks: true in config).
// Good - tests behavior
it("allows user to create a new alias", async () => {
render(<AliasGenerator />);
await userEvent.click(screen.getByRole("button", { name: "Generate" }));
expect(screen.getByText(/new alias created/i)).toBeInTheDocument();
});
// Avoid - tests implementation
it("calls createAlias function", () => {
const createAlias = jest.fn();
render(<AliasGenerator onGenerate={createAlias} />);
// This tests the prop, not the user experience
});import { waitFor } from "@testing-library/react";
it("loads data asynchronously", async () => {
render(<AsyncComponent />);
await waitFor(() => {
expect(screen.getByText("Data loaded")).toBeInTheDocument();
});
});One assertion per test when possible. Use descriptive test names.
// Good - focused, clear
it("disables submit button when form is invalid", () => {
render(<Form />);
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
});
it("enables submit button when form is valid", async () => {
render(<Form />);
await userEvent.type(screen.getByLabelText("Email"), "user@example.com");
expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
});
// Avoid - tests multiple unrelated things
it("works correctly", () => {
// Tests 10 different behaviors
});Trust that React Testing Library, Next.js, etc. work correctly. Test your code.
// Avoid - testing React Router
it("navigates to home page", () => {
// Don't test that Next.js routing works
});
// Good - test your component's behavior
it("shows link to home page", () => {
render(<Navigation />);
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("href", "/");
});it("submits form with valid data", async () => {
const handleSubmit = jest.fn();
render(<ContactForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText("Email"), "user@example.com");
await userEvent.type(screen.getByLabelText("Message"), "Hello!");
await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(handleSubmit).toHaveBeenCalledWith({
email: "user@example.com",
message: "Hello!"
});
});import { setMockProfileDataOnce } from "__mocks__/hooks/api/profile";
it("displays error when profile fetch fails", async () => {
setMockProfileDataOnce(null); // Simulate error
render(<Profile />);
await waitFor(() => {
expect(screen.getByText(/error loading profile/i)).toBeInTheDocument();
});
});it("shows loading indicator", () => {
render(<DataComponent />);
expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument();
});it("shows premium features for premium users", () => {
setMockProfileData({ has_premium: true });
render(<Dashboard />);
expect(screen.getByText(/premium feature/i)).toBeInTheDocument();
});
it("hides premium features for free users", () => {
setMockProfileData({ has_premium: false });
render(<Dashboard />);
expect(screen.queryByText(/premium feature/i)).not.toBeInTheDocument();
});it("renders list of aliases", () => {
const aliases = [
getMockRandomAlias({ address: "alias1@relay.com" }),
getMockRandomAlias({ address: "alias2@relay.com" }),
];
setMockAliasesData({ random: aliases });
render(<AliasList />);
expect(screen.getByText("alias1@relay.com")).toBeInTheDocument();
expect(screen.getByText("alias2@relay.com")).toBeInTheDocument();
});Use waitFor or await userEvent.* for async operations:
// Before
userEvent.click(button); // Missing await
// After
await userEvent.click(button);Make sure you're calling setMock* functions before rendering:
// Wrong order
render(<Component />);
setMockProfileData({ has_premium: true }); // Too late!
// Correct order
setMockProfileData({ has_premium: true });
render(<Component />);- Check if element is rendered conditionally
- Use
findBy*for async elements - Use
queryBy*to assert non-existence - Use
screen.debug()to see current DOM
screen.debug(); // Prints current DOM to consoleReset flags in beforeEach:
beforeEach(() => {
resetFlags();
setFlags({ my_flag: true });
});Use the factory functions to get properly typed mock data:
// Good - typed correctly
const alias = getMockRandomAlias();
// Avoid - may have type errors
const alias = { address: "test@relay.com" }; // Missing required fieldsWhen adding new features:
- Write tests alongside your code
- Use existing mock patterns for consistency
- Add new mock factories to
__mocks__/when needed - Run tests before committing:
npm test - Ensure coverage thresholds are met