Skip to content

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-dom for components, node for 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:

  1. Accessible queries (preferred):

    • getByRole() - buttons, links, headings, etc.
    • getByLabelText() - form inputs
    • getByText() - visible text content
  2. Semantic alternatives:

    • getByDisplayValue() - input values
    • getByTitle() - 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