Vue 开发者的 React 实战指南:测试篇

作为 Vue 开发者,在迁移到 React 开发时,测试策略和方法也需要相应调整。本文将从 Vue 开发者熟悉的角度出发,详细介绍 React 中的测试方法和最佳实践。

测试工具对比

Vue 的测试工具

在 Vue 生态中,我们通常使用:

  • Vue Test Utils:官方的组件测试工具
  • Jest:单元测试框架
  • Cypress:端到端测试工具
// Vue 组件测试示例
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';

describe('Counter.vue', () => {
  it('increments count when button is clicked', async () => {
    const wrapper = mount(Counter);
    
    expect(wrapper.text()).toContain('0');
    
    await wrapper.find('button').trigger('click');
    
    expect(wrapper.text()).toContain('1');
  });
});

React 的测试工具

在 React 生态中,我们主要使用:

  • React Testing Library:官方推荐的测试工具
  • Jest:单元测试框架
  • Cypress:端到端测试工具
// React 组件测试示例
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

describe('Counter', () => {
  it('increments count when button is clicked', () => {
    render();
    
    expect(screen.getByText('0')).toBeInTheDocument();
    
    fireEvent.click(screen.getByRole('button'));
    
    expect(screen.getByText('1')).toBeInTheDocument();
  });
});

主要区别:

  1. 测试理念

    • Vue Test Utils 偏向实现细节
    • React Testing Library 偏向用户行为
  2. API 设计

    • Vue 使用 wrapper API
    • React 使用 DOM API
  3. 查询方式

    • Vue 可以直接访问组件实例
    • React 推荐使用可访问性查询

组件测试

1. 基础组件测试

// Button.tsx
function Button({ onClick, children, disabled }) {
  return (
    
  );
}

// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

describe('Button', () => {
  it('renders children correctly', () => {
    render();
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });
  
  it('handles click events', () => {
    const handleClick = jest.fn();
    render();
    
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
  
  it('can be disabled', () => {
    const handleClick = jest.fn();
    render(
      
    );
    
    const button = screen.getByText('Click me');
    expect(button).toBeDisabled();
    
    fireEvent.click(button);
    expect(handleClick).not.toHaveBeenCalled();
  });
});

2. 表单组件测试

// LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm', () => {
  const mockSubmit = jest.fn();
  
  beforeEach(() => {
    mockSubmit.mockClear();
  });
  
  it('validates required fields', async () => {
    render();
    
    fireEvent.click(screen.getByRole('button', { name: /submit/i }));
    
    expect(await screen.findByText(/username is required/i)).toBeInTheDocument();
    expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
    expect(mockSubmit).not.toHaveBeenCalled();
  });
  
  it('validates email format', async () => {
    render();
    
    await userEvent.type(
      screen.getByLabelText(/email/i),
      'invalid-email'
    );
    
    fireEvent.click(screen.getByRole('button', { name: /submit/i }));
    
    expect(await screen.findByText(/invalid email format/i)).toBeInTheDocument();
    expect(mockSubmit).not.toHaveBeenCalled();
  });
  
  it('submits form with valid data', async () => {
    render();
    
    await userEvent.type(
      screen.getByLabelText(/email/i),
      '[email protected]'
    );
    await userEvent.type(
      screen.getByLabelText(/password/i),
      'password123'
    );
    
    fireEvent.click(screen.getByRole('button', { name: /submit/i }));
    
    await waitFor(() => {
      expect(mockSubmit).toHaveBeenCalledWith({
        email: '[email protected]',
        password: 'password123'
      });
    });
  });
});

3. 异步组件测试

// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserProfile from './UserProfile';

const server = setupServer(
  rest.get('/api/user/:id', (req, res, ctx) => {
    return res(
      ctx.json({
        id: 1,
        name: 'John Doe',
        email: '[email protected]'
      })
    );
  })
);

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

describe('UserProfile', () => {
  it('displays loading state initially', () => {
    render();
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });
  
  it('displays user data after successful fetch', async () => {
    render();
    
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
      expect(screen.getByText('[email protected]')).toBeInTheDocument();
    });
  });
  
  it('handles error state', async () => {
    server.use(
      rest.get('/api/user/:id', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );
    
    render();
    
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});

Hook 测试

1. 自定义 Hook 测试

// useCounter.ts
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);
  
  return { count, increment, decrement, reset };
}

// useCounter.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });
  
  it('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
  
  it('increments counter', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  it('decrements counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
  
  it('resets counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(5);
  });
});

2. Context Hook 测试

// useAuth.test.tsx
import { renderHook, act } from '@testing-library/react-hooks';
import { AuthProvider, useAuth } from './useAuth';

describe('useAuth', () => {
  const wrapper = ({ children }) => (
    {children}
  );
  
  it('provides authentication state', () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    expect(result.current.isAuthenticated).toBe(false);
    expect(result.current.user).toBeNull();
  });
  
  it('handles login', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    await act(async () => {
      await result.current.login('[email protected]', 'password');
    });
    
    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user).toEqual({
      email: '[email protected]'
    });
  });
  
  it('handles logout', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    await act(async () => {
      await result.current.login('[email protected]', 'password');
      await result.current.logout();
    });
    
    expect(result.current.isAuthenticated).toBe(false);
    expect(result.current.user).toBeNull();
  });
});

集成测试

1. 路由测试

// App.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import App from './App';

describe('App routing', () => {
  it('navigates to different pages', () => {
    const history = createMemoryHistory();
    render(
      
        
      
    );
    
    // 首页
    expect(screen.getByText(/welcome/i)).toBeInTheDocument();
    
    // 导航到关于页
    fireEvent.click(screen.getByText(/about/i));
    expect(screen.getByText(/about us/i)).toBeInTheDocument();
    
    // 导航到用户页
    fireEvent.click(screen.getByText(/users/i));
    expect(screen.getByText(/user list/i)).toBeInTheDocument();
  });
  
  it('handles 404 pages', () => {
    const history = createMemoryHistory();
    history.push('/invalid-route');
    
    render(
      
        
      
    );
    
    expect(screen.getByText(/404/i)).toBeInTheDocument();
  });
});

2. Redux 集成测试

// store.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
import TodoList from './TodoList';

describe('TodoList with Redux', () => {
  let store;
  
  beforeEach(() => {
    store = configureStore({
      reducer: rootReducer,
      preloadedState: {
        todos: []
      }
    });
  });
  
  it('adds new todo', () => {
    render(
      
        
      
    );
    
    const input = screen.getByPlaceholderText(/add todo/i);
    fireEvent.change(input, { target: { value: 'New Todo' } });
    fireEvent.click(screen.getByText(/add/i));
    
    expect(screen.getByText('New Todo')).toBeInTheDocument();
    expect(store.getState().todos).toHaveLength(1);
  });
  
  it('toggles todo completion', () => {
    store = configureStore({
      reducer: rootReducer,
      preloadedState: {
        todos: [
          { id: 1, text: 'Test Todo', completed: false }
        ]
      }
    });
    
    render(
      
        
      
    );
    
    fireEvent.click(screen.getByText('Test Todo'));
    
    expect(store.getState().todos[0].completed).toBe(true);
  });
});

端到端测试

使用 Cypress

// cypress/integration/auth.spec.js
describe('Authentication', () => {
  beforeEach(() => {
    cy.visit('/login');
  });
  
  it('successfully logs in', () => {
    cy.get('[data-testid="email-input"]')
      .type('[email protected]');
    
    cy.get('[data-testid="password-input"]')
      .type('password123');
    
    cy.get('[data-testid="login-button"]')
      .click();
    
    cy.url().should('include', '/dashboard');
    cy.get('[data-testid="user-profile"]')
      .should('contain', '[email protected]');
  });
  
  it('displays validation errors', () => {
    cy.get('[data-testid="login-button"]')
      .click();
    
    cy.get('[data-testid="email-error"]')
      .should('be.visible')
      .and('contain', 'Email is required');
    
    cy.get('[data-testid="password-error"]')
      .should('be.visible')
      .and('contain', 'Password is required');
  });
  
  it('handles invalid credentials', () => {
    cy.get('[data-testid="email-input"]')
      .type('[email protected]');
    
    cy.get('[data-testid="password-input"]')
      .type('wrongpassword');
    
    cy.get('[data-testid="login-button"]')
      .click();
    
    cy.get('[data-testid="error-message"]')
      .should('be.visible')
      .and('contain', 'Invalid credentials');
  });
});

测试最佳实践

  1. 测试策略

    • 遵循测试金字塔
    • 关注用户行为
    • 避免测试实现细节
    • 保持测试简单
  2. 代码组织

    • 合理组织测试文件
    • 使用测试工具函数
    • 避免重复代码
    • 维护测试数据
  3. 性能考虑

    • 合理使用 mock
    • 避免不必要的等待
    • 优化测试运行时间
    • 并行运行测试

小结

  1. React 测试的特点:

    • 行为驱动测试
    • 可访问性优先
    • 组件化测试
    • 工具链完善
  2. 从 Vue 到 React 的转变:

    • 适应新的测试理念
    • 掌握测试工具
    • 建立测试意识
    • 实践测试策略
  3. 开发建议:

    • 先写测试再实现
    • 保持测试简单
    • 关注测试覆盖
    • 持续维护测试

下一篇文章,我们将深入探讨 React 的部署和持续集成策略,帮助你构建完整的开发流程。

如果觉得这篇文章对你有帮助,别忘了点个赞

你可能感兴趣的:(Vue 开发者的 React 实战指南:测试篇)