iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0

「功能看起來很簡單,」PM 指著設計稿說:「使用者輸入待辦事項,按下 Enter 或點擊按鈕就能新增。」你點點頭,心裡卻開始盤算:輸入驗證、狀態更新、UI 回饋、錯誤處理...每個看似簡單的功能背後,都藏著無數的邊界情況。今天,讓我們用 TDD 的方式,一步步實作新增 Todo 的功能!

🗺️ 第二十一天的旅程

測試基礎 → 進階觀念 → 框架特性 → 【實戰應用】← 我們在這裡
  Day 1-7    Day 8-13    Day 14-20    Day 21-27

經過前二十天的學習,我們已經熟悉了 React Testing Library 的操作、MSW 的 API 模擬,以及元件測試的各種技巧。今天要把這些知識整合起來,實作一個完整的新增 Todo 功能。

📝 分析需求

在開始寫測試之前,先列出新增 Todo 的需求:

  1. 基本輸入:使用者可以在輸入框中輸入待辦事項
  2. 提交方式:支援按下 Enter 鍵或點擊「新增」按鈕
  3. 輸入驗證:不允許空白或只有空格的待辦事項
  4. 狀態管理:成功新增後清空輸入框
  5. API 互動:透過 API 將新待辦事項儲存到後端

🧪 第一個測試:基本輸入功能

建立 tests/day21/AddTodo.test.tsx

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');
  });
});

建立 src/todo/AddTodo.tsx

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>
  );
}

🎯 測試提交功能

更新 tests/day21/AddTodo.test.tsx

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('');
  });
});

更新 src/todo/AddTodo.tsx

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>
  );
}

🛡️ 輸入驗證測試

更新 tests/day21/AddTodo.test.tsx

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();
  });
});

🌐 整合 API 測試

建立 tests/day21/TodoApp.test.tsx

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();
  });
});

完整實作 src/todo/AddTodo.tsx

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>
  );
}

🎯 本日重點整理

✅ 測試要點

  • 輸入測試:驗證使用者輸入行為
  • 提交測試:測試不同提交方式
  • 驗證測試:確保輸入驗證正確運作
  • 整合測試:測試與 API 的互動

🔧 技術重點

  • 使用 userEvent 模擬真實使用者操作
  • vi.fn() 建立 mock 函數、運用 MSW 模擬 API 回應

明天我們將學習如何測試更新和刪除 Todo 的功能,讓我們的待辦事項應用更加完整!

📚 參考資源


上一篇
Day 20 - 測試 TodoList 元件 📝
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言