高级场景测试 #
概述 #
本章介绍 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