高级场景测试 #

概述 #

本章介绍 Testing Library 的高级测试场景,帮助你处理复杂的测试需求:

text
┌─────────────────────────────────────────────────────────────┐
│                     高级测试场景                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  错误边界测试                                                │
│  ├── 测试错误捕获                                            │
│  ├── 测试备用 UI                                             │
│  └── 测试错误恢复                                            │
│                                                             │
│  Portal 测试                                                 │
│  ├── 测试 Portal 渲染                                        │
│  └── 测试 Modal 组件                                         │
│                                                             │
│  第三方组件测试                                               │
│  ├── Mock 第三方库                                           │
│  └── 测试集成                                                │
│                                                             │
│  快照测试                                                    │
│  ├── 组件快照                                                │
│  └── 交互快照                                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

错误边界测试 #

创建错误边界 #

jsx
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  
  componentDidCatch(error, errorInfo) {
    console.error('Error caught:', error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return this.props.fallback || <div>Something went wrong</div>;
    }
    
    return this.props.children;
  }
}

测试错误边界 #

jsx
function ThrowError({ shouldThrow }) {
  if (shouldThrow) {
    throw new Error('Test error');
  }
  return <div>OK</div>;
}

test('ErrorBoundary catches errors', () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  
  render(
    <ErrorBoundary>
      <ThrowError shouldThrow={true} />
    </ErrorBoundary>
  );
  
  expect(screen.getByText('Something went wrong')).toBeInTheDocument();
  
  spy.mockRestore();
});

test('ErrorBoundary renders children when no error', () => {
  render(
    <ErrorBoundary>
      <ThrowError shouldThrow={false} />
    </ErrorBoundary>
  );
  
  expect(screen.getByText('OK')).toBeInTheDocument();
});

测试自定义 Fallback #

jsx
test('ErrorBoundary with custom fallback', () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  
  render(
    <ErrorBoundary fallback={<div role="alert">Error occurred</div>}>
      <ThrowError shouldThrow={true} />
    </ErrorBoundary>
  );
  
  expect(screen.getByRole('alert')).toHaveTextContent('Error occurred');
  
  spy.mockRestore();
});

测试错误恢复 #

jsx
function ErrorBoundaryWithReset({ children }) {
  const [hasError, setHasError] = useState(false);
  
  const resetError = () => setHasError(false);
  
  const handleError = () => setHasError(true);
  
  if (hasError) {
    return (
      <div>
        <span>Error occurred</span>
        <button onClick={resetError}>Try again</button>
      </div>
    );
  }
  
  return (
    <ErrorBoundaryHandler onError={handleError}>
      {children}
    </ErrorBoundaryHandler>
  );
}

test('ErrorBoundary reset functionality', async () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  const user = userEvent.setup();
  
  const { rerender } = render(
    <ErrorBoundaryWithReset>
      <ThrowError shouldThrow={true} />
    </ErrorBoundaryWithReset>
  );
  
  expect(screen.getByText('Error occurred')).toBeInTheDocument();
  
  await user.click(screen.getByRole('button', { name: /try again/i }));
  
  rerender(
    <ErrorBoundaryWithReset>
      <ThrowError shouldThrow={false} />
    </ErrorBoundaryWithReset>
  );
  
  expect(screen.getByText('OK')).toBeInTheDocument();
  
  spy.mockRestore();
});

Portal 测试 #

测试 Modal 组件 #

jsx
function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  
  return ReactDOM.createPortal(
    <div role="dialog" aria-modal="true">
      <div className="modal-content">
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

test('Modal renders in portal', async () => {
  const user = userEvent.setup();
  const onClose = jest.fn();
  
  render(
    <Modal isOpen={true} onClose={onClose}>
      <h1>Modal Title</h1>
    </Modal>
  );
  
  expect(screen.getByRole('dialog')).toBeInTheDocument();
  expect(screen.getByText('Modal Title')).toBeInTheDocument();
  
  await user.click(screen.getByRole('button', { name: /close/i }));
  
  expect(onClose).toHaveBeenCalled();
});

测试 Modal 开关 #

jsx
function ModalTrigger() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        Modal Content
      </Modal>
    </div>
  );
}

test('Modal open and close', async () => {
  const user = userEvent.setup();
  
  render(<ModalTrigger />);
  
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  
  await user.click(screen.getByRole('button', { name: /open modal/i }));
  
  expect(screen.getByRole('dialog')).toBeInTheDocument();
  
  await user.click(screen.getByRole('button', { name: /close/i }));
  
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

测试 Portal 容器 #

jsx
beforeEach(() => {
  const portalRoot = document.createElement('div');
  portalRoot.setAttribute('id', 'modal-root');
  document.body.appendChild(portalRoot);
});

afterEach(() => {
  const portalRoot = document.getElementById('modal-root');
  if (portalRoot) {
    document.body.removeChild(portalRoot);
  }
});

test('Portal renders in specific container', () => {
  render(<Modal isOpen={true}>Content</Modal>);
  
  const portalRoot = document.getElementById('modal-root');
  expect(portalRoot).toContainElement(screen.getByText('Content'));
});

第三方组件测试 #

Mock UI 库组件 #

jsx
jest.mock('some-ui-library', () => ({
  Button: ({ children, onClick }) => (
    <button onClick={onClick}>{children}</button>
  ),
  Input: ({ value, onChange, placeholder }) => (
    <input
      value={value}
      onChange={onChange}
      placeholder={placeholder}
    />
  ),
}));

function FormWithUILibrary() {
  const [value, setValue] = useState('');
  
  return (
    <form>
      <Input
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="Enter value"
      />
      <Button onClick={() => console.log(value)}>Submit</Button>
    </form>
  );
}

test('form with mocked UI library', async () => {
  const user = userEvent.setup();
  const spy = jest.spyOn(console, 'log').mockImplementation();
  
  render(<FormWithUILibrary />);
  
  await user.type(screen.getByPlaceholderText('Enter value'), 'test');
  await user.click(screen.getByRole('button', { name: /submit/i }));
  
  expect(spy).toHaveBeenCalledWith('test');
  
  spy.mockRestore();
});

测试 React Router 集成 #

jsx
import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom';

function Navigation() {
  return (
    <nav>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
    </nav>
  );
}

test('navigation with MemoryRouter', async () => {
  const user = userEvent.setup();
  
  render(
    <MemoryRouter initialEntries={['/']}>
      <Navigation />
      <Routes>
        <Route path="/" element={<div>Home Page</div>} />
        <Route path="/about" element={<div>About Page</div>} />
      </Routes>
    </MemoryRouter>
  );
  
  expect(screen.getByText('Home Page')).toBeInTheDocument();
  
  await user.click(screen.getByRole('link', { name: /about/i }));
  
  expect(screen.getByText('About Page')).toBeInTheDocument();
});

测试 Redux 集成 #

jsx
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';

function renderWithRedux(
  ui,
  {
    preloadedState,
    store = configureStore({ reducer: rootReducer, preloadedState }),
    ...options
  } = {}
) {
  const Wrapper = ({ children }) => (
    <Provider store={store}>{children}</Provider>
  );
  
  return { store, ...render(ui, { wrapper: Wrapper, ...options }) };
}

function Counter() {
  const count = useSelector((state) => state.counter);
  const dispatch = useDispatch();
  
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+</button>
    </div>
  );
}

test('counter with Redux', async () => {
  const user = userEvent.setup();
  const { store } = renderWithRedux(<Counter />, {
    preloadedState: { counter: 5 },
  });
  
  expect(screen.getByText('5')).toBeInTheDocument();
  
  await user.click(screen.getByRole('button', { name: '+' }));
  
  expect(screen.getByText('6')).toBeInTheDocument();
  expect(store.getState().counter).toBe(6);
});

快照测试 #

组件快照 #

jsx
import renderer from 'react-test-renderer';

function Button({ children, variant = 'primary' }) {
  return (
    <button className={`btn btn-${variant}`}>
      {children}
    </button>
  );
}

test('Button snapshot', () => {
  const tree = renderer
    .create(<Button variant="primary">Click me</Button>)
    .toJSON();
  
  expect(tree).toMatchSnapshot();
});

使用 Testing Library 快照 #

jsx
test('Button snapshot with Testing Library', () => {
  const { asFragment } = render(<Button>Click me</Button>);
  
  expect(asFragment()).toMatchSnapshot();
});

交互快照 #

jsx
function Accordion({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        {title} {isOpen ? '−' : '+'}
      </button>
      {isOpen && <div className="content">{children}</div>}
    </div>
  );
}

test('Accordion open state snapshot', async () => {
  const user = userEvent.setup();
  const { asFragment } = render(
    <Accordion title="Section">Content</Accordion>
  );
  
  expect(asFragment()).toMatchSnapshot('closed state');
  
  await user.click(screen.getByRole('button'));
  
  expect(asFragment()).toMatchSnapshot('open state');
});

内联快照 #

jsx
test('inline snapshot', () => {
  const { asFragment } = render(<Button>Click</Button>);
  
  expect(asFragment()).toMatchInlineSnapshot(`
    <DocumentFragment>
      <button>
        Click
      </button>
    </DocumentFragment>
  `);
});

表单验证测试 #

测试验证规则 #

jsx
function ValidatedForm() {
  const [errors, setErrors] = useState({});
  const [values, setValues] = useState({ email: '', password: '' });
  
  const validate = () => {
    const newErrors = {};
    
    if (!values.email) {
      newErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(values.email)) {
      newErrors.email = 'Invalid email format';
    }
    
    if (!values.password) {
      newErrors.password = 'Password is required';
    } else if (values.password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters';
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (validate()) {
      console.log('Form submitted', values);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          value={values.email}
          onChange={(e) => setValues({ ...values, email: e.target.value })}
        />
        {errors.email && <span role="alert">{errors.email}</span>}
      </div>
      
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={values.password}
          onChange={(e) => setValues({ ...values, password: e.target.value })}
        />
        {errors.password && <span role="alert">{errors.password}</span>}
      </div>
      
      <button type="submit">Submit</button>
    </form>
  );
}

test('form validation', async () => {
  const user = userEvent.setup();
  const spy = jest.spyOn(console, 'log').mockImplementation();
  
  render(<ValidatedForm />);
  
  await user.click(screen.getByRole('button', { name: /submit/i }));
  
  expect(screen.getByText('Email is required')).toBeInTheDocument();
  expect(screen.getByText('Password is required')).toBeInTheDocument();
  
  await user.type(screen.getByLabelText(/email/i), 'invalid-email');
  await user.type(screen.getByLabelText(/password/i), 'short');
  await user.click(screen.getByRole('button', { name: /submit/i }));
  
  expect(screen.getByText('Invalid email format')).toBeInTheDocument();
  expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument();
  
  await user.clear(screen.getByLabelText(/email/i));
  await user.type(screen.getByLabelText(/email/i), 'test@example.com');
  await user.clear(screen.getByLabelText(/password/i));
  await user.type(screen.getByLabelText(/password/i), 'password123');
  await user.click(screen.getByRole('button', { name: /submit/i }));
  
  expect(spy).toHaveBeenCalled();
  
  spy.mockRestore();
});

可访问性测试 #

使用 jest-axe #

jsx
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

function AccessibleButton() {
  return (
    <button aria-label="Close dialog">
      <span aria-hidden="true">×</span>
    </button>
  );
}

test('should have no accessibility violations', async () => {
  const { container } = render(<AccessibleButton />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

测试 ARIA 属性 #

jsx
function Tabs() {
  const [activeTab, setActiveTab] = useState(0);
  
  return (
    <div>
      <div role="tablist">
        <button
          role="tab"
          aria-selected={activeTab === 0}
          onClick={() => setActiveTab(0)}
        >
          Tab 1
        </button>
        <button
          role="tab"
          aria-selected={activeTab === 1}
          onClick={() => setActiveTab(1)}
        >
          Tab 2
        </button>
      </div>
      <div role="tabpanel">
        {activeTab === 0 ? 'Content 1' : 'Content 2'}
      </div>
    </div>
  );
}

test('Tabs accessibility', async () => {
  const user = userEvent.setup();
  
  render(<Tabs />);
  
  const tabs = screen.getAllByRole('tab');
  
  expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
  expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
  
  await user.click(tabs[1]);
  
  expect(tabs[0]).toHaveAttribute('aria-selected', 'false');
  expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});

性能测试 #

测试渲染次数 #

jsx
function ExpensiveComponent({ data }) {
  const processedData = useMemo(() => {
    return data.map((item) => ({ ...item, processed: true }));
  }, [data]);
  
  return (
    <ul>
      {processedData.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

test('memoization prevents unnecessary renders', () => {
  const data = [{ id: 1, name: 'Item 1' }];
  
  const { rerender } = render(<ExpensiveComponent data={data} />);
  
  rerender(<ExpensiveComponent data={data} />);
  
  expect(screen.getAllByRole('listitem')).toHaveLength(1);
});

测试技巧 #

等待多个更新 #

jsx
test('wait for multiple updates', async () => {
  render(<AsyncComponent />);
  
  await waitFor(() => {
    expect(screen.getByText('Step 1')).toBeInTheDocument();
  });
  
  await waitFor(() => {
    expect(screen.getByText('Step 2')).toBeInTheDocument();
  });
  
  await waitFor(() => {
    expect(screen.getByText('Complete')).toBeInTheDocument();
  });
});

测试组件卸载 #

jsx
function ComponentWithCleanup() {
  useEffect(() => {
    return () => {
      console.log('Cleanup');
    };
  }, []);
  
  return <div>Content</div>;
}

test('cleanup on unmount', () => {
  const spy = jest.spyOn(console, 'log').mockImplementation();
  
  const { unmount } = render(<ComponentWithCleanup />);
  
  unmount();
  
  expect(spy).toHaveBeenCalledWith('Cleanup');
  
  spy.mockRestore();
});

测试 Refs #

jsx
function InputWithRef() {
  const inputRef = useRef(null);
  
  const focusInput = () => {
    inputRef.current?.focus();
  };
  
  return (
    <div>
      <input ref={inputRef} />
      <button onClick={focusInput}>Focus</button>
    </div>
  );
}

test('ref focus', async () => {
  const user = userEvent.setup();
  
  render(<InputWithRef />);
  
  const input = screen.getByRole('textbox');
  
  await user.click(screen.getByRole('button', { name: /focus/i }));
  
  expect(input).toHaveFocus();
});

最佳实践 #

推荐做法 #

jsx
// ✅ 测试用户可见的行为
test('user can submit form', async () => {
  await user.type(screen.getByLabelText('Email'), 'test@example.com');
  await user.click(screen.getByRole('button', { name: /submit/i }));
  expect(screen.getByText('Success')).toBeInTheDocument();
});

// ✅ 使用语义化查询
screen.getByRole('button');
screen.getByLabelText('Email');

// ✅ 测试可访问性
const results = await axe(container);
expect(results).toHaveNoViolations();

避免做法 #

jsx
// ❌ 测试实现细节
expect(component.state.count).toBe(1);

// ❌ 使用不稳定的查询
screen.getByTestId('submit-btn');

// ❌ 忽略可访问性
// 缺少 aria 属性测试

下一步 #

现在你已经掌握了高级测试场景,接下来学习 最佳实践 了解更多测试技巧和规范!

最后更新:2026-03-28