「功能看起來很簡單,」PM 指著設計稿說:「使用者輸入待辦事項,按下 Enter 或點擊按鈕就能新增。」你點點頭,心裡卻開始盤算:輸入驗證、狀態更新、UI 回饋、錯誤處理...每個看似簡單的功能背後,都藏著無數的邊界情況。今天,讓我們用 TDD 的方式,一步步實作新增 Todo 的功能!
測試基礎 → 進階觀念 → 框架特性 → 【實戰應用】← 我們在這裡
Day 1-7 Day 8-13 Day 14-20 Day 21-27
經過前二十天的學習,我們已經熟悉了 React Testing Library 的操作、MSW 的 API 模擬,以及元件測試的各種技巧。今天要把這些知識整合起來,實作一個完整的新增 Todo 功能。
在開始寫測試之前,先列出新增 Todo 的需求:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AddTodo from '../../src/todo/AddTodo';
describe('AddTodo', () => {
it('rendersInputField', () => {
render(<AddTodo />);
const input = screen.getByPlaceholderText(/add a new todo/i);
expect(input).toBeInTheDocument();
});
it('allowsUserInput', async () => {
const user = userEvent.setup();
render(<AddTodo />);
const input = screen.getByPlaceholderText(/add a new todo/i);
await user.type(input, 'Learn TDD');
expect(input).toHaveValue('Learn TDD');
});
});
import { useState } from 'react';
export default function AddTodo() {
const [text, setText] = useState('');
return (
<div className="add-todo">
<input
type="text"
placeholder="Add a new todo"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</div>
);
}
describe('AddTodo submission', () => {
it('callsOnAddWithEnterKey', async () => {
const user = userEvent.setup();
const mockOnAdd = vi.fn();
render(<AddTodo onAdd={mockOnAdd} />);
const input = screen.getByPlaceholderText(/add a new todo/i);
await user.type(input, 'New Task{Enter}');
expect(mockOnAdd).toHaveBeenCalledWith('New Task');
});
it('callsOnAddWithButton', async () => {
const user = userEvent.setup();
const mockOnAdd = vi.fn();
render(<AddTodo onAdd={mockOnAdd} />);
const input = screen.getByPlaceholderText(/add a new todo/i);
const button = screen.getByRole('button', { name: /add/i });
await user.type(input, 'New Task');
await user.click(button);
expect(mockOnAdd).toHaveBeenCalledWith('New Task');
});
it('clearsInputAfterSubmit', async () => {
const user = userEvent.setup();
const mockOnAdd = vi.fn();
render(<AddTodo onAdd={mockOnAdd} />);
const input = screen.getByPlaceholderText(/add a new todo/i);
await user.type(input, 'New Task{Enter}');
expect(input).toHaveValue('');
});
});
import { useState, FormEvent } from 'react';
interface AddTodoProps {
onAdd?: (text: string) => void;
}
export default function AddTodo({ onAdd }: AddTodoProps) {
const [text, setText] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (text.trim() && onAdd) {
onAdd(text.trim());
setText('');
}
};
return (
<form className="add-todo" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Add a new todo"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add</button>
</form>
);
}
describe('AddTodo validation', () => {
it('rejectsEmptyInput', async () => {
const user = userEvent.setup();
const mockOnAdd = vi.fn();
render(<AddTodo onAdd={mockOnAdd} />);
const input = screen.getByPlaceholderText(/add a new todo/i);
await user.type(input, '{Enter}');
expect(mockOnAdd).not.toHaveBeenCalled();
});
it('showsValidationError', async () => {
const user = userEvent.setup();
render(<AddTodo onAdd={vi.fn()} />);
const button = screen.getByRole('button', { name: /add/i });
await user.click(button);
const error = await screen.findByText(/please enter a todo/i);
expect(error).toBeInTheDocument();
});
});
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import TodoApp from '../../src/todo/TodoApp';
const server = setupServer(
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: 1, text: 'Existing Todo', completed: false }
]);
}),
http.post('/api/todos', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
id: Date.now(),
text: body.text,
completed: false
}, { status: 201 });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('TodoApp - Add Todo', () => {
it('addsNewTodoViaAPI', async () => {
const user = userEvent.setup();
render(<TodoApp />);
await screen.findByText('Existing Todo');
const input = screen.getByPlaceholderText(/add a new todo/i);
const button = screen.getByRole('button', { name: /add/i });
await user.type(input, 'New Todo Item');
await user.click(button);
await screen.findByText('New Todo Item');
expect(input).toHaveValue('');
});
it('handlesAPIError', async () => {
server.use(
http.post('/api/todos', () => {
return new HttpResponse(null, { status: 500 });
})
);
const user = userEvent.setup();
render(<TodoApp />);
const input = screen.getByPlaceholderText(/add a new todo/i);
await user.type(input, 'Failed Todo{Enter}');
const error = await screen.findByText(/failed to add todo/i);
expect(error).toBeInTheDocument();
});
});
import { useState, FormEvent } from 'react';
interface AddTodoProps {
onAdd?: (text: string) => void | Promise<void>;
disabled?: boolean;
}
export default function AddTodo({ onAdd, disabled = false }: AddTodoProps) {
const [text, setText] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
if (!text.trim()) {
setError('Please enter a todo');
return;
}
if (onAdd) {
setIsSubmitting(true);
try {
await onAdd(text.trim());
setText('');
} catch (err) {
setError('Failed to add todo');
} finally {
setIsSubmitting(false);
}
}
};
const isDisabled = disabled || isSubmitting;
return (
<form className="add-todo" onSubmit={handleSubmit}>
<div className="input-group">
<input
type="text"
placeholder="Add a new todo"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={isDisabled}
aria-invalid={!!error}
aria-describedby={error ? 'todo-error' : undefined}
/>
<button type="submit" disabled={isDisabled}>
{isSubmitting ? 'Adding...' : 'Add'}
</button>
</div>
{error && (
<span id="todo-error" className="error" role="alert">
{error}
</span>
)}
</form>
);
}
userEvent
模擬真實使用者操作vi.fn()
建立 mock 函數、運用 MSW 模擬 API 回應明天我們將學習如何測試更新和刪除 Todo 的功能,讓我們的待辦事項應用更加完整!