iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 22

Day 22: 30天打造SaaS產品前端篇-元件測試進階與測試驅動開發 (TDD)

  • 分享至 

  • xImage
  •  

前情提要

在 Day 21 我們建立了 Vitest 和 React Testing Library 的測試環境。今天我們要實作元件測試的進階技巧測試驅動開發 (TDD) 的實踐,透過實際案例學習如何開發高品質的前端測試。

測試驅動開發 (TDD) 的三大定律

/**
 * TDD 的紅綠重構循環
 *
 * 🔴 Red: 先寫一個會失敗的測試
 *    - 定義預期行為
 *    - 確保測試真的會失敗
 *
 * 🟢 Green: 寫最少的程式碼讓測試通過
 *    - 只要能通過測試即可
 *    - 先求有,再求好
 *
 * 🔵 Refactor: 重構程式碼但保持測試通過
 *    - 改善程式碼品質
 *    - 消除重複
 *    - 優化設計
 *
 * 重複這個循環,直到功能完成
 */

實戰案例:使用 TDD 開發會員切換元件

讓我們用 TDD 的方式從零開始開發一個租戶切換元件:

第一輪:紅 → 綠 → 重構

// src/components/TenantSwitcher/TenantSwitcher.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { TenantSwitcher } from './TenantSwitcher';

describe('TenantSwitcher', () => {
  // 🔴 紅: 先寫測試
  it('should display current tenant name', () => {
    const currentTenant = {
      id: '1',
      name: 'Gym A',
      slug: 'gym-a',
    };

    render(<TenantSwitcher current={currentTenant} tenants={[]} />);

    // 期望看到當前租戶名稱
    expect(screen.getByText('Gym A')).toBeInTheDocument();
  });
});

執行測試,應該會失敗 (因為元件還不存在)。現在寫最簡單的實作:

// src/components/TenantSwitcher/TenantSwitcher.tsx
// 🟢 綠: 最簡單的實作
interface Tenant {
  id: string;
  name: string;
  slug: string;
}

interface TenantSwitcherProps {
  current: Tenant;
  tenants: Tenant[];
}

export function TenantSwitcher({ current }: TenantSwitcherProps) {
  return <div>{current.name}</div>;
}

測試通過! 但程式碼還很陽春,繼續下一個測試:

第二輪:新增切換功能

// TenantSwitcher.test.tsx
it('should show tenant list when clicked', async () => {
  const user = userEvent.setup();
  const currentTenant = {
    id: '1',
    name: 'Gym A',
    slug: 'gym-a',
  };
  const tenants = [
    currentTenant,
    { id: '2', name: 'Gym B', slug: 'gym-b' },
    { id: '3', name: 'Gym C', slug: 'gym-c' },
  ];

  render(<TenantSwitcher current={currentTenant} tenants={tenants} />);

  // 點擊當前租戶
  await user.click(screen.getByText('Gym A'));

  // 應該顯示所有租戶
  expect(screen.getByText('Gym B')).toBeInTheDocument();
  expect(screen.getByText('Gym C')).toBeInTheDocument();
});

現在讓測試通過:

// TenantSwitcher.tsx
import { useState } from 'react';
import { Menu, Button } from '@mantine/core';

export function TenantSwitcher({ current, tenants }: TenantSwitcherProps) {
  const [opened, setOpened] = useState(false);

  return (
    <Menu opened={opened} onChange={setOpened}>
      <Menu.Target>
        <Button onClick={() => setOpened(!opened)}>
          {current.name}
        </Button>
      </Menu.Target>

      <Menu.Dropdown>
        {tenants.map(tenant => (
          <Menu.Item key={tenant.id}>
            {tenant.name}
          </Menu.Item>
        ))}
      </Menu.Dropdown>
    </Menu>
  );
}

第三輪:新增切換回調

// TenantSwitcher.test.tsx
it('should call onSwitch when tenant is selected', async () => {
  const user = userEvent.setup();
  const onSwitch = vi.fn();
  const currentTenant = {
    id: '1',
    name: 'Gym A',
    slug: 'gym-a',
  };
  const tenants = [
    currentTenant,
    { id: '2', name: 'Gym B', slug: 'gym-b' },
  ];

  render(
    <TenantSwitcher
      current={currentTenant}
      tenants={tenants}
      onSwitch={onSwitch}
    />
  );

  // 開啟選單
  await user.click(screen.getByText('Gym A'));

  // 選擇 Gym B
  await user.click(screen.getByText('Gym B'));

  // 應該呼叫 onSwitch
  expect(onSwitch).toHaveBeenCalledWith({
    id: '2',
    name: 'Gym B',
    slug: 'gym-b',
  });
});

實作:

// TenantSwitcher.tsx
interface TenantSwitcherProps {
  current: Tenant;
  tenants: Tenant[];
  onSwitch?: (tenant: Tenant) => void;
}

export function TenantSwitcher({ current, tenants, onSwitch }: TenantSwitcherProps) {
  const [opened, setOpened] = useState(false);

  const handleSelect = (tenant: Tenant) => {
    setOpened(false);
    onSwitch?.(tenant);
  };

  return (
    <Menu opened={opened} onChange={setOpened}>
      <Menu.Target>
        <Button onClick={() => setOpened(!opened)}>
          {current.name}
        </Button>
      </Menu.Target>

      <Menu.Dropdown>
        {tenants.map(tenant => (
          <Menu.Item
            key={tenant.id}
            onClick={() => handleSelect(tenant)}
          >
            {tenant.name}
          </Menu.Item>
        ))}
      </Menu.Dropdown>
    </Menu>
  );
}

第四輪:視覺優化與狀態顯示

// TenantSwitcher.test.tsx
it('should highlight current tenant in the list', async () => {
  const user = userEvent.setup();
  const currentTenant = {
    id: '1',
    name: 'Gym A',
    slug: 'gym-a',
  };
  const tenants = [
    currentTenant,
    { id: '2', name: 'Gym B', slug: 'gym-b' },
  ];

  render(<TenantSwitcher current={currentTenant} tenants={tenants} />);

  await user.click(screen.getByText('Gym A'));

  // 當前租戶應該有特殊標記
  const currentItem = screen.getByRole('menuitem', { name: /Gym A/i });
  expect(currentItem).toHaveAttribute('data-active', 'true');
});

it('should show tenant switch loading state', () => {
  const currentTenant = {
    id: '1',
    name: 'Gym A',
    slug: 'gym-a',
  };

  render(
    <TenantSwitcher
      current={currentTenant}
      tenants={[]}
      isLoading={true}
    />
  );

  expect(screen.getByRole('button')).toHaveAttribute('data-loading', 'true');
});

完整實作:

// TenantSwitcher.tsx
import { useState } from 'react';
import { Menu, Button, Loader } from '@mantine/core';
import { IconBuilding, IconCheck } from '@tabler/icons-react';

interface Tenant {
  id: string;
  name: string;
  slug: string;
}

interface TenantSwitcherProps {
  current: Tenant;
  tenants: Tenant[];
  onSwitch?: (tenant: Tenant) => void;
  isLoading?: boolean;
}

export function TenantSwitcher({
  current,
  tenants,
  onSwitch,
  isLoading = false,
}: TenantSwitcherProps) {
  const [opened, setOpened] = useState(false);

  const handleSelect = (tenant: Tenant) => {
    if (tenant.id === current.id) return;
    setOpened(false);
    onSwitch?.(tenant);
  };

  return (
    <Menu opened={opened} onChange={setOpened} width={260}>
      <Menu.Target>
        <Button
          leftSection={<IconBuilding size={16} />}
          rightSection={isLoading ? <Loader size="xs" /> : null}
          variant="subtle"
          data-loading={isLoading}
        >
          {current.name}
        </Button>
      </Menu.Target>

      <Menu.Dropdown>
        <Menu.Label>切換租戶</Menu.Label>
        {tenants.map(tenant => {
          const isCurrent = tenant.id === current.id;
          return (
            <Menu.Item
              key={tenant.id}
              onClick={() => handleSelect(tenant)}
              data-active={isCurrent}
              rightSection={isCurrent ? <IconCheck size={16} /> : null}
              style={{
                backgroundColor: isCurrent
                  ? 'var(--mantine-color-blue-light)'
                  : undefined,
              }}
            >
              {tenant.name}
            </Menu.Item>
          );
        })}
      </Menu.Dropdown>
    </Menu>
  );
}

進階測試技巧

1. 測試非同步狀態更新

// src/components/CourseScheduler/CourseScheduler.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CourseScheduler } from './CourseScheduler';

describe('CourseScheduler - Async Operations', () => {
  let queryClient: QueryClient;

  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
        mutations: { retry: false },
      },
    });
  });

  it('should show loading state while fetching courses', async () => {
    // Mock API 延遲回應
    const mockFetch = vi.fn(() =>
      new Promise(resolve =>
        setTimeout(() => resolve({ data: [] }), 100)
      )
    );

    render(
      <QueryClientProvider client={queryClient}>
        <CourseScheduler fetchCourses={mockFetch} />
      </QueryClientProvider>
    );

    // 應該立即顯示 loading
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();

    // 等待資料載入完成
    await waitFor(() => {
      expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
    });

    expect(mockFetch).toHaveBeenCalledOnce();
  });

  it('should handle API errors gracefully', async () => {
    const mockFetch = vi.fn(() =>
      Promise.reject(new Error('Network error'))
    );

    render(
      <QueryClientProvider client={queryClient}>
        <CourseScheduler fetchCourses={mockFetch} />
      </QueryClientProvider>
    );

    // 等待錯誤訊息出現
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });

    expect(screen.getByText(/network error/i)).toBeInTheDocument();
  });

  it('should refetch data when retry button is clicked', async () => {
    const user = userEvent.setup();
    const mockFetch = vi
      .fn()
      .mockRejectedValueOnce(new Error('Failed'))
      .mockResolvedValueOnce({ data: [{ id: 1, name: 'Course A' }] });

    render(
      <QueryClientProvider client={queryClient}>
        <CourseScheduler fetchCourses={mockFetch} />
      </QueryClientProvider>
    );

    // 等待錯誤出現
    await waitFor(() => {
      expect(screen.getByText(/failed/i)).toBeInTheDocument();
    });

    // 點擊重試按鈕
    const retryButton = screen.getByRole('button', { name: /retry/i });
    await user.click(retryButton);

    // 等待成功載入
    await waitFor(() => {
      expect(screen.getByText('Course A')).toBeInTheDocument();
    });

    expect(mockFetch).toHaveBeenCalledTimes(2);
  });
});

2. 測試複雜的使用者互動

// src/components/Members/MemberForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { MemberForm } from './MemberForm';

describe('MemberForm - Complex Interactions', () => {
  it('should validate form before submission', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();

    render(<MemberForm onSubmit={onSubmit} />);

    // 不填寫任何欄位直接送出
    const submitButton = screen.getByRole('button', { name: /submit/i });
    await user.click(submitButton);

    // 應該顯示驗證錯誤
    expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();

    // 不應該呼叫 onSubmit
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it('should validate email format in real-time', async () => {
    const user = userEvent.setup();
    render(<MemberForm />);

    const emailInput = screen.getByLabelText(/email/i);

    // 輸入無效的 email
    await user.type(emailInput, 'invalid-email');
    await user.tab(); // 觸發 blur 事件

    // 應該顯示格式錯誤
    expect(
      await screen.findByText(/invalid email format/i)
    ).toBeInTheDocument();

    // 修正為有效的 email
    await user.clear(emailInput);
    await user.type(emailInput, 'user@example.com');
    await user.tab();

    // 錯誤訊息應該消失
    await waitFor(() => {
      expect(
        screen.queryByText(/invalid email format/i)
      ).not.toBeInTheDocument();
    });
  });

  it('should handle multi-step form navigation', async () => {
    const user = userEvent.setup();
    render(<MemberForm mode="create" steps={['basic', 'membership', 'payment']} />);

    // Step 1: 基本資訊
    expect(screen.getByText(/step 1/i)).toBeInTheDocument();
    await user.type(screen.getByLabelText(/name/i), 'John Doe');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');

    // 下一步
    await user.click(screen.getByRole('button', { name: /next/i }));

    // Step 2: 會員方案
    await waitFor(() => {
      expect(screen.getByText(/step 2/i)).toBeInTheDocument();
    });
    await user.click(screen.getByLabelText(/premium plan/i));

    // 返回上一步
    await user.click(screen.getByRole('button', { name: /back/i }));

    // 應該回到 Step 1,且資料保留
    expect(screen.getByLabelText(/name/i)).toHaveValue('John Doe');

    // 再次前進
    await user.click(screen.getByRole('button', { name: /next/i }));
    await user.click(screen.getByRole('button', { name: /next/i }));

    // Step 3: 付款資訊
    expect(screen.getByText(/step 3/i)).toBeInTheDocument();
  });
});

3. 測試 Zustand Store

// src/stores/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAuthStore } from './auth';

describe('AuthStore', () => {
  beforeEach(() => {
    // 重置 store 狀態
    useAuthStore.setState({
      user: null,
      token: null,
      isAuthenticated: false,
    });
  });

  it('should initialize with unauthenticated state', () => {
    const { result } = renderHook(() => useAuthStore());

    expect(result.current.user).toBeNull();
    expect(result.current.token).toBeNull();
    expect(result.current.isAuthenticated).toBe(false);
  });

  it('should set user and token on login', () => {
    const { result } = renderHook(() => useAuthStore());

    const user = {
      id: '1',
      name: 'John Doe',
      email: 'john@example.com',
    };
    const token = 'mock-jwt-token';

    act(() => {
      result.current.login(user, token);
    });

    expect(result.current.user).toEqual(user);
    expect(result.current.token).toBe(token);
    expect(result.current.isAuthenticated).toBe(true);
  });

  it('should clear state on logout', () => {
    const { result } = renderHook(() => useAuthStore());

    // 先登入
    act(() => {
      result.current.login(
        { id: '1', name: 'John', email: 'john@test.com' },
        'token'
      );
    });

    expect(result.current.isAuthenticated).toBe(true);

    // 登出
    act(() => {
      result.current.logout();
    });

    expect(result.current.user).toBeNull();
    expect(result.current.token).toBeNull();
    expect(result.current.isAuthenticated).toBe(false);
  });

  it('should persist token to localStorage on login', () => {
    const { result } = renderHook(() => useAuthStore());
    const token = 'persistent-token';

    act(() => {
      result.current.login(
        { id: '1', name: 'Jane', email: 'jane@test.com' },
        token
      );
    });

    expect(localStorage.getItem('auth-token')).toBe(token);
  });

  it('should remove token from localStorage on logout', () => {
    const { result } = renderHook(() => useAuthStore());

    // 先設置 token
    localStorage.setItem('auth-token', 'some-token');

    act(() => {
      result.current.logout();
    });

    expect(localStorage.getItem('auth-token')).toBeNull();
  });
});

4. 測試自訂 Hooks

// src/hooks/useAuth.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useAuth } from './useAuth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

describe('useAuth Hook', () => {
  let queryClient: QueryClient;

  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
      },
    });
  });

  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );

  it('should return current user when authenticated', async () => {
    // Mock API
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () =>
          Promise.resolve({
            user: { id: '1', name: 'Test User' },
          }),
      })
    ) as any;

    const { result } = renderHook(() => useAuth(), { wrapper });

    await waitFor(() => {
      expect(result.current.user).toEqual({
        id: '1',
        name: 'Test User',
      });
      expect(result.current.isLoading).toBe(false);
    });
  });

  it('should handle login mutation', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () =>
          Promise.resolve({
            token: 'new-token',
            user: { id: '2', name: 'Logged In User' },
          }),
      })
    ) as any;

    const { result } = renderHook(() => useAuth(), { wrapper });

    // 執行登入
    act(() => {
      result.current.login({
        email: 'user@test.com',
        password: 'password',
      });
    });

    await waitFor(() => {
      expect(result.current.isAuthenticated).toBe(true);
      expect(result.current.user?.name).toBe('Logged In User');
    });
  });
});

測試最佳實踐

1. 使用測試 ID 的時機

// ❌ 避免過度使用 data-testid
<div data-testid="user-profile">
  <h1 data-testid="user-name">{user.name}</h1>
  <p data-testid="user-email">{user.email}</p>
</div>

// ✅ 優先使用語義化查詢
<div role="region" aria-label="User Profile">
  <h1>{user.name}</h1>
  <p>{user.email}</p>
</div>

// 測試:
expect(screen.getByRole('region', { name: /user profile/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(user.name);

2. Mock 的最小化原則

// ❌ 過度 mock
vi.mock('@mantine/core', () => ({
  Button: ({ children, onClick }: any) => (
    <button onClick={onClick}>{children}</button>
  ),
  Menu: ({ children }: any) => <div>{children}</div>,
  // ... mock 所有元件
}));

// ✅ 只 mock 必要的外部依賴
vi.mock('./api/client', () => ({
  fetchCourses: vi.fn(),
  createCourse: vi.fn(),
}));

// 保持 UI 元件使用真實實作

3. 測試描述的清晰性

// ❌ 不清楚的測試描述
it('works', () => {
  // ...
});

it('test case 1', () => {
  // ...
});

// ✅ 清楚描述行為
it('should display error message when email format is invalid', () => {
  // ...
});

it('should call onSubmit with form data when all validations pass', () => {
  // ...
});

測試覆蓋率報告

# 執行測試並生成覆蓋率報告
pnpm test -- --coverage

# 覆蓋率報告範例:
# ┌────────────────────┬───────┬────────┬─────────┬─────────┐
# │ File               │ % Stmts│ % Branch│ % Funcs │ % Lines │
# ├────────────────────┼───────┼────────┼─────────┼─────────┤
# │ TenantSwitcher.tsx │  95.45│  88.89 │  100.00 │  95.23  │
# │ MemberForm.tsx     │  87.50│  81.25 │   90.00 │  86.96  │
# │ useAuth.ts         │  92.31│  85.71 │   88.89 │  91.67  │
# └────────────────────┴───────┴────────┴─────────┴─────────┘

今日總結

我們今天實作了前端測試的進階技巧:

核心功能

  1. TDD 實踐: 紅→綠→重構循環
  2. 元件測試: 從簡單到複雜的漸進式開發
  3. 非同步測試: 處理 API 呼叫、loading、error
  4. 狀態測試: Zustand store 和自訂 hooks

技術特色

  • 使用者中心: 優先使用語義化查詢
  • 真實場景: 模擬完整的使用者互動
  • 最小 Mock: 只 mock 必要的外部依賴
  • 清晰描述: 測試即文件

測試策略

  • 先寫測試,後寫實作 (TDD)
  • 從最簡單的實作開始
  • 逐步增加功能和測試
  • 重構時保持測試通過

上一篇
Day 21: 30天打造SaaS產品前端篇 - React Testing Library 與元件測試策略
下一篇
Day 23: 30天打造SaaS產品前端篇-測試覆蓋率報告與效能優化
系列文
30 天製作工作室 SaaS 產品 (前端篇)24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言