Vitest 前端測試教學筆記:Unit Test → Component Test

AdSense

面試題

Take an example of a frontend unit test and a component test.

Unit Test:專注邏輯與純函式

目標

  • 不依賴 UI 或 DOM。
  • 驗證「輸入 → 輸出」是否正確。
  • 執行快速、穩定。

範例:查詢參數組裝函式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// utils/buildSearchQuery.ts
export function buildSearchQuery(criteria: {
keyword: string;
status: string;
dateRange: { from: string; to: string };
}) {
const params = new URLSearchParams();

if (criteria.keyword) params.append("keyword", criteria.keyword);
if (criteria.status) params.append("status", criteria.status);
if (criteria.dateRange.from) params.append("from", criteria.dateRange.from);
if (criteria.dateRange.to) params.append("to", criteria.dateRange.to);

return params.toString();
}

對應測試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// utils/buildSearchQuery.test.ts
import { describe, it, expect } from "vitest";
import { buildSearchQuery } from "./buildSearchQuery";

describe("buildSearchQuery - Unit Test", () => {
it("should build correct query string", () => {
const query = buildSearchQuery({
keyword: "invoice",
status: "Pending",
dateRange: { from: "2025-01-01", to: "2025-01-31" }
});
expect(query).toBe("keyword=invoice&status=Pending&from=2025-01-01&to=2025-01-31");
});

it("should ignore empty fields", () => {
const query = buildSearchQuery({
keyword: "",
status: "",
dateRange: { from: "", to: "" }
});
expect(query).toBe("");
});

it("should encode special characters", () => {
const query = buildSearchQuery({
keyword: "sales report",
status: "",
dateRange: { from: "", to: "" }
});
expect(query).toBe("keyword=sales+report");
});
});

Component Test:專注 UI 與互動行為

目標

  • 在 jsdom 環境 render 元件。
  • 模擬使用者輸入、點擊、送出。
  • 驗證狀態轉換、驗證訊息、loading、reset 等行為。

範例元件:SearchFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// SearchFilter.tsx
import { useState } from "react";

interface SearchCriteria {
keyword: string;
status: string;
dateRange: { from: string; to: string };
}

interface SearchFilterProps {
onSearch: (criteria: SearchCriteria) => Promise<void> | void;
}

export default function SearchFilter({ onSearch }: SearchFilterProps) {
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

const reset = () => {
setKeyword("");
setStatus("");
setFrom("");
setTo("");
setError("");
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (from && to && from > to) {
setError("Date range is invalid");
return;
}

setError("");
setLoading(true);
await onSearch({ keyword, status, dateRange: { from, to } });
setLoading(false);
};

return (
<form onSubmit={handleSubmit}>
<input
placeholder="Keyword"
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>

<select
aria-label="Status"
value={status}
onChange={e => setStatus(e.target.value)}
>
<option value="">-- Select Status --</option>
<option value="Pending">Pending</option>
<option value="Approved">Approved</option>
</select>

<input
aria-label="From"
type="date"
value={from}
onChange={e => setFrom(e.target.value)}
/>

<input
aria-label="To"
type="date"
value={to}
onChange={e => setTo(e.target.value)}
/>

{error && <p role="alert">{error}</p>}

<button type="submit" disabled={loading}>
{loading ? "Searching..." : "Search"}
</button>

<button type="button" onClick={reset} disabled={loading}>
Reset
</button>
</form>
);
}

對應測試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// SearchFilter.test.tsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import SearchFilter from "./SearchFilter";

describe("SearchFilter - Component Test", () => {
it("should submit valid criteria", async () => {
const mockOnSearch = vi.fn().mockResolvedValue(undefined);
render(<SearchFilter onSearch={mockOnSearch} />);

fireEvent.change(screen.getByPlaceholderText("Keyword"), {
target: { value: "report" }
});
fireEvent.change(screen.getByLabelText("Status"), {
target: { value: "Pending" }
});
fireEvent.change(screen.getByLabelText("From"), {
target: { value: "2025-01-01" }
});
fireEvent.change(screen.getByLabelText("To"), {
target: { value: "2025-01-31" }
});

fireEvent.click(screen.getByRole("button", { name: "Search" }));

await waitFor(() => {
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: "report",
status: "Pending",
dateRange: { from: "2025-01-01", to: "2025-01-31" }
});
});
});

it("should show error when date range is invalid", () => {
const mockOnSearch = vi.fn();
render(<SearchFilter onSearch={mockOnSearch} />);

fireEvent.change(screen.getByLabelText("From"), {
target: { value: "2025-02-10" }
});
fireEvent.change(screen.getByLabelText("To"), {
target: { value: "2025-01-01" }
});

fireEvent.click(screen.getByRole("button", { name: "Search" }));

expect(screen.getByRole("alert")).toHaveTextContent("Date range is invalid");
expect(mockOnSearch).not.toHaveBeenCalled();
});

it("should disable Search button while loading", async () => {
const mockOnSearch = vi.fn().mockImplementation(
() => new Promise(resolve => setTimeout(resolve, 100))
);
render(<SearchFilter onSearch={mockOnSearch} />);

fireEvent.click(screen.getByRole("button", { name: "Search" }));
expect(screen.getByRole("button", { name: "Searching..." })).toBeDisabled();

await waitFor(() =>
expect(screen.getByRole("button", { name: "Search" })).not.toBeDisabled()
);
});

it("should reset fields when Reset button is clicked", () => {
const mockOnSearch = vi.fn();
render(<SearchFilter onSearch={mockOnSearch} />);

fireEvent.change(screen.getByPlaceholderText("Keyword"), {
target: { value: "hello" }
});

fireEvent.click(screen.getByRole("button", { name: "Reset" }));

expect(screen.getByPlaceholderText("Keyword")).toHaveValue("");
});
});

1) fireEvent 和 screen 是什麼?

  • screen:由 React Testing Library 提供,用來在測試中找畫面上的元素。因為測試會把你的 component 渲染成 DOM (網頁結構),而 screen 就像是一支放大鏡,用來在已 render 的「虛擬畫面」中查找元素的全域查詢器。常用方法有 getByRolegetByTextgetByLabelTextgetByPlaceholderText 等。

    1
    2
    screen.getByPlaceholderText("Keyword"); // 找到 placeholder 為 Keyword 的輸入框
    screen.getByRole("button", { name: /search/i }); // 找到名稱為 Search 的按鈕
  • fireEvent:用來模擬使用者動作,例如 change、click、submit 等。

    1
    2
    3
    4
    fireEvent.change(screen.getByPlaceholderText("Keyword"), {
    target: { value: "invoice" }
    });
    fireEvent.click(screen.getByRole("button", { name: /search/i }));

2) expect(mockOnSearch).toHaveBeenCalledWith({...}) 的功用

  • 它斷言mockOnSearch 有被呼叫,而且帶著完全符合的參數物件。如下:驗證子元件確實把整組表單條件以正確格式回傳給父層。

    1
    2
    3
    4
    5
    expect(mockOnSearch).toHaveBeenCalledWith({
    keyword: "invoice",
    status: "Pending",
    dateRange: { from: "2025-01-01", to: "2025-01-31" }
    });

3) toBeInTheDocumenttoHaveDisplayValuetoHaveTextContent

  • expect(...).toBeInTheDocument();:只能檢查「元素存在」,不能保證文字內容正確。
  • expect(...).toHaveDisplayValue("Approved");是給 <input><select><textarea> 用的,檢查「值」。不是<p><div>
  • expect(...).toHaveTextContent("Date range is invalid");<p><div> 用的,檢查「一般文字內容」。

例子:

1
2
3
expect(screen.getByText("Date range is invalid")).toBeInTheDocument();
expect(screen.getByLabelText("Status")).toHaveDisplayValue("Approved");
expect(screen.getByRole("alert")).toHaveTextContent("Date range is invalid");

4) 為什麼一個用 mockResolvedValue(undefined),另一個用「延遲 Promise」?

  • 「提交參數正確 (should submit valid criteria)」的測試只需要確認 callback 被正確呼叫,使用:

    1
    const mockOnSearch = vi.fn().mockResolvedValue(undefined); // 立即 resolve

    這樣不會拖慢測試,也不關心 loading。

    • 「載入時按鈕被停用 (should disable Search button while loading)」的測試需要真的等待一段時間,才能看到按鈕先變成 disabled 再復原:
    1
    2
    3
    const mockOnSearch = vi.fn().mockImplementation(
    () => new Promise(resolve => setTimeout(resolve, 100))
    );

    這能模擬 API 延遲,才能測得出「Searching…」與 disabled 狀態。如果直接用 vi.fn(),它會立即結束,setLoading(false) 也會立刻執行,這樣就不會觸發 loading 狀態,測不出 disabled 狀態。

5) 為什麼用 await waitFor(() => expect(...).not.toBeDisabled())

  • waitFor 跟「一般 await」不一樣,並不是用來「等某個 Promise 結束」,而是重複執行內部的 expect(...),直到它通過或超時,適合等待「非同步 UI 更新」。如下:斷言是「按鈕不再 disabled」。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
      await waitFor(() =>
    expect(screen.getByRole("button", { name: "Search" })).not.toBeDisabled();
    );
    ```

    * <span style="color: #ff0000">以下是錯誤的。</span>React 的狀態更新是非同步的,`await onSearch()` 完成後才會 `setLoading(false)`,畫面需要時間回復。如果像是下面的寫法,即使 `expect(mockOnSearch).toHaveBeenCalledWith(...)` 成功,但是當程式跑到 line 4 時,按鈕可能還沒切回 "Search",測試就會失敗。
    ```ts=
    await waitFor(() => {
    expect(mockOnSearch).toHaveBeenCalledWith({ ... });
    });
    expect(screen.getByRole("button", { name: "Search" })).not.toBeDisabled();

建議與實務小技巧

  • 查找元素時優先使用可近用性查詢:getByRole(帶 name),其次 getByLabelTextgetByPlaceholderTextgetByText
  • 文字內容斷言用 toHaveTextContent,輸入類用 toHaveValuetoHaveDisplayValue
  • 非同步 UI 更新一律搭配 await waitFor(...)
  • 需要觀察 loading 狀態時,務必用回傳延遲 Promise 的 mock,避免假陽性或測不到。
  • Unit Test 驗證純邏輯,Component Test 驗證整體互動與副作用;同一功能拆兩層測,維護性與可讀性最佳。

Mid level 面試回答

In my project, I separate unit tests and component tests because they validate different layers of functionality.

For example, I had a SearchFilter feature:

  • At the unit level, I tested the business logic separately. I extracted a helper function called buildSearchQuery that converts the search criteria object into a query string. Since this function has no UI and no dependencies, it’s suitable for a fast and reliable unit test.

  • At the component level, I wrote tests using Vitest + React Testing Library to verify UI behavior, such as validating the form, disabling the button during loading, and emitting the search criteria when the user clicks “Search”.

By separating unit tests and component tests, I follow a maintainable testing strategy based on the testing pyramid:

  • Unit tests → fast, isolated logic
  • Component tests → UI behavior and interactions
  • Integration tests → end-to-end data flow (optional based on project)

This approach keeps the test suite reliable, maintainable, and efficient as the application grows.