当前位置: 首页 > article >正文

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(<Counter />);

    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
      onClick={onClick}
      disabled={disabled}
      className="btn"
    >
      {children}
    </button>
  );
}

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

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

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

    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('can be disabled', () => {
    const handleClick = jest.fn();
    render(
      <Button onClick={handleClick} disabled>
        Click me
      </Button>
    );

    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(<LoginForm onSubmit={mockSubmit} />);

    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(<LoginForm onSubmit={mockSubmit} />);

    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(<LoginForm onSubmit={mockSubmit} />);

    await userEvent.type(
      screen.getByLabelText(/email/i),
      'test@example.com'
    );
    await userEvent.type(
      screen.getByLabelText(/password/i),
      'password123'
    );

    fireEvent.click(screen.getByRole('button', { name: /submit/i }));

    await waitFor(() => {
      expect(mockSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        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: 'john@example.com'
      })
    );
  })
);

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

describe('UserProfile', () => {
  it('displays loading state initially', () => {
    render(<UserProfile userId={1} />);
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

  it('displays user data after successful fetch', async () => {
    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
      expect(screen.getByText('john@example.com')).toBeInTheDocument();
    });
  });

  it('handles error state', 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/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 }) => (
    <AuthProvider>{children}</AuthProvider>
  );

  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('test@example.com', 'password');
    });

    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user).toEqual({
      email: 'test@example.com'
    });
  });

  it('handles logout', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper });

    await act(async () => {
      await result.current.login('test@example.com', '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(
      <Router history={history}>
        <App />
      </Router>
    );

    // 首页
    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(
      <Router history={history}>
        <App />
      </Router>
    );

    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(
      <Provider store={store}>
        <TodoList />
      </Provider>
    );

    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(
      <Provider store={store}>
        <TodoList />
      </Provider>
    );

    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('test@example.com');

    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', 'test@example.com');
  });

  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('wrong@example.com');

    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 的部署和持续集成策略,帮助你构建完整的开发流程。

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


http://www.kler.cn/a/507213.html

相关文章:

  • linux手动安装mysql5.7
  • 【redis】redis-cli命令行工具的使用
  • MySQL 事务
  • 我的世界-与门、或门、非门等基本门电路实现
  • FLASK创建下载
  • QT 如何禁止QComboBox鼠标滚轮
  • CMake构建C#工程(protobuf)
  • Web 实时消息推送的七种实现方案
  • SpringBoot链接Kafka
  • 在 .NET 9 中使用 Scalar 替代 Swagger
  • 基于 Python 的财经数据接口库:AKShare
  • NFTScan | 01.06~01.12 NFT 市场热点汇总
  • 图论基础,如何快速上手图论?
  • Redis哨兵模式搭建示例(配置开机自启)
  • 代码随想录25 回溯算法
  • 78_Redis网络模型
  • K8S--边车容器
  • 如何Python机器学习、深度学习技术提升气象、海洋、水文?
  • 2025第3周 | json-server的基本使用
  • Linux下使用MySql数据库
  • 采用海豚调度器+Doris开发数仓保姆级教程(满满是踩坑干货细节,持续更新)
  • 浏览器中的Markdown编辑器
  • 【2024年华为OD机试】(B卷,100分)- 相对开音节 (Java JS PythonC/C++)
  • java常用开发工具类
  • uniapp 自定义日历组件 源码
  • Spring Boot中的自动配置原理是什么