Files
pos-system/microservices/.agent/skills/react-testing-patterns/SKILL.md
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

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
author version
Velik Ho 1.0

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