Unit Testing
This document describes the unit testing approach used in the Account Overview project. We use Vitest and React Testing Library to test components and business logic with a focus on user behavior.
Overview
Our unit tests focus on verifying that components and functions work correctly from a user's perspective. Rather than testing implementation details, we test the observable behavior and outcomes.
Testing Stack
- Vitest - Test runner and framework (Jest-compatible with ESM support)
- React Testing Library - Component testing focused on user interactions
- Happy DOM - Fast DOM environment for browser simulation
- MSW (Mock Service Worker) - API request mocking
- @remix-run/testing - Remix-specific testing utilities
Configuration
Tests are configured in vitest.config.mts:
- Test pattern:
./app/**/*.test.{ts,tsx} - Environment:
happy-domfor components,nodefor server code - Global setup:
tests/vitest.setup.ts - Coverage: HTML and text reporters for CI
Project Structure
Tests are co-located with source files throughout the app/ directory:
app/
├── components/
│ ├── component-name.tsx
│ ├── component-name.test.tsx
│ └── mocks/
│ └── mock-data.ts
├── routes/
│ ├── route-name/
│ │ ├── route.tsx
│ │ └── route.test.tsx
└── services/
├── service-name.ts
├── service-name.test.ts
└── service-name.mocks.ts
Mock files use the .mocks.ts suffix and contain reusable test data and MSW handlers.
Testing Approach
Philosophy
Our tests follow React Testing Library's guiding principle: "The more your tests resemble the way your software is used, the more confidence they can give you."
This means:
- Testing through user interactions rather than component internals
- Using semantic queries that match how users navigate (roles, labels, text)
- Avoiding tests that would break from refactoring that doesn't change behavior
- Mocking external dependencies but testing real business logic
What We Test
Components and features:
- Rendering with various props and states
- User interactions (clicks, form inputs, keyboard navigation)
- Conditional rendering and visibility
- Accessibility (ARIA attributes, semantic HTML)
- Error states and edge cases
- Context integration
What we avoid testing:
- Internal component state changes
- CSS classes or styling details
- React library functionality
- Complex integration flows (these belong in E2E tests)
Selector Strategy
The project uses semantic selectors in this priority order:
-
Accessible queries (preferred):
getByRole()- buttons, links, headings, etc.getByLabelText()- form inputsgetByText()- visible text content
-
Semantic alternatives:
getByDisplayValue()- input valuesgetByTitle()- title attributes
Note: This project doesn't use data-testid attributes. All selectors should match how users or assistive technologies identify elements.
Mocking Strategy
We mock external dependencies but prefer real implementations for pure logic:
When to mock:
- External APIs and network requests
- Database operations
- File system operations
- Slow or unreliable dependencies
- Side effects that are hard to test
When to use real implementations:
- Pure business logic functions
- Simple utilities and helpers
- Fast, deterministic operations
- Configuration and constants
API Mocking with MSW
MSW (Mock Service Worker) intercepts API requests at the network level:
import { server } from "~mocks/server.js";
import { userHandlers } from "../services/user.mocks.js";
describe("UserProfile", () => {
beforeEach(() => {
server.use(...userHandlers);
});
it("displays user information", async () => {
render(<UserProfile />);
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeVisible();
});
});
});
Module Mocking
For non-API dependencies, Vitest mocking can be used:
import { vi } from "vitest";
vi.mock("../helpers/analytics.js", () => ({
trackEvent: vi.fn(),
}));
// Later in test
expect(trackEvent).toHaveBeenCalledWith("button_click");
Common Patterns
Basic Component Test
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { RemixComponentWrapper } from "../helpers/test-helpers.js";
import { WelcomeCard } from "./welcome-card.js";
describe("WelcomeCard", () => {
it("displays welcome message with user name", () => {
render(
<RemixComponentWrapper>
<WelcomeCard userName="Alice" />
</RemixComponentWrapper>
);
expect(screen.getByText("Welcome, Alice!")).toBeVisible();
});
it("shows default message when no name provided", () => {
render(
<RemixComponentWrapper>
<WelcomeCard />
</RemixComponentWrapper>
);
expect(screen.getByText("Welcome!")).toBeVisible();
});
});
Testing User Interactions
import userEvent from "@testing-library/user-event";
describe("ContactForm", () => {
it("submits form with entered data", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<ContactForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("Email"), "test@statista.com");
await user.type(screen.getByLabelText("Message"), "Hello!");
await user.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: "test@statista.com",
message: "Hello!",
});
});
});
});
Testing Conditional Rendering
describe("FeatureCard", () => {
it("displays premium badge for paid users", () => {
render(<FeatureCard isPremium={true} />);
expect(screen.getByText("Premium")).toBeVisible();
});
it("hides premium badge for free users", () => {
render(<FeatureCard isPremium={false} />);
expect(screen.queryByText("Premium")).toBeNull();
});
});
Testing Async Operations
import { waitFor } from "@testing-library/react";
describe("DataLoader", () => {
it("loads and displays data", async () => {
render(<DataLoader />);
expect(screen.getByText("Loading...")).toBeVisible();
await waitFor(() => {
expect(screen.getByText("Data loaded successfully")).toBeVisible();
});
});
});
Testing Remix Routes
import { createRoutesStub } from "react-router";
describe("Dashboard Route", () => {
it("renders user dashboard with data", () => {
const RemixStub = createRoutesStub([
{
path: "/",
Component: DashboardRoute,
loader: () => ({ userName: "Alice", projects: 5 }),
},
]);
render(<RemixStub initialEntries={["/"]} />);
expect(screen.getByText("Alice")).toBeVisible();
expect(screen.getByText("5 projects")).toBeVisible();
});
});
Best Practices
Test Organization
Tests are organized using describe blocks that group related test cases:
describe("AccountCard", () => {
describe("rendering", () => {
// Tests for basic rendering
});
describe("interactions", () => {
// Tests for user interactions
});
describe("error states", () => {
// Tests for error handling
});
});
Test Naming
Test names describe what is being tested, under what conditions, and what the expected outcome is:
✅ Good: "displays error message when form validation fails"
✅ Good: "renders card with price information for logged-out users"
❌ Avoid: "should work", "test component", "renders correctly"
Assertions
The project uses Jest-compatible matchers through Vitest:
// Visibility
expect(element).toBeVisible();
expect(element).toBeHidden();
// Existence
expect(screen.getByText("Hello")).toBeInTheDocument();
expect(screen.queryByText("Hidden")).toBeNull();
// Count
expect(screen.getAllByRole("listitem")).toHaveLength(3);
// Text matching
expect(screen.getByText("Exact text")).toBeVisible();
expect(screen.getByText(/partial match/i)).toBeVisible();
Mock Management
Mocks are automatically reset between tests via vitest.setup.ts. For test-specific mocks:
afterEach(() => {
vi.clearAllMocks();
});
When mocks become unnecessary (e.g., after refactoring), remove them to keep tests maintainable and let real implementations run when appropriate.
Running Tests
Available commands:
# Run all tests
pnpm run test
# Run with coverage
pnpm run test --coverage
# Watch mode for development
pnpm run test --watch
# Run specific test file
pnpm run test path/to/file.test.tsx
CI Integration
Tests run automatically in CI with:
- Parallel test execution for speed
- Coverage reporting (HTML and text)
- Fail-fast mode to catch focused tests (
it.only) - Automatic mock cleanup between tests
Environment variables are stubbed in tests/vitest.setup.ts for consistent test execution.
Further Reading
- React Testing Library Documentation
- Vitest Documentation
- MSW Documentation
- E2E Testing Guide for integration test patterns