beio Logobeio

【App 工程師的前端轉生術 10】最終章:依賴注入 (DI) 與單元測試策略

發布於

01. 前言:為什麼我不敢重構這段 Code?

作為 App 開發者,我們習慣了 TDD (Test-Driven Development) 或至少有 CI (Continuous Integration) 在背後守護。

  • Android: Hilt/Dagger 注入依賴,JUnit + Mockk 跑測試。
  • iOS: Swinject 注入依賴,XCTest 跑測試。

但在前端領域,很多專案是「手動測試」——改一行 Code,切換視窗,重新整理,手動點點看有沒有壞掉。這種開發方式不僅慢,而且充滿恐懼。

這系列的最後一篇,我們要為我們的 MVVM 架構加上最後一塊拼圖:可測試性 (Testability)

02. 輕量級 DI:不需要 Dagger 也能做注入

在 Mobile 開發中,我們習慣用強大的 DI Container (Hilt, Swinject)。但在 React/Next.js 中,我們通常不需要那麼重的工具。

我們的目標是:讓 ViewModel (Custom Hook) 不直接依賴具體的 Repository 實作,而是依賴介面。

修改 ViewModel 支援注入

回顧 Part 4 的 useLoginViewModel,原本它是直接 import authRepository。這導致測試時無法 Mock 網路請求。

我們利用 Default Arguments (預設參數) 來達成輕量級 DI,這對應到 App 開發中的 Constructor Injection

// src/features/auth/view-models/useLoginViewModel.ts

// 1. 定義依賴介面 (Contract)
interface LoginDeps {
  loginRepo?: IAuthRepository; // Part 3 定義的 Interface
}

// 2. 注入依賴 (預設值為真實的 Repository)
export const useLoginViewModel = ({
  loginRepo = authRepository, // ✨ Dependency Injection
}: LoginDeps = {}) => {
  
  const [state, setState] = useState(...);
  // const router = useRouter(); // Router 比較難 mock,通常會包一層 adapter 或用 jest.mock

  const login = async () => {
    // 使用注入的 repo,而不是寫死的 import
    // 這樣測試時就能換成 MockRepo
    await loginRepo.login(email, password); 
    // ...
  };

  return { login, ... };
};

現在,這個 Hook 在 Production 運行時會使用真實的 API,但在測試時,我們可以傳入一個假的 Mock Repo。

03. 測試環境建置:Jest + React Testing Library

這對應到 Mobile 的測試堆疊:

Mobile 概念前端工具
Test Runner (JUnit/XCTest)Jest (或 Vitest)
Mocking (Mockk/Mockito)jest.fn() / jest.mock()
Component Testing (Espresso/XCUITest)React Testing Library (RTL)
Hook Testing (ViewModel Testing)@testing-library/react-hooks

04. 實戰:測試 ViewModel (Business Logic)

這是 App 工程師最在意的部分:邏輯測試。我們不需要渲染 UI,只需要確認「輸入 A,狀態是否變成 B」。

// __tests__/useLoginViewModel.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useLoginViewModel } from '@/features/auth/view-models/useLoginViewModel';

// Mock Repository
const mockLogin = jest.fn();
const mockRepo = {
  login: mockLogin,
};

// Mock Next.js Router (解決 useRouter 在測試環境報錯的問題)
jest.mock('next/navigation', () => ({
  useRouter: () => ({ push: jest.fn() }),
}));

describe('LoginViewModel', () => {
  
  beforeEach(() => {
    mockLogin.mockClear();
  });

  test('初始狀態應為 idle', () => {
    const { result } = renderHook(() => useLoginViewModel({ loginRepo: mockRepo }));
    expect(result.current.isLoading).toBe(false);
  });

  test('當登入成功時,應呼叫 Repository', async () => {
    // Arrange: 設定 Mock 回傳成功
    mockLogin.mockResolvedValue({ id: '1', name: 'Ken' });
    
    const { result } = renderHook(() => useLoginViewModel({ loginRepo: mockRepo }));

    // Act: 執行登入 (因為有 State 更新,需包在 act 裡)
    await act(async () => {
      result.current.setEmail('test@example.com');
      result.current.setPassword('123456');
      await result.current.login();
    });

    // Assert
    expect(result.current.isLoading).toBe(false); // 結束後 loading 解除
    expect(mockLogin).toHaveBeenCalledTimes(1);   // Repo 被呼叫過
  });

  test('當 API 失敗時,應顯示錯誤訊息', async () => {
    // Arrange: 設定 Mock 拋出錯誤
    mockLogin.mockRejectedValue(new Error('密碼錯誤'));
    
    const { result } = renderHook(() => useLoginViewModel({ loginRepo: mockRepo }));

    // Act
    await act(async () => {
      // 設定有效輸入以通過驗證
      result.current.setEmail('test@example.com'); 
      result.current.setPassword('123456');
      await result.current.login();
    });

    // Assert
    expect(result.current.errorMessage).toBe('密碼錯誤');
  });
});

App 工程師視角: 這看起來是不是跟測試 Kotlin ViewModel 或 Swift ObservableObject 幾乎一樣?透過 renderHook 獲取 result.current,我們完全掌握了 Hook 的內部狀態。

05. 整合測試:UI Component Testing

有時候我們想測試「View 是否正確綁定 ViewModel」。這在 Android 類似 Robolectric 或 Compose UI Test。

我們測試的是 User Behavior,而不是 Implementation Detail。

// __tests__/LoginPage.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import LoginPage from '@/app/login/page';

// 這裡我們 Mock 整個 ViewModel Hook
// 這樣我們就不需要管 Repo,只專注測 UI 對 ViewModel 狀態的反應
jest.mock('@/features/auth/view-models/useLoginViewModel', () => ({
  useLoginViewModel: () => ({
    email: '',
    isLoading: true, // 模擬 Loading 中
    login: jest.fn(),
  })
}));

test('當 ViewModel 處於 Loading 狀態,按鈕應被禁用', () => {
  render(<LoginPage />);
  
  // 尋找按鈕 (就像 Espresso 的 onView(withId(...)))
  // getByRole 比較推薦,因為它模擬真實使用者的查找方式
  const button = screen.getByRole('button', { name: /登入/i });
  
  // 斷言
  expect(button).toBeDisabled();
  expect(button).toHaveTextContent('登入中...'); // 檢查文字變化
});

06. 系列總結:你的全端之路

恭喜你!如果你讀完了這 10 篇文章,你已經具備了用 App 架構思維 開發 Next.js 的能力。

讓我們最後回顧一次這個 MVVM Architecture Mapping,這也是你轉生後的武器庫:

層級App 概念 (iOS/Android)Next.js 對應實作系列篇章
RuntimeSDK / Application / ActivityNext.js App Router / Layout[Part 1, 2]
ModelCodable / SerializableZod Schema + TypeScript[Part 3]
DataRepository / ServiceRepository Pattern[Part 3]
LogicViewModelCustom Hooks[Part 4]
UISwiftUI View / ComposableAtomic Components[Part 5]
StylingModifiersTailwind CSS[Part 6]
StateSingleton / ObservableZustand / Context[Part 7]
BackendBackend APIAPI Routes / Server Actions[Part 8]
PerfProfiler / InstrumentsReact DevTools / Web Vitals[Part 9]
TestJUnit / XCTestJest / React Testing Library[Part 10]

給 App 工程師的最後建議

前端技術變遷極快,今天流行 Next.js,明天可能是 Remix 或其他框架。但架構觀念是永恆的

不要被為了追求新潮而寫出「義大利麵代碼」的教學誤導。堅持你的 SOLID 原則,堅持 Separation of Concerns,你會發現,Web 前端其實只是另一個你稍微陌生的 UI 平台,而你早已是個熟練的架構師。

Build strong, ship fast. Happy Coding!


Ken Huang

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner