Jest中Mock網路請求

WindrunnerMax發表於2021-09-12

Jest中Mock網路請求

最近需要將一個比較老的庫修改為TS並進行單元測試,修改為TS還能會一點,單元測試純粹是現學現賣了,初學Jest框架,覺得在單元測試中比較麻煩的就是測試網路請求,所以記錄一下MockAxios發起網路請求的一些方式。初學兩天的小白,如有問題還請指出。

描述

文中提到的示例全部在 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伺服器,通過axiosproxy將網路請求進行代理,轉發到啟動的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即可嘗試執行,實際上是將包裝axioswrap-request庫進行了一個mock操作,在Jest啟動時會進行編譯,在這裡將這個庫mock掉後,所有在之後引入這個庫的檔案都是會獲得mock後的物件,也就是說我們可以認為這個庫已經重寫了,重寫之後的方法都是JESTMock 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時再實現斷言或者是指定返回值,這樣就可以解決上述問題,實際上就是實現了JestMock FunctionsmockImplementation

// 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中的例子實際上是寫複雜了,在JestMock FunctionsmockImplementation的實現,直接使用即可。

// 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: 真實發起網路請求

demo4demo5通過npm run test:demo4-5即可嘗試執行,採用這種方式是進行了真正的資料請求,在這裡會利用axios的代理,將內部的資料請求轉發到指定的伺服器埠,當然這個伺服器也是在本地啟動的,通過指定對應的path相關的請求與響應資料進行測試,如果請求的資料不正確,則不會正常匹配到相關的響應資料,這樣這個請求會直接返回500,返回的響應資料如果不正確的話也會在斷言時被捕捉。在這裡就使用到了jest-axios-mock-server庫,首先我們需要指定三個檔案,分別對應每個單元測試檔案啟動前執行,Jest測試啟動前執行,與Jest測試完成後執行的三個生命週期進行的操作,分別是jest.config.js配置檔案的setupFilesglobalSetupglobalTeardown三個配置項。
首先是setupFiles,在這裡我們除了初始化JSDOM之外,還需要對axios的預設代理進行操作,因為採用的方案是使用axiosproxy進行資料請求的轉發,所以才需要在單元測試的最前方設定代理值。

// 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);

之後便是globalSetupglobalTeardown兩個配置項,在這裡指的是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,
    },
    // ...
}

當然,或許會有提出為什麼不在每個單元測試檔案的beforeAllafterAll生命週期啟動與關閉伺服器,首先這個方案我也嘗試過,首先對於每個測試檔案將伺服器啟動結束後再關閉雖然相對比較耗費時間,但是理論上還是合理的,畢竟要進行資料隔離的話確實是沒錯,但是在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型別,以減少型別出錯導致的異常,在這裡示例了兩個資料集,另外在匹配querydata時是支援正規表示式的,對於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

相關文章