【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 對應實作 | 系列篇章 |
|---|---|---|---|
| Runtime | SDK / Application / Activity | Next.js App Router / Layout | [Part 1, 2] |
| Model | Codable / Serializable | Zod Schema + TypeScript | [Part 3] |
| Data | Repository / Service | Repository Pattern | [Part 3] |
| Logic | ViewModel | Custom Hooks | [Part 4] |
| UI | SwiftUI View / Composable | Atomic Components | [Part 5] |
| Styling | Modifiers | Tailwind CSS | [Part 6] |
| State | Singleton / Observable | Zustand / Context | [Part 7] |
| Backend | Backend API | API Routes / Server Actions | [Part 8] |
| Perf | Profiler / Instruments | React DevTools / Web Vitals | [Part 9] |
| Test | JUnit / XCTest | Jest / React Testing Library | [Part 10] |
給 App 工程師的最後建議
前端技術變遷極快,今天流行 Next.js,明天可能是 Remix 或其他框架。但架構觀念是永恆的。
不要被為了追求新潮而寫出「義大利麵代碼」的教學誤導。堅持你的 SOLID 原則,堅持 Separation of Concerns,你會發現,Web 前端其實只是另一個你稍微陌生的 UI 平台,而你早已是個熟練的架構師。
Build strong, ship fast. Happy Coding!

關於作者
Ken Huang
熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。
最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。
這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。