Vitest - Mocking
前言
在撰寫測試時,經常遇到的挑戰是大部分程式碼包含 Side Effect,並非單純的 Pure Function。這些程式碼可能依賴外部套件、網路等因素,增加了測試的複雜性和不穩定性。此外,測試時通常不希望直接依賴外部 API,以免因外部因素的變動導致 CI 測試失敗、誤觸發 DoS、或超出速率限制。
為了解決這些問題,可以透過 mock 方法來模擬函式的返回值或 API 的回應,讓測試聚焦於當前程式碼的邏輯。
@Vitest Mocking Doc
@Fast Unit Testing With Vitest - 1:04:30
範例
用一個簡單 React 程式碼來分別演示如何 Mock API Fetching 和 Mock hook :
CharacterList- 提供打關鍵字的搜尋框,並會渲染出對應的角色列表useSearch- 會根據關鍵字(keyword),返回包含關鍵字的物件陣列useCharacters- 執行打 API 的邏輯函式,且返回 API 相關狀態
CharacterList 中有使用到 useCharacters 和 useSearch 這兩個 hooks。
// CharacterList.tsx - 提供打關鍵字的搜尋框,並會渲染出對應的角色列表
const CharacterList = () => {
  const { characters } = useCharacters();
  const { keyword, filteredItems, setKeyword } = useSearch<Character>(
    characters,
    ["name"]
  );
  return (
    <div>
      <input
        type="text"
        value={keyword}
        placeholder="Search characters"
        onChange={(e) => setKeyword(e.target.value)}
      />
      <ul>
        {filteredItems.map((character) => (
          <li key={character.id}>{character.name}</li>
        ))}
      </ul>
    </div>
  );
};
// useSearch.tsx -  會根據關鍵字(keyword),返回包含關鍵字的物件陣列
const useSearch = <T extends { [key: string]: any }>(
  items: T[],
  filters = ["id"]
) => {
  const [keyword, setKeyword] = useState("");
  const filteredItems = items.filter((item) =>
    filters.some((key) =>
      item[key]?.toLowerCase().includes(keyword.toLowerCase())
    )
  );
  return {
    keyword,
    filteredItems,
    setKeyword
  };
};
// useCharacters.tsx - 執行打 API 的邏輯函式,且返回 API 相關狀態
type Character = {
  id: number;
  name: string;
};
const useCharacters = (): {
  characters: Character[];
  isLoading: boolean;
  error: string | null;
} => {
  const [characters, setCharacters] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const fetchCharacters = async () => {
    try {
      setIsLoading(true);
      const response = await fetch(
        "https://rickandmortyapi.com/api/character/?name=rick&status=alive"
      );
      if (!response.ok) {
        throw new Error("Failed to fetch characters");
      }
      const data = await response.json();
      setCharacters(data.results);
    } catch (error) {
      setError((error as Error).message);
    } finally {
      setIsLoading(false);
    }
  };
  useEffect(() => {
    fetchCharacters();
  }, []);
  return {
    characters,
    isLoading,
    error
  };
};
export default useCharacters;
設定 Mocking 對象
1. import 需要模擬的函式
import useCharacters from "../hooks/useCharacters";
import useSearch from "../hooks/useSearch";
2. 使用 vi.mock 選擇要模擬的函式
vi.mock("../hooks/useCharacters");
vi.mock("../hooks/useSearch");
3. 宣告模擬的函式
const mockUseCharacters = useCharacters as MockedFunction<typeof useCharacters>;
const mockUseSearch = useSearch as MockedFunction<typeof useSearch>;
4. 使用 MockedFunction 來指定模擬函式的返回值
mockUseCharacters.mockReturnValue({
  characters: [
    { id: 1, name: "Character 1" },
    { id: 2, name: "Character 2" }
  ],
  isLoading: false,
  error: null
});
mockUseSearch.mockReturnValue({
  keyword: "",
  filteredItems: [
    { id: 1, name: "Character 1" },
    { id: 2, name: "Character 2" }
  ],
  setKeyword: vi.fn()
});
步驟完整程式碼
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, MockedFunction, vi } from "vitest";
// 1. 引入測試需要的函式
import useCharacters from "../hooks/useCharacters";
import useSearch from "../hooks/useSearch";
// 2. 選擇要模擬的函式
vi.mock("../hooks/useCharacters");
vi.mock("../hooks/useSearch");
// 3. 宣告模擬的函式
const mockUseCharacters = useCharacters as MockedFunction<typeof useCharacters>;
const mockUseSearch = useSearch as MockedFunction<typeof useSearch>;
describe("CharacterList Component", () => {
  beforeEach(() => {
    // 4. 指定模擬函式的返回值 (設置每個測試的初始狀態)
    mockUseCharacters.mockReturnValue({
      characters: [
        { id: 1, name: "Character 1" },
        { id: 2, name: "Character 2" }
      ],
      isLoading: false,
      error: null
    });
    mockUseSearch.mockReturnValue({
      keyword: "",
      filteredItems: [
        { id: 1, name: "Character 1" },
        { id: 2, name: "Character 2" }
      ],
      setKeyword: vi.fn()
    });
  });
  afterEach(() => {
    vi.clearAllMocks();
    cleanup(); // 清理 DOM
  });
});
如果確保返回值永遠是定值,且後面不需要在更新修改該資料,可以在執行到第二步驟即可,vi.mock 傳入第二個參數,後續都會返回其結果:
// 呼叫 getUsers,永遠的 return 值都會是這邊設定好的,不會因後續操作而變動
vi.mock("./getUsers", () => {
  return {
    getUsers() {
      return {
        user: [
          { id: 1, name: "Jennie" },
          { id: 2, name: "Jason" }
        ],
        isLoading: false,
        error: null
      };
    }
  };
});
測試目標
1. 初始渲染列表
測試第一次渲染的內容是否為預期。
test("renders characters correctly", () => {
  render(<CharacterList />);
  expect(screen.getByText("Character 1")).toBeTruthy();
  expect(screen.getByText("Character 2")).toBeTruthy();
});
2. 搜尋功能
測試在輸入框內輸入關鍵字後,渲染的列表內容是否有包含該關鍵字。
使用到的 API:
mockReturnValueOnce
只會在下一次調用 useSearch 時返回這個值,這樣可以模擬特定情境下的返回值,而不影響其他測試。rerender
用於模擬 React 組件在資料改變後的重新渲染機制,因資料改變,從而觸發畫面更新。fireEvent
模擬 React 事件,例如 change、click 等。
test("changes keyword in UI and filters characters", () => {
  const setKeywordMock = vi.fn();
  mockUseSearch.mockReturnValueOnce({
    keyword: "",
    filteredItems: [
      { id: 1, name: "Character 1" },
      { id: 2, name: "Character 2" }
    ],
    setKeyword: setKeywordMock
  });
  const { rerender } = render(<CharacterList />);
  const input = screen.getByPlaceholderText("Search characters");
  // 模擬觸發 input 值改變
  fireEvent.change(input, { target: { value: "Character 1" } });
  // 模擬 mockUseSearch 在 input 改變後的返回值
  mockUseSearch.mockReturnValueOnce({
    keyword: "Character 1",
    filteredItems: [{ id: 1, name: "Character 1" }],
    setKeyword: setKeywordMock
  });
  // 重新渲染 CharacterList
  rerender(<CharacterList />);
  expect(setKeywordMock).toHaveBeenCalledWith("Character 1");
  expect(screen.getByText("Character 1")).toBeTruthy();
  expect(screen.queryByText("Character 2")).toBeNull();
});
參考資料
 TestVitestmock
 Published on 15 Dec 2024
 Updated on 15 Dec 2024