iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0

「單元測試都過了,為什麼整合起來還是壞掉?」資深工程師搖搖頭,「因為你只測試了零件,沒測試組裝。」

今天,我們要把前 28 天學到的所有技巧組合起來,打造一個完整的 React 應用整合測試策略!

今日旅程地圖

基礎測試 [✅]        Kata 練習 [✅]        框架實戰 [✅]        整合部署 [🔄]
Day 1-10            Day 11-17            Day 18-27           Day 28-30
                                                                 ↑ 我們在這裡!

整合測試的挑戰

// 建立 tests/day29/integration-challenge.test.tsx
import { describe, it, expect } from 'vitest';

describe('Integration Challenges', () => {
  it('shows why unit tests are not enough', () => {
    // 單元測試 A: ✅ 通過
    const formatDate = (date: Date) => date.toLocaleDateString();
    
    // 單元測試 B: ✅ 通過
    const calculateDays = (start: Date, end: Date) => {
      return Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
    };
    
    // 整合時: ❌ 時區問題!
    const startDate = new Date('2024-01-01T00:00:00Z');
    const endDate = new Date('2024-01-02T23:59:59Z');
    const days = calculateDays(startDate, endDate);
    
    expect(days).toBe(1); // 可能失敗!
  });
});

完整的 Todo App 整合測試

// 建立 tests/day29/todo-integration.test.tsx
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import TodoApp from '../../src/todo/TodoApp';

const server = setupServer(
  http.get('/api/todos', () => {
    return HttpResponse.json([
      { id: 1, title: '學習 TDD', completed: false },
      { id: 2, title: '寫測試', completed: true }
    ]);
  }),
  http.post('/api/todos', async ({ request }) => {
    const body = await request.json() as { title: string };
    return HttpResponse.json({
      id: 3, title: body.title, completed: false
    }, { status: 201 });
  })
);

beforeEach(() => server.listen());
afterEach(() => server.resetHandlers());
afterEach(() => server.close());

describe('Todo App Integration', () => {
  const renderTodoApp = () => {
    const queryClient = new QueryClient({
      defaultOptions: { queries: { retry: false }, mutations: { retry: false } }
    });
    return render(
      <QueryClientProvider client={queryClient}>
        <TodoApp />
      </QueryClientProvider>
    );
  };
  
  it('performs complete CRUD operations', async () => {
    const user = userEvent.setup();
    renderTodoApp();
    
    // 載入初始資料
    await waitFor(() => {
      expect(screen.getByText('學習 TDD')).toBeInTheDocument();
    });
    
    // 新增待辦事項
    const input = screen.getByPlaceholderText('新增待辦事項...');
    await user.type(input, '整合測試');
    await user.keyboard('{Enter}');
    
    await waitFor(() => {
      expect(screen.getByText('整合測試')).toBeInTheDocument();
    });
    
    // 標記完成
    const todoItem = screen.getByText('學習 TDD').closest('li');
    const checkbox = within(todoItem!).getByRole('checkbox');
    await user.click(checkbox);
    
    await waitFor(() => {
      expect(checkbox).toBeChecked();
    });
  });
});

跨元件通訊測試

// 建立 tests/day29/cross-component.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TodoProvider } from '../../src/todo/TodoContext';
import TodoList from '../../src/todo/TodoList';
import TodoStats from '../../src/todo/TodoStats';

describe('Cross Component Communication', () => {
  it('syncs statistics updates', async () => {
    const user = userEvent.setup();
    
    render(
      <TodoProvider>
        <TodoList />
        <TodoStats />
      </TodoProvider>
    );
    
    // 初始狀態
    expect(screen.getByText(/總計: 0/i)).toBeInTheDocument();
    
    // 新增待辦
    const input = screen.getByPlaceholderText('新增待辦...');
    await user.type(input, '測試項目');
    await user.keyboard('{Enter}');
    
    // 統計立即更新
    await waitFor(() => {
      expect(screen.getByText(/總計: 1/i)).toBeInTheDocument();
    });
  });
});

路由整合測試

// 建立 tests/day29/routing-integration.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import App from '../../src/App';
import Settings from '../../src/Settings';

describe('Routing Integration', () => {
  it('navigates through complete flow', async () => {
    const user = userEvent.setup();
    
    render(
      <MemoryRouter initialEntries={['/']}>
        <Routes>
          <Route path="/" element={<App />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </MemoryRouter>
    );
    
    expect(screen.getByText(/待辦清單/i)).toBeInTheDocument();
    
    const settingsLink = screen.getByRole('link', { name: /設定/i });
    await user.click(settingsLink);
    
    await waitFor(() => {
      expect(screen.getByText(/應用程式設定/i)).toBeInTheDocument();
    });
  });
});

狀態管理整合

// 建立 tests/day29/state-integration.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import todoReducer from '../../src/store/todoSlice';
import userReducer from '../../src/store/userSlice';
import Dashboard from '../../src/Dashboard';

describe('State Management Integration', () => {
  it('syncs cross slice state', async () => {
    const store = configureStore({
      reducer: { todos: todoReducer, user: userReducer }
    });
    const user = userEvent.setup();
    
    render(<Provider store={store}><Dashboard /></Provider>);
    
    const loginButton = screen.getByRole('button', { name: /登入/i });
    await user.click(loginButton);
    
    await waitFor(() => {
      expect(screen.getByText(/歡迎, TestUser/i)).toBeInTheDocument();
      expect(screen.getByText(/個人待辦事項/i)).toBeInTheDocument();
    });
  });
});

完整實作:整合測試套件

// 完整實作 tests/day29/complete-integration-suite.test.tsx
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from '../../src/store';
import ErrorBoundary from '../../src/ErrorBoundary';
import App from '../../src/App';

const createTestWrapper = () => {
  const store = configureStore({ reducer: rootReducer });
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false }, mutations: { retry: false } }
  });
  
  return ({ children }: { children: React.ReactNode }) => (
    <Provider store={store}>
      <QueryClientProvider client={queryClient}>
        <MemoryRouter>
          <ErrorBoundary>{children}</ErrorBoundary>
        </MemoryRouter>
      </QueryClientProvider>
    </Provider>
  );
};

const server = setupServer();
beforeEach(() => server.listen());
afterEach(() => server.resetHandlers());
afterEach(() => server.close());

describe('Complete Integration Suite', () => {
  it('completes end to end user flow', async () => {
    const user = userEvent.setup();
    
    // 設定 API 模擬
    server.use(
      http.post('/api/login', async ({ request }) => {
        const body = await request.json() as { username: string };
        return HttpResponse.json({
          token: 'test-token',
          user: { id: 1, name: 'Test User' }
        });
      }),
      http.get('/api/todos', () => {
        return HttpResponse.json([
          { id: 1, title: '整合測試', completed: false }
        ]);
      })
    );
    
    const TestWrapper = createTestWrapper();
    render(<App />, { wrapper: TestWrapper });
    
    // 登入流程
    const usernameInput = screen.getByLabelText(/使用者名稱/i);
    const loginButton = screen.getByRole('button', { name: /登入/i });
    
    await user.type(usernameInput, 'test');
    await user.click(loginButton);
    
    // 驗證登入成功
    await waitFor(() => {
      expect(screen.getByText(/歡迎, Test User/i)).toBeInTheDocument();
    });
    
    // 載入待辦事項
    await waitFor(() => {
      expect(screen.getByText('整合測試')).toBeInTheDocument();
    });
    
    // 互動測試
    const todoCheckbox = screen.getByRole('checkbox');
    await user.click(todoCheckbox);
    
    await waitFor(() => {
      expect(todoCheckbox).toBeChecked();
    });
  });
});

今日回顧

今天我們完成了完整的整合測試實戰:

已達成

  1. 整合測試策略 - 建立完整的測試架構
  2. 跨元件通訊 - 測試元件間的互動
  3. 路由整合 - 驗證導航流程
  4. 狀態管理 - 確保狀態同步
  5. 端到端流程 - 完整使用者旅程

重要觀念

  • 整合測試填補單元測試的空缺
  • 測試真實的使用者流程
  • 確保系統整體運作正常

明日預告

明天是我們旅程的最後一天,我們將部署整個應用並總結這 30 天的學習!


上一篇
Day 28 - 整合準備 🔧
下一篇
Day 30 - 部署與總結 🎊
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言