Jest中Mock網絡請求


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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM