Jest中Mock網絡請求
最近需要將一個比較老的庫修改為TS
並進行單元測試,修改為TS
還能會一點,單元測試純粹是現學現賣了,初學Jest
框架,覺得在單元測試中比較麻煩的就是測試網絡請求,所以記錄一下Mock
掉Axios
發起網絡請求的一些方式。初學兩天的小白,如有問題還請指出。
描述
文中提到的示例全部在 jest-axios-mock-server倉庫 中,直接使用包管理器安裝就可以啟動示例,例如通過yarn
安裝:
$ yarn install
在package.json
中指定了一些命令,分別如下:
npm run build
:rollup
的打包命令。npm run test:demo1
: 簡單地mock
封裝的網絡請求庫。npm run test:demo2
: 采用重新實現並hook
的方式完成mock
。npm run test:demo3
: 使用Jest
中的庫完成demo2
的實現。npm run test:demo4-5
: 啟動一個node
服務器,通過axios
的proxy
將網絡請求進行代理,轉發到啟動的node
服務器,通過設置好對應的單元測試請求與響應的數據,利用對應關系實現測試,也就是jest-axios-mock-server
完成的工作。
在這里我們封裝了一層axios
,比較接近真實場景,可以查看test/demo/wrap-request.ts
文件,實際上只是簡單的在內部創建了一個axios
實例,並且轉發了一下響應的數據而已,test/demo/index.ts
文件簡單地導出了一個counter
方法,這里對於這兩個參數有一定的處理然后才發起網絡請求,之后對於響應的數據也有一定的處理,只是為了模擬一下相關的操作而已。
// test/demo/wrap-request.ts
import axios, { AxiosRequestConfig } from "axios";
const instance = axios.create({
timeout: 3000,
});
export const request = (options: AxiosRequestConfig): Promise<any> => {
// do something wrap
return instance.request(options).then(res => res.data);
};
// test/demo/index.ts
import { request } from "./wrap-request";
export const counter = (id: number, number: number): Promise<{ result: number; msg: string }> => {
const operate = number > 0 ? 1 : -1;
return request({
url: "https://www.example.com/api/setCounter",
method: "POST",
data: { id, operate },
})
.then(res => {
if (res.result === 0) return { result: 0, msg: "success" };
if (res.result === -100) return { result: -100, msg: "need login" };
return { result: -999, msg: "fail" };
})
.catch(err => {
return { result: -999, msg: "fail" };
});
};
此處的Jest
使用了JSDOM
模擬的瀏覽器環境,在jest.config.js
中配置的setupFiles
屬性中配置了啟動文件test/config/setup.js
,在此處初始化了JSDOM
。
import { JSDOM } from "jsdom";
const config = {
url: "https://www.example.com/",
domain: "example.com",
};
const dom = new JSDOM("", config);
global.document = dom.window.document;
global.document.domain = config.domain;
global.window = dom.window;
global.location = dom.window.location;
demo1: 簡單Mock網絡請求
在test/demo1.test.js
中進行了簡單的mock
處理,通過npm run test:demo1
即可嘗試運行,實際上是將包裝axios
的wrap-request
庫進行了一個mock
操作,在Jest
啟動時會進行編譯,在這里將這個庫mock
掉后,所有在之后引入這個庫的文件都是會獲得mock
后的對象,也就是說我們可以認為這個庫已經重寫了,重寫之后的方法都是JEST
的Mock Functions
了,可以使用諸如mockReturnValue
一類的函數進行數據模擬,關於Mock Functions
可以參考https://www.jestjs.cn/docs/mock-functions
。
// test/demo1.test.js
import { counter } from "./demo";
import { request } from "./demo/wrap-request";
jest.mock("./demo/wrap-request");
describe("Simple mock", () => {
it("test success", () => {
request.mockResolvedValue({ result: 0 });
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: 0, msg: "success" });
});
});
it("test need login", () => {
request.mockResolvedValue({ result: -100 });
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: -100, msg: "need login" });
});
});
it("test something wrong", () => {
request.mockResolvedValue({ result: 1111111 });
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: -999, msg: "fail" });
});
});
});
在這里我們完成了返回值的Mock
,也就是說對於wrap-request
庫中的request
返回的值我們都能進行控制了,但是之前也提到過對於傳入的參數也有一定的處理,這部分內容我們還沒有進行斷言,所以對於這個我們同樣需要嘗試進行處理。
demo2: hook網絡請求
demo2
通過npm run test:demo2
即可嘗試運行,在上邊提到了我們可以處理返回值的情況,但是沒法斷言輸入的參數是否正確進行了處理,所以我們需要處理一下這種情況,所幸Jest
提供了一種可以直接實現被Mock
的函數庫的方式,當然實際上Jest
還提供了mockImplementation
的方式,這個是在demo3
中使用的方式,在這里我們重寫了被mock
的函數庫,在實現的時候也可以使用jest.fn
完成Implementations
,這里通過在返回之前寫入了一個hook
函數,並且在各個test
時再實現斷言或者是指定返回值,這樣就可以解決上述問題,實際上就是實現了Jest
中Mock Functions
的mockImplementation
。
// test/demo2.test.js
import { counter } from "./demo";
import * as request from "./demo/wrap-request";
jest.mock("./demo/wrap-request", () => {
let hook = () => ({ result: 0 });
return {
setHook: cb => (hook = cb),
request: (...args) => {
return new Promise(resolve => {
resolve(hook(...args));
});
},
};
});
describe("Simple mock", () => {
it("test success", () => {
request.setHook(() => ({ result: 0 }));
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: 0, msg: "success" });
});
});
it("test need login", () => {
request.setHook(() => ({ result: -100 }));
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: -100, msg: "need login" });
});
});
it("test something wrong", () => {
request.setHook(() => ({ result: 1111111 }));
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: -999, msg: "fail" });
});
});
it("test param transform", () => {
return new Promise(done => {
request.setHook(({ data }) => {
expect(data).toStrictEqual({ id: 1, operate: 1 });
done();
return { result: 0 };
});
counter(1, 1000);
});
});
});
demo3: 使用Jest的mockImplementation
demo3
通過npm run test:demo3
即可嘗試運行,在demo2
中的例子實際上是寫復雜了,在Jest
中Mock Functions
有mockImplementation
的實現,直接使用即可。
// test/demo3.test.js
import { counter } from "./demo";
import { request } from "./demo/wrap-request";
jest.mock("./demo/wrap-request");
describe("Simple mock", () => {
it("test success", () => {
request.mockImplementation(() => Promise.resolve({ result: 0 }));
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: 0, msg: "success" });
});
});
it("test need login", () => {
request.mockImplementation(() => Promise.resolve({ result: -100 }));
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: -100, msg: "need login" });
});
});
it("test something wrong", () => {
request.mockImplementation(() => Promise.resolve({ result: 1111111 }));
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: -999, msg: "fail" });
});
});
it("test param transform", () => {
return new Promise(done => {
request.mockImplementation(({ data }) => {
expect(data).toStrictEqual({ id: 1, operate: 1 });
done();
return Promise.resolve({ result: 0 });
});
counter(1, 1000);
});
});
});
demo4-5: 真實發起網絡請求
demo4
與demo5
通過npm run test:demo4-5
即可嘗試運行,采用這種方式是進行了真正的數據請求,在這里會利用axios
的代理,將內部的數據請求轉發到指定的服務器端口,當然這個服務器也是在本地啟動的,通過指定對應的path
相關的請求與響應數據進行測試,如果請求的數據不正確,則不會正常匹配到相關的響應數據,這樣這個請求會直接返回500
,返回的響應數據如果不正確的話也會在斷言時被捕捉。在這里就使用到了jest-axios-mock-server
庫,首先我們需要指定三個文件,分別對應每個單元測試文件啟動前執行,Jest
測試啟動前執行,與Jest
測試完成后執行的三個生命周期進行的操作,分別是jest.config.js
配置文件的setupFiles
、globalSetup
、globalTeardown
三個配置項。
首先是setupFiles
,在這里我們除了初始化JSDOM
之外,還需要對axios
的默認代理進行操作,因為采用的方案是使用axios
的proxy
進行數據請求的轉發,所以才需要在單元測試的最前方設定代理值。
// test/config/setup.js
import { JSDOM } from "jsdom";
import { init } from "../../src/index";
import axios from "axios";
const config = {
url: "https://www.example.com/",
domain: "example.com",
};
const dom = new JSDOM("", config);
global.document = dom.window.document;
global.document.domain = config.domain;
global.window = dom.window;
global.location = dom.window.location;
init(axios);
之后便是globalSetup
與globalTeardown
兩個配置項,在這里指的是Jest
單元測試啟動前與全部測試完畢后進行的操作,我們將服務器啟動與關閉的操作都放在這里,請注意,在這兩個文件運行的文件是單獨的一個獨立context
,與任何進行的單元測試的context
都是無關的,包括setupFiles
配置項指定的文件,所以在此處所有的數據要么是通過在配置文件中指定,要不就是通過網絡在服務器端口之間進行傳輸。
// test/config/global-setup.js
import { run } from "../../src";
export default async () => {
await run();
};
// test/config/global-teardown.js
import { close } from "../../src";
export default async function () {
await close();
}
對於配置端口與域名信息,將其直接放置在jest.config.js
中的globals
字段中了,對於debug
這個配置項,建議和test.only
配合使用,在調用服務器信息的過程中可以打印出相關的請求信息。
// jest.config.js
module.exports = {
// ...
globals: {
host: "127.0.0.1",
port: "5000",
debug: false,
},
// ...
}
當然,或許會有提出為什么不在每個單元測試文件的beforeAll
與afterAll
生命周期啟動與關閉服務器,首先這個方案我也嘗試過,首先對於每個測試文件將服務器啟動結束后再關閉雖然相對比較耗費時間,但是理論上還是合理的,畢竟要進行數據隔離的話確實是沒錯,但是在afterAll
關閉的時候就出了問題,因為node
服務器在關閉時調用的close
方法並不會真實地關閉服務器以及端口占用,他只是停止處理請求了,端口還是被占用,當啟動第二個單元測試文件時會拋出端口正在被占用的異常,雖然現在已經有一些解決的方案,但是我嘗試過后並不理想,會偶現端口依舊被占用的情況,尤其是在node
開機后第一次被運行的情況,異常的概率比較大,所以效果不是很理想,最終還是采用了這種完全隔離的方案,具體相關的問題可以參考https://stackoverflow.com/questions/14626636/how-do-i-shutdown-a-node-js-https-server-immediately
。
由於采用的是完全隔離的方案,所以我們想給測試的請求進行請求與響應數據的傳輸的時候,只有兩個方案,要么在服務器啟動的時候,也就是test/config/global-setup.js
文件中將數據全部指定完成,要么就是通過網絡進行數據傳輸,即在服務器運行的過程中通過指定path
然后該path
的網絡請求會攜帶數據,在服務器的閉包中會把這個數據請求指定,當然在這里兩種方式都支持,我覺得還是在每個單元測試文件中指定一個自己的數據比較合適,所以在這里僅示例了在單元測試文件中指定要測試的數據。關於要測試的數據,指定了一個DataMapper
類型,以減少類型出錯導致的異常,在這里示例了兩個數據集,另外在匹配query
和data
時是支持正則表達式的,對於DataMapper
類型的結構還是比較標准的。
// test/data/demo1.data.ts
import { DataMapper } from "../../src";
const data: DataMapper = {
"/api/setCounter": [
{
request: {
method: "POST",
data: '{"id":1,"operate":1}',
},
response: {
status: 200,
json: {
result: 0,
},
},
},
{
request: {
method: "POST",
data: /"id":2,"operate":-1/,
},
response: {
status: 200,
json: {
result: -100,
},
},
},
],
};
export default data;
// test/data/demo2.data.ts
import { DataMapper } from "../../src";
const data: DataMapper = {
"/api/setCounter": [
{
request: {
method: "POST",
data: /"id":3,"operate":-1/,
},
response: {
status: 200,
json: {
result: -100,
},
},
},
],
};
export default data;
最后進行的兩個單元測試中就在beforeAll
中指定了要測試的數據,要注意這里是return setSuitesData(data)
,因為要在數據設置成功響應以后在進行單元測試,之后就是正常的請求與響應以及斷言測試是否正確了。
// test/demo4.test.js
import { counter } from "./demo";
import { setSuitesData } from "../src/index";
import data from "./data/demo1.data";
beforeAll(() => {
return setSuitesData(data);
});
describe("Simple mock", () => {
it("test success", () => {
return counter(1, 2).then(res => {
expect(res).toStrictEqual({ result: 0, msg: "success" });
});
});
it("test need login", () => {
return counter(2, -3).then(res => {
expect(res).toStrictEqual({ result: -100, msg: "need login" });
});
});
});
// test/demo5.test.js
import { counter } from "./demo";
import { setSuitesData } from "../src/index";
import data from "./data/demo2.data";
beforeAll(() => {
return setSuitesData(data);
});
describe("Simple mock", () => {
it("test success", () => {
return counter(3, -30).then(res => {
expect(res).toStrictEqual({ result: -100, msg: "need login" });
});
});
it("test no match response", () => {
return counter(6, 2).then(res => {
expect(res).toStrictEqual({ result: -999, msg: "fail" });
});
});
});
BLOG
https://github.com/WindrunnerMax/EveryDay/
參考
https://www.jestjs.cn/docs/mock-functions
https://stackoverflow.com/questions/41316071/jest-clean-up-after-all-tests-have-run
https://stackoverflow.com/questions/14626636/how-do-i-shutdown-a-node-js-https-server-immediately