8.3 KiB
8.3 KiB
name, description, compatibility, metadata
| name | description | compatibility | metadata | ||||
|---|---|---|---|---|---|---|---|
| react-testing-patterns | Testing patterns cho React với Vitest, React Testing Library, và Playwright. Use for unit tests, component tests, E2E tests, và Storybook integration testing. | vitest>=4, @testing-library/react>=14, playwright>=1 |
|
React Testing Patterns / Mẫu Testing React
When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Writing unit tests / Viết unit tests
- Testing React components / Testing React components
- Creating E2E tests with Playwright / Tạo E2E tests với Playwright
- Testing Storybook stories / Testing Storybook stories
- Mocking APIs and modules / Mocking APIs và modules
Testing Pyramid / Tháp Testing
┌───────────┐
│ E2E │ Playwright (few, slow, high confidence)
├───────────┤
│Integration│ RTL + Vitest (moderate)
├───────────┤
│ Unit │ Vitest (many, fast, focused)
└───────────┘
Key Patterns / Mẫu Chính
Test File Organization
src/
├── features/auth/
│ ├── components/
│ │ └── LoginForm/
│ │ ├── LoginForm.tsx
│ │ └── LoginForm.test.tsx # Component test
│ └── hooks/
│ └── useAuth.test.ts # Hook test
├── stores/
│ └── __tests__/
│ └── auth-store.test.ts # Store test
└── __tests__/ # Integration tests
└── auth-flow.test.tsx
e2e/
└── auth.spec.ts # E2E tests
Unit Testing with Vitest
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('should increment counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should reset counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(0);
});
});
Component Testing with RTL
// components/LoginForm/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('should submit form with valid data', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
// Type in form fields
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit form
await user.click(screen.getByRole('button', { name: /login/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('should show validation errors', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
// Submit without filling form
await user.click(screen.getByRole('button', { name: /login/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
});
});
Testing with Query Wrapper
// test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render as rtlRender } from '@testing-library/react';
/**
* EN: Custom render with providers
* VI: Custom render với providers
*/
export function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return rtlRender(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
}
// Usage
import { renderWithProviders } from '@/test/utils';
it('should load data', async () => {
renderWithProviders(<UserProfile userId="1" />);
expect(await screen.findByText('John Doe')).toBeInTheDocument();
});
Mocking with Vitest
// Mocking modules
import { vi } from 'vitest';
// Mock entire module
vi.mock('@/services/api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }),
}));
// Mock specific function
import * as api from '@/services/api';
vi.spyOn(api, 'fetchUser').mockResolvedValue({ id: '1', name: 'Test' });
// Mock with implementation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
usePathname: () => '/dashboard',
}));
Playwright E2E Testing
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login');
// Fill form
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('password123');
// Submit
await page.getByRole('button', { name: 'Login' }).click();
// Verify redirect
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('should show error on invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
});
});
Storybook Interaction Testing
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
};
export default meta;
export const ClickTest: StoryObj<typeof Button> = {
args: { children: 'Click me' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
// Test interaction
await userEvent.click(button);
// Assert
await expect(button).toHaveFocus();
},
};
Common Mistakes / Lỗi Thường Gặp
1. Testing Implementation Details
// ❌ BAD: Testing internal state
expect(component.state.isOpen).toBe(true);
// ✅ GOOD: Testing user-visible behavior
expect(screen.getByRole('dialog')).toBeVisible();
2. Not Waiting for Async Operations
// ❌ BAD: Immediate assertion
render(<AsyncComponent />);
expect(screen.getByText('Data')).toBeInTheDocument(); // Fails!
// ✅ GOOD: Wait for element
render(<AsyncComponent />);
expect(await screen.findByText('Data')).toBeInTheDocument();
3. Using getBy* for Absent Elements
// ❌ BAD: Throws error if not found
expect(screen.getByText('Error')).not.toBeInTheDocument();
// ✅ GOOD: Use queryBy* for absence checks
expect(screen.queryByText('Error')).not.toBeInTheDocument();
Quick Reference / Tham Chiếu Nhanh
| Query | Use Case |
|---|---|
getBy* |
Element must exist, throws if not |
queryBy* |
Element may not exist (for negative assertions) |
findBy* |
Async, waits for element |
*ByRole |
Preferred, most accessible |
*ByLabelText |
Form inputs |
*ByText |
Non-interactive text |
*ByTestId |
Last resort |
| Tool | Use Case |
|---|---|
| Vitest | Unit & integration tests |
| RTL | Component testing |
| userEvent | User interaction simulation |
| Playwright | E2E browser tests |
| MSW | API mocking |
Resources / Tài Nguyên
- React State Management - Store testing
- React UI Components - Component patterns
- Testing Library Docs
- Vitest Docs
- Playwright Docs