コンテンツにスキップ

Testing Strategies

Comprehensive guide to testing React and Next.js applications.


Testing Philosophy

Test Types Pyramid

        /\
       /E2E\         (Few) - Full user flows
      /------\
     /  API   \      (Some) - Integration tests
    /----------\
   / Unit Tests \    (Many) - Component/function tests
  /--------------\

Principles: 1. Write tests that give confidence 2. Test behavior, not implementation 3. Make tests maintainable 4. Balance coverage vs. speed


Testing Tools Ecosystem

Core Stack (2025 Standard)

Tool Purpose When to Use
Vitest Test runner Unit & integration tests
Testing Library Component testing React component tests
Playwright E2E testing Full user flows
MSW API mocking Mock server responses
Jest Alternative runner Legacy projects

Installation

# Vitest + React Testing Library
npm install -D vitest @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event @vitejs/plugin-react

# Playwright
npm init playwright@latest

# MSW (Mock Service Worker)
npm install -D msw

Unit Testing with Vitest

Configuration

vitest.config.ts:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './test/setup.ts',
  },
});

test/setup.ts:

import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});

Basic Component Test

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from './Button';

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('handles click events', async () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    await userEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('can be disabled', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Testing Hooks

import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('starts with initial value', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });

  it('increments count', () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });
});

React Testing Library

Query Priority

Recommended order: 1. getByRole - Most accessible 2. getByLabelText - Form elements 3. getByPlaceholderText - Form fallback 4. getByText - Non-interactive elements 5. getByTestId - Last resort

// ✅ Prefer accessible queries
const button = screen.getByRole('button', { name: /submit/i });
const input = screen.getByLabelText(/username/i);

// ❌ Avoid test IDs when possible
const element = screen.getByTestId('submit-button');

User Interactions

import userEvent from '@testing-library/user-event';

describe('LoginForm', () => {
  it('submits form data', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn();

    render(<LoginForm onSubmit={handleSubmit} />);

    // Type in fields
    await user.type(
      screen.getByLabelText(/username/i),
      'testuser'
    );
    await user.type(
      screen.getByLabelText(/password/i),
      'password123'
    );

    // Submit
    await user.click(screen.getByRole('button', { name: /login/i }));

    // Verify
    expect(handleSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      password: 'password123'
    });
  });
});

Async Testing

import { waitFor, waitForElementToBeRemoved } from '@testing-library/react';

describe('AsyncComponent', () => {
  it('loads and displays data', async () => {
    render(<UserProfile userId={1} />);

    // Wait for loading to disappear
    await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));

    // Check data appeared
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });

  it('handles errors', async () => {
    server.use(
      rest.get('/api/user/:id', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );

    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText(/error loading user/i)).toBeInTheDocument();
    });
  });
});

Mocking with MSW

Setup

mocks/handlers.ts:

import { rest } from 'msw';

export const handlers = [
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { id: 1, name: 'John Doe' },
        { id: 2, name: 'Jane Smith' }
      ])
    );
  }),

  rest.post('/api/login', async (req, res, ctx) => {
    const { username, password } = await req.json();

    if (username === 'test' && password === 'password') {
      return res(
        ctx.status(200),
        ctx.json({ token: 'fake-token' })
      );
    }

    return res(
      ctx.status(401),
      ctx.json({ error: 'Invalid credentials' })
    );
  }),
];

mocks/server.ts:

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

test/setup.ts:

import { server } from '../mocks/server';
import { beforeAll, afterEach, afterAll } from 'vitest';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Override Handlers

import { server } from '../mocks/server';
import { rest } from 'msw';

describe('Error handling', () => {
  it('shows error on failed request', async () => {
    // Override default handler
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );

    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});

Next.js Testing

Testing Server Components

import { render, screen } from '@testing-library/react';

// Mock async component
vi.mock('./data', () => ({
  getUsers: vi.fn(() => Promise.resolve([
    { id: 1, name: 'Test User' }
  ]))
}));

describe('UsersPage', () => {
  it('renders user list', async () => {
    const UsersPage = (await import('./page')).default;
    render(await UsersPage());

    expect(screen.getByText('Test User')).toBeInTheDocument();
  });
});

Testing Server Actions

import { describe, it, expect, vi } from 'vitest';
import { addPost } from './actions';

// Mock Prisma
vi.mock('@/lib/prisma', () => ({
  prisma: {
    post: {
      create: vi.fn((data) => Promise.resolve({ id: 1, ...data }))
    }
  }
}));

describe('addPost', () => {
  it('creates a post', async () => {
    const formData = new FormData();
    formData.append('title', 'Test Post');
    formData.append('content', 'Test Content');

    const result = await addPost({}, formData);

    expect(result.ok).toBe(true);
    expect(prisma.post.create).toHaveBeenCalledWith({
      data: {
        title: 'Test Post',
        content: 'Test Content'
      }
    });
  });
});

Testing Route Handlers

import { GET, POST } from './route';
import { NextRequest } from 'next/server';

describe('/api/posts', () => {
  it('returns posts', async () => {
    const request = new NextRequest('http://localhost:3000/api/posts');
    const response = await GET(request);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(Array.isArray(data)).toBe(true);
  });

  it('creates post', async () => {
    const request = new NextRequest('http://localhost:3000/api/posts', {
      method: 'POST',
      body: JSON.stringify({
        title: 'New Post',
        content: 'Content'
      })
    });

    const response = await POST(request);
    expect(response.status).toBe(201);
  });
});

E2E Testing with Playwright

Configuration

playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 13'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Basic E2E Test

import { test, expect } from '@playwright/test';

test('user can login', async ({ page }) => {
  await page.goto('/login');

  // Fill form
  await page.getByLabel('Username').fill('testuser');
  await page.getByLabel('Password').fill('password123');

  // Submit
  await page.getByRole('button', { name: /login/i }).click();

  // Verify redirect
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome, testuser')).toBeVisible();
});

test('validates form inputs', async ({ page }) => {
  await page.goto('/login');

  // Submit without filling
  await page.getByRole('button', { name: /login/i }).click();

  // Check error messages
  await expect(page.getByText('Username is required')).toBeVisible();
  await expect(page.getByText('Password is required')).toBeVisible();
});

Page Object Pattern

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.getByLabel('Username');
    this.passwordInput = page.getByLabel('Password');
    this.loginButton = page.getByRole('button', { name: /login/i });
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
}

// Test using page object
test('login flow', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('testuser', 'password123');

  await expect(page).toHaveURL('/dashboard');
});

Test Organization

File Structure

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx
│   │   └── Button.stories.tsx
│   └── Form/
│       ├── Form.tsx
│       └── Form.test.tsx
├── hooks/
│   ├── useCounter.ts
│   └── useCounter.test.ts
└── utils/
    ├── format.ts
    └── format.test.ts

e2e/
├── auth/
│   ├── login.spec.ts
│   └── register.spec.ts
└── blog/
    └── create-post.spec.ts

Naming Conventions

// Component tests
describe('ComponentName', () => {
  it('renders correctly', () => {});
  it('handles user interaction', () => {});
  it('displays error state', () => {});
});

// Hook tests
describe('useHookName', () => {
  it('initializes with default value', () => {});
  it('updates state correctly', () => {});
});

// Utility tests
describe('functionName', () => {
  it('returns expected output for valid input', () => {});
  it('throws error for invalid input', () => {});
});

Testing Best Practices

1. Test User Behavior

// ❌ Testing implementation
it('sets loading state', () => {
  const { result } = renderHook(() => useData());
  expect(result.current.loading).toBe(true);
});

// ✅ Testing behavior
it('shows loading indicator while fetching', async () => {
  render(<DataComponent />);
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  await waitFor(() => {
    expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
  });
});

2. Avoid Testing Library Internals

// ❌ Testing React Query internals
it('uses correct query key', () => {
  const { result } = renderHook(() => useUsers());
  expect(result.current.queryKey).toEqual(['users']);
});

// ✅ Testing actual behavior
it('fetches and displays users', async () => {
  render(<UserList />);
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
});

3. Keep Tests Simple

// ❌ Complex test setup
it('handles complex scenario', async () => {
  const user = createMockUser();
  const posts = createMockPosts();
  const comments = createMockComments();
  // ... too much setup
});

// ✅ Simple and focused
it('displays user name', () => {
  render(<UserCard user={{ name: 'John' }} />);
  expect(screen.getByText('John')).toBeInTheDocument();
});

4. Use Data-TestId Sparingly

// ❌ Overusing test IDs
<button data-testid="submit-button">Submit</button>

// ✅ Use semantic queries
<button type="submit">Submit</button>

// Then query by role
screen.getByRole('button', { name: /submit/i });

Coverage Goals

What to Aim For

Type Target Priority
Critical paths 100% High
Business logic 80%+ High
UI components 60%+ Medium
Utilities 90%+ High
E2E flows Key scenarios High

Running Coverage

# Vitest
vitest run --coverage

# View report
open coverage/index.html

package.json:

{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}


CI/CD Integration

GitHub Actions

.github/workflows/test.yml:

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run test:coverage
      - run: npm run test:e2e
      - uses: codecov/codecov-action@v3


Resources