Stories

Detail Return Return

全面掌握 Jest:從零開始的測試指南(下篇) - Stories Detail

在上一篇測試指南中,我們介紹了Jest 的背景、如何初始化項目、常用的匹配器語法以及鈎子函數的使用。這一篇篇將繼續深入探討 Jest 的高級特性,包括 Mock 函數、異步請求的處理、Mock 請求的模擬、類的模擬以及定時器的模擬、snapshot 的使用。通過這些技術,我們將能夠更高效地編寫和維護測試用例,尤其是在處理複雜異步邏輯和外部依賴時。

Mock 函數

假設存在一個 runCallBack 函數,其作用是判斷入參是否為函數,如果是,則執行傳入的函數。

export const runCallBack = (callback) => {
  typeof callback == "function" && callback();
};

編寫測試用例

我們先嚐試編寫它的測試用例:

import { runCallBack } from './func';
test("測試 runCallBack", () => {
  const fn = () => {
    return "hello";
  };
  expect(runCallBack(fn)).toBe("hello");
});

此時,命令行會報錯提示 runCallBack(fn) 執行的返回值為 undefined,而不是 "hello"。如果期望得到正確的返回值,就需要修改原始的 runCallBack 函數,但這種做法不符合我們的測試預期——我們不希望為了測試而改變原有的業務功能。

這時,mock 函數就可以很好地解決這個問題。mock 可以用來模擬一個函數,並可以自定義函數的返回值。我們可以通過 mock 函數來分析其調用次數、入參和出參等信息。

使用 mock 解決問題

上述測試用例可以改為如下形式:

test("測試 runCallBack", () => {
  const fn = jest.fn();
  runCallBack(fn);
  expect(fn).toBeCalled();
  expect(fn.mock.calls.length).toBe(1);
});

這裏,toBeCalled() 用於檢查函數是否被調用過,fn.mock.calls.length 用於檢查函數被調用的次數。

mock 屬性中還有一些有用的參數:

  • calls: 數組,保存着每次調用時的入參。
  • instances: 數組,保存着每次調用時的實例對象。
  • invocationCallOrder: 數組,保存着每次調用的順序。
  • results: 數組,保存着每次調用的執行結果。

自定義返回值

mock 還可以自定義返回值。可以在 jest.fn 中定義回調函數,或者通過 mockReturnValuemockReturnValueOnce 方法定義返回值。

test("測試 runCallBack 返回值", () => {
  const fn = jest.fn(() => {
    return "hello";
  });
  createObject(fn);
  expect(fn.mock.results[0].value).toBe("hello");
  
  fn.mockReturnValue('alice') // 定義返回值
  createObject(fn);
  expect(fn.mock.results[1].value).toBe("alice");

  fn.mockReturnValueOnce('x') // 定義只返回一次的返回值
  createObject(fn);
  expect(fn.mock.results[2].value).toBe("x");

  createObject(fn);
  expect(fn.mock.results[3].value).toBe("alice");
});

構造函數的模擬

構造函數作為一種特殊的函數,也可以通過 mock 實現模擬。

// func.js
export const createObject = (constructFn) => {
  typeof constructFn == "function" && new constructFn();
};

// func.test.js
import { createObject } from './func';
test("測試 createObject", () => {
    const fn = jest.fn();
    createObject(fn);
    expect(fn).toBeCalled();
    expect(fn.mock.calls.length).toBe(1);
});

通過使用 mock 函數,我們可以更好地模擬函數的行為,並分析其調用情況。這樣不僅可以避免修改原有業務邏輯,還能確保測試的準確性和可靠性。

異步代碼

在處理異步請求時,我們期望 Jest 能夠等待異步請求結束後再對結果進行校驗。測試請求接口地址使用 http://httpbin.org/get,可以將參數通過 query 的形式拼接在 URL 上,如 http://httpbin.org/get?name=alice。這樣接口返回的數據中將攜帶 { name: 'alice' },可以依此來對代碼進行校驗。

以下分別通過異步請求回調函數、Promise 鏈式調用、await 的方式獲取響應結果來進行分析。

回調函數類型

回調函數的形式通過 done() 函數告訴 Jest 異步測試已經完成。

func.js 文件中通過 Axios 發送 GET 請求:

const axios = require("axios");

export const getDataCallback = (url, callbackFn) => {
  axios.get(url).then(
    (res) => {
      callbackFn && callbackFn(res.data);
    },
    (error) => {
      callbackFn && callbackFn(error);
    }
  );
};

func.test.js 文件中引入發送請求的方法:

import { getDataCallback } from "./func";
test("回調函數類型-成功", (done) => {
  getDataCallback("http://httpbin.org/get?name=alice", (data) => {
    expect(data.args).toEqual({ name: "alice" });
    done();
  });
});

test("回調函數類型-失敗", (done) => {
  getDataCallback("http://httpbin.org/xxxx", (data) => {
    expect(data.message).toContain("404");
    done();
  });
});

promise類型

Promise 類型的用例中,需要使用 return 關鍵字來告訴 Jest 測試用例的結束時間。

// func.js
export const getDataPromise = (url) => {
  return axios.get(url);
};

Promise 類型的函數可以通過 then 函數來處理:

// func.test.js
test("Promise 類型-成功", () => {
  return getDataPromise("http://httpbin.org/get?name=alice").then((res) => {
    expect(res.data.args).toEqual({ name: "alice" });
  });
});

test("Promise 類型-失敗", () => {
  return getDataPromise("http://httpbin.org/xxxx").catch((res) => {
    expect(res.response.status).toBe(404);
  });
});

也可以直接通過 resolvesrejects 獲取響應的所有參數並進行匹配:

test("Promise 類型-成功匹配對象t", () => {
  return expect(
    getDataPromise("http://httpbin.org/get?name=alice")
  ).resolves.toMatchObject({
    status: 200,
  });
});

test("Promise 類型-失敗拋出異常", () => {
  return expect(getDataPromise("http://httpbin.org/xxxx")).rejects.toThrow();
});

await 類型

上述 getDataPromise 也可以通過 await 的形式來編寫測試用例:

test("await 類型-成功", async () => {
  const res = await getDataPromise("http://httpbin.org/get?name=alice");
  expect(res.data.args).toEqual({ name: "alice" });
});

test("await 類型-失敗", async () => {
  try {
    await getDataPromise("http://httpbin.org/xxxx")
  } catch(e){
    expect(e.status).toBe(404)
  }
});

通過上述幾種方式,可以有效地編寫異步函數的測試用例。回調函數Promise 鏈式調用以及 await 的方式各有優劣,可以根據具體情況選擇合適的方法。

Mock 請求/類/Timers

在前面處理異步代碼時,是根據真實的接口內容來進行校驗的。然而,這種方式並不總是最佳選擇。一方面,每個校驗都需要發送網絡請求獲取真實數據,這會導致測試用例執行時間較長;另一方面,接口格式是否滿足要求是後端開發者需要着重測試的內容,前端測試用例並不需要涵蓋這部分內容。

在之前的函數測試中,我們使用了 Mock 來模擬函數。實際上,Mock 不僅可以用來模擬函數,還可以模擬網絡請求和文件。

Mock 網絡請求

Mock 網絡請求有兩種方式:一種是直接模擬發送請求的工具(如 Axios),另一種是模擬引入的文件。

直接模擬 Axios

首先,在 request.js 中定義發送網絡請求的邏輯:

import axios from "axios";

export const fetchData = () => {
  return axios.get("/").then((res) => res.data);
};

然後,使用 jest 模擬 axios 即 jest.mock("axios"),並通過 axios.get.mockResolvedValue 來定義響應成功的返回值:

const axios = require("axios");
import { fetchData } from "./request";

jest.mock("axios");
test("測試 fetchData", () => {
  axios.get.mockResolvedValue({
    data: "hello",
  });
  return fetchData().then((data) => {
    expect(data).toEqual("hello");
  });
});
模擬引入的文件

如果希望模擬 request.js 文件,可以在當前目錄下創建 __mocks__ 文件夾,並在其中創建同名的 request.js 文件來定義模擬請求的內容:

// __mocks__/request.js
export const fetchData = () => {
  return new Promise((resolve, reject) => {
    resolve("world");
  });
};

使用 jest.mock('./request') 語法,Jest 在執行測試用例時會自動將真實的請求文件內容替換成 __mocks__/request.js 的文件內容:

// request.test.js
import { fetchData } from "./request";
jest.mock("./request");

test("測試 fetchData", () => {
  return fetchData().then((data) => {
    expect(data).toEqual("world");
  });
});

如果部分內容需要從真實的文件中獲取,可以通過 jest.requireActual() 函數來實現。取消模擬則可以使用 jest.unmock()

Mock 類

假設在業務場景中定義了一個工具類,類中有多個方法,我們需要對類中的方法進行測試。

// util.js
export default class Util {
  add(a, b) {
    return a + b;
  }
  create() {}
}

// util.test.js
import Util from "./util";
test("測試add方法", () => {
  const util = new Util();
  expect(util.add(2, 5)).toEqual(7);
});

此時,另一個文件如 useUtil.js 也用到了 Util 類:

// useUtil.js
import Util from "./util";

export function useUtil() {
  const util = new Util();
  util.add(2, 6);
  util.create();
}

在編寫 useUtil 的測試用例時,我們只希望測試當前文件,並不希望重新測試 Util 類的功能。這時也可以通過 Mock 來實現。

__mock__ 文件夾下創建模擬文件

可以在 __mock__ 文件夾下創建 util.js 文件,文件中定義模擬函數:

// __mock__/util.js
const Util = jest.fn()
Util.prototype.add = jest.fn()
Util.prototype.create = jest.fn();
export default Util;

// useUtil.test.js
jest.mock("./util");
import Util from "./util";
import { useUtilFunc } from "./useUtil";

test("useUtil", () => {
  useUtilFunc();
  expect(Util).toHaveBeenCalled();
  expect(Util.mock.instances[0].add).toHaveBeenCalled();
  expect(Util.mock.instances[0].create).toHaveBeenCalled();
});
在當前 .test.js 文件定義模擬函數

也可以在當前 .test.js 文件中定義模擬函數:

// useUtil.test.js
import { useUtilFunc } from "./useUtil";
import Util from "./util";
jest.mock("./util", () => {
  const Util = jest.fn();
  Util.prototype.add = jest.fn();
  Util.prototype.create = jest.fn();
  return Util
});
test("useUtil", () => {
  useUtilFunc();
  expect(Util).toHaveBeenCalled();
  expect(Util.mock.instances[0].add).toHaveBeenCalled();
  expect(Util.mock.instances[0].create).toHaveBeenCalled();
});

這兩種方式都可以模擬類。

Timers

在定義一些功能函數時,比如防抖和節流,經常會使用 setTimeout 來推遲函數的執行。這類功能也可以通過 Mock 來模擬測試。

// timer.js
export const timer = (callback) => {
  setTimeout(() => {
    callback();
  }, 3000);
};
使用 done 異步執行

一種方式是使用 done 來異步執行:

import { timer } from './timer'

test("timer", (done) => {
  timer(() => {
    done();
    expect(1).toBe(1);
  });
});
使用 Jest 的 timers 方法

另一種方式是使用 Jest 提供的 timers 方法,通過 useFakeTimers 啓用假定時器模式,runAllTimers 來手動運行所有的定時器,並使用 toHaveBeenCalledTimes 來檢查調用次數:

beforeEach(()=>{
    jest.useFakeTimers()
})

test('timer測試', ()=>{
    const fn = jest.fn();
    timer(fn);
    jest.runAllTimers();
    expect(fn).toHaveBeenCalledTimes(1);
})

此外,還有 runOnlyPendingTimers 方法用來執行當前位於隊列中的 timers,以及 advanceTimersByTime 方法用來快進 X 毫秒。

例如,在存在嵌套的定時器時,可以通過 advanceTimersByTime 快進來模擬:

// timer.js
export const timerTwice = (callback) => {
  setTimeout(() => {
    callback();
    setTimeout(() => {
      callback();
    }, 3000);
  }, 3000);
};

// timer.test.js
import { timerTwice } from "./timer";
test("timerTwice 測試", () => {
  const fn = jest.fn();
  timerTwice(fn);
  jest.advanceTimersByTime(3000);
  expect(fn).toHaveBeenCalledTimes(1);
  jest.advanceTimersByTime(3000);
  expect(fn).toHaveBeenCalledTimes(2);
});

無論是模擬網絡請求、類還是定時器,Mock 都是一個強大的工具,可以幫助我們構建可靠且高效的測試用例。

snapshot

假設當前存在一個配置,配置的內容可能會經常變更,如下所示:

export const generateConfig = () => {
  return {
    server: "http://localhost",
    port: 8001,
    domain: "localhost",
  };
};
toEqual 匹配

如果對它進行測試用例編寫,最簡單的方式就是使用 toEqual 匹配,如下所示:

import { generateConfig } from "./snapshot";

test("測試 generateConfig", () => {
  expect(generateConfig()).toEqual({
    server: "http://localhost",
    port: 8001,
    domain: "localhost",
  });
});

但是這種方式存在一些問題:每當配置文件發生變更時,都需要修改測試用例。為了避免測試用例頻繁修改,可以通過 snapshot 快照來解決這個問題。

toMatchSnapshot

通過 toMatchSnapshot 函數生成快照:

test("測試 generateConfig", () => {
  expect(generateConfig()).toMatchSnapshot();
});

第一次執行 toMatchSnapshot 時,會生成一個 __snapshots__ 文件夾,裏面存放着 xxx.test.js.snap 這樣的文件,內容是當前配置的執行結果。

第二次執行時,會生成一個新的快照並與已有的快照進行比較。如果相同則測試通過;如果不相同,測試用例不通過,並且在命令行會提示你是否需要更新快照,如 “1 snapshot failed from 1 test suite. Inspect your code changes or press u to update them”。

按下 u 鍵之後,測試用例會通過,並且覆蓋原有的快照。

快照的值不同

如果該函數每次的值不同,生成的快照也不相同,例如每次調用函數返回時間戳:

export const generateConfig = () => {
  return {
    server: "http://localhost",
    port: 8002,
    domain: "localhost",
    date: new Date()
  };
};

在這種情況下,toMatchSnapshot 可以接受一個對象作為參數,該對象用於描述快照中的某些字段應該如何匹配:

test("測試 generateConfig", () => {
  expect(generateConfig()).toMatchSnapshot({
    date: expect.any(Date)
  });
});
行內快照

上述的快照是在 __snapshots__ 文件夾下生成的,還有一種方式是通過 toMatchInlineSnapshot 在當前的 .test.js 文件中生成。需要注意的是,這種方式通常需要配合 prettier 工具來使用。

test("測試 generateConfig", () => {
  expect(generateConfig()).toMatchInlineSnapshot({
    date: expect.any(Date),
  });
});

測試用例通過後,該用例的格式如下:

test("測試 generateConfig", () => {
  expect(generateConfig()).toMatchInlineSnapshot({
  date: expect.any(Date)
}, `
{
  "date": Any<Date>,
  "domain": "localhost",
  "port": 8002,
  "server": "http://localhost",
}
`);
});

使用 snapshot 測試可以有效地減少頻繁修改測試用例的工作量。無論配置如何變化,只需要更新一次快照即可保持測試的一致性。

本篇及上一篇文章的內容合在一起涵蓋了 Jest 的基本使用和高級配置。更多有關前端工程化的內容,請參考我的其他博文,持續更新中~

user avatar juanerma Avatar xiangjian_659d190d45a7b Avatar
Favorites 2 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.