Jest Test 总结

530 阅读12分钟

Vue 2

UT 之前很少在前端写,但是最近业务需求,学习了一些关于Vue 2的Jest UT的方法,因为不涉及业务,所以没有系统的学习,只是一些简单的入门经验总结,一些太深入的东西并没有做很多研究,如果之后有新的收获,还会更新。

1. 先导入需要的组建

  • Vuex: 本地状态存储,一本会存储一些全局数据和在action中进行一些ajax请求。
  • VueRouter:单文件Vue项目渲染页面和组建的路由地址。
  • component:需要被测试的组建。
  • @vue/test-utils:Vue 提供的模拟Vue实例生命周期hook函数等的插件
    1. config:使用config.mocks,可以模拟一些,自定义在Vue.prototypeVue实例原型上的全局变量,比如i18n
    2. createLocalVue:在测试文件中模拟一下Vue实例。
    3. mount: 模拟测试文件的挂载到Dom上的行为的函数,这个函数是深度挂载,会把组建component内容和他所有的子组件都实例化加载之后进行测试,因此自然就会默认加载子组件中的created和mounted方法,这样会使测试效率变低时间变长。
      • (3.1) 如果我们只需要关注某些子组件是否加载,而并不需要关注子组件内部的事件,我们可以使用stubs在本地模拟这个组建,这样在运行UT的时候,就不会加载子组件内部的状态和事件(这种情形一般是不常见的,因为使用mount挂载的时候,基本默认我们要加载所有的子组建了)
      • (3.2) 如果我们的重点在于关注,父子组件中传值的逻辑,那么我们更建议使用mount,而不是shallowmount
    4. shallowMount: 作用和mount一致,但是这个是浅度的,只会渲染加载组建component自身的内容,而他的子组件并不会被实例化渲染加载,可以提高测试效率,相当于把组件中的所有子组件都默认进行了一次stabs的模拟。在实际情况中,一个也页面可以存在大量的本地和全局的子组件,如果每个都进行一次stabs,工作量太大。因此,一般建议使用shallowMount
      • (4.1) 但是很多情况下,在父组件中,需要直接使用ref调用子组件中的方法,但是子组件已经默认被stabs了,子组件中的methods自然也就无法调用了,一般会报子组件是个undefined,此时,需要shallowmountstabs结合使用,在本地重新模拟定义这个子组件中的方法methods,让单元测可以继续进行即可。
      • (4.2) 特殊的情况: 如果父组件中的子组件是动态条件加载的,此时使用shallowMount无法加载这个子组件,测试这个子组件是否已经挂载时,就会报错。但是我们又不想关心这个子组件内部的事件和反应,此时我们就只能使用mountstabs结合起来解决动态子组件无法加载的问题。

测试示例:

1. componentA.vue

<template>
    <div>
        <div class="title">{{classTitle}}</div>
        <children-comp v-if="childrenList.length > 0" :list="childrenList" class="child-class" />
    </div>
</template>
<script>
improt ChildrenComp from './ChildrenComponent';
import { cloneDeep } from 'lodash';
import { mapGetters } from 'vuex';
export default {
    name:'componentA'components: {
        ChildrenComp
    },
    props: {
        childrenDatas: {
            type:Array,
            default: () => []
        }
    },
    computed: {
        ...mapGetters({
            classNameList: 'classCode/getClassNameList'
        }),
        classTitle() {
            if(this.$route.name === 'class1') {
                return this.$t("compontA.classTitle1")
            }
            return this.$t("compontA.classTitle2")
        },
        childrenList() {
           return cloneDeep(this.childrenDatas).map((child) => {
               child.className = this.classNameList.find((class) => class.value === child.classCd)?.text || "";
               return child;
           })
        }
    }
    
}
</script>

2. componentA.spec.js

import Vuex from 'vuex';
import VueRouter from 'vue-router';
import subject from '@/components/componentA.vue';
improt ChildrenComp from '@/components/ChildrenComponent';
import { config, createLocalVue, mount } from '@vue/test-utils';
const localVue = createLocalVue(); // 创建测试文件本地Vue实例。
// 像main.js 一样要在本地Vue实例上注册Vuex和VueRouter。
localVue.use(Vuex);
localVue.use(VueRouter);
// 在运行之前,mocks模拟Vue.prototype实例原型上的自定义的方法等。
beforeAll(() => {
    config.mocks.$t = (msg) => msg; // 相当于把原来的国际化,直接模拟成为一个自己想要的结果,避免无关紧要的函数的干扰。
});
// 测试文件整体描述:使用 describe() 函数
describe("ComponentA rendering test", () => {
   // 设置需要模拟的实例。
   let wrapper, // 接受mount挂载之后 返回的Vue实例对象,之后可以使用 wrapper.vm 调用所有挂载在Vue实例上的方法和数据
       store, // 模拟 Vuex
       router, // 模拟 VueRouter
       propsData; // 模拟 传递父子组建传递的props 数据。
   //在测试挂载开始之前,设置模拟的数据
   beforeEach(async () => {
       store = new Vuex.store({
           modules: {
               classCode: {
                   namespaced: true,
                   getters: {
                       getClassNameList: jest.fn().mockReturnValue([
                           {value: 'class1', text: '一班'},
                           {value: 'class2', text: '二班'},
                       ]) // 如果遇到一些额外的函数请求,可以使用jest.fn()来模拟函数,然后返回我们可以用来进行测试的数据即可,在运行UT的时候,this.$store.getters['classCode/getClassNameList'] 返回的数据就是我们mock模拟的数据。
                   }
               }
           }
       });
       router = new VueRouter({
           routes:[
               {path:'/class1/detail',name:'class1'},
               {path:'/class2/detail',name:'class2'},
           ]
       });
       propsData = {
           childrenDatas: [] // 初始阶段设置 props 的数据为空。
       };
       wrapper = mount(subject, {
           localVue,
           store,
           propsData,
           router,
           stubs: {
             ChildrenComp:true  
           }
       }); // 我们这种情况就适用于(4.2)的情况,因为这个子组建是条件加载的。如果子组件不是条件加载的,可以直接使用shallowMount
       await wrapper.vm.$nextTick();  // 在父组件,子组件全部都调用挂载完成之后,再往下进行
   });
   afterEach(() => {
       jest.resetAllMocks();
   }); //页面测试结束,释放所有模拟的结果。
   // 测试文件具体的测试用例的描述使用it() 函数 
   // 测试传递的props为空的测试,应该是 title 内部不一样,且子组件违背挂载。
   it("test empty childrenDatas /class1/detail page is rendered", () => {
       router.push({name : 'class1'}); // 测试不同路由时,需要进行router.push之后才能生效。
       const title = wrapper.findAll(".title").at(0);
       expect(title.text()).toBe("compontA.classTitle1");
       expect(wrapper.findComponent("children-comp").exist()).toBeFalsy();
   });
   it("test empty childrenDatas /class2/detail page is rendered", () => {
       router.push({name : 'class2'}); 
       const title = wrapper.findAll(".title").at(0);
       expect(title.text()).toBe("compontA.classTitle2");
       expect(wrapper.findComponent("children-comp").exist()).toBeFalsy();
   });
   // 测试传递来的props不为空时的数据,子组件被挂载,且传递给子组件的数据被计算属性处理过。
   it("test childrenDatas existed page is rendered", async () => {
        wrapper.setProps({
            childrenDatas: [{
                classCd: 'class2',
                name: 'testName',
                gender: 'testGender'
            }]
        });//设置props 数据
        await wrapper.vm.$nextTick();  // 在父组件,子组件全部都调用挂载完成之后,再往下进行。
        const title = wrapper.findAll(".title").at(0);
        expect(title.text()).toBe("compontA.classTitle2"); // 未设置router时,默认未"compontA.classTitle2";因为上面已经测试过路由的变化了,所以在这个用例里面,路由变化不是重点。
        expect(wrapper.findComponent("children-comp").exist()).toBeTruthy(); // 当props,有数据时,子组件应该存在。
        expect(wrapper.vm.childrenList).toEqual([{
                classCd: 'class2',
                name: 'testName',
                gender: 'testGender',
                className: '二班'
        }]); //测试计算属性childrenList的数据是否
   });
});

这样一个简单的componentA和这个组件相关的UT就写好了。

3 (4.1)情况的简单示例:

  • componentB.vue:
<template>
    <div>
        <childValidate ref="child">
            ...
        </childValidate>
    </div>
</template>
<script>
export default {
    methods: {
        save: () => {
            this.$refs['child'].validate((valid) => {
                if (valid) {
                    ......
                }
        })
    }
}
...
}
</script>

当使用shallowMount来浅度挂载componentB时,子组件都没有被实例化挂载,当调用wrapper.vm.save()时,this.$refs['child']就是undefined,导致报错,进而使整个单元测无法进行。那么此时就需要在shallowMount中的stabs对象中,模拟一个childValidate 组件。如下:

  • componentB.spec.js
...
// 子组件的模拟对象
const childValidate = {
  render: jest.fn(), // 给这个子组件的渲染函数模拟一个函数并且没有返回值,表示此组件并没有实际渲染的必要
  methods: {
    validate: (cb) => {cb(true)} // 模拟子组件上的 methods validate,并返回true,让校验通过继续,使测试文件继续进行。
  }
};
wrapper = shallowMount(componentB, {
    localVue,
    store,
    router,
    propsData,
    stubs: {
        childValidate // 模拟组件 childValidate
    }
})
...

给不需要被关注的子组件 childValidate 模拟一个组件对象,永远返回校验正确的结果,当调用wrapper.vm.save()时,使UT可以跑通,达到验证测试的实际目的。

4. Vue 2 + jest 总结:

最开始写前端UT的时候,确实无从下手。后来发现其实不管时前端还是后端的单元测试,一个核心的目标就是,测到我们期望的数据变化和结果,再实现这个目标的过程中,遇到无需关注却复杂的全局原型变量,ajax请求的函数和一些复杂纷乱的组件等,分别可以使用config.mocks,jest.fn,stabs等本地模拟的方式代替,最终获取我们想要测试以及期望的数据即可,后续还学到什么知识,依然会继续更新。

NodeJs

nodeJs编写的js后端代码的单元测试,相对Vue来说简单很多(只用导入一些需要测试的模块和使用到的公共模块即可),并且可以计算出来覆盖率等比较有意义的数据(Vue的单元测试,只能用于测试测试用例是否正确有效,并不能计算覆盖率)

  • afunHandler.spec.js
// 先导入用到的公共模块。
const { mailCommonFun ,  utilV3: { userUtil }  } = require('comon-module'); // 用到字段的公共文件
const Query = require('/afun/query'); // 测试模块用到的数据库的模块
// 导入需要测试的模块
const aFunHanler = require('/afun/aFunHandler.js');

// 使用解构赋值导入一些插件的某些公共字段时,是无法正常使用的。我们可以模拟这个公共模块。
jest.mock('comon-module', () => ({
    mailCommonFun: jest.fn(),
    utilV3: {
       userUtil: jest.fn() 
    }
}));

// 如果有时候,在这个公共方法里,我们需要使用很多的方法,我们可以使用jest.requireActual() 导入这个真实的公共模块。
// 但是这种方式会引入大量没有用到的公共模块的东西,
// 所以如果公共文件导出的方法并不是涉及公共模块很多其他方法的调用,还是优先推荐上面的方法比较好。
jest.mock('comon-module', () => ({
    ...jest.requireActual('comon-module'),
    mailCommonFun: jest.fn(),
    utilV3: {
       userUtil: jest.fn() 
    }
}));

describe('afunchandler test', () => {
    let event;
    beforeEach(() = {
        event = {
            knex: {
                raw:jest.fn()
            },
            body: {
                param: {}
            },
            serveAlias: 'dev'
        };
        Query.getList = jest.fn.mockResolvedValue({rows:[ {
            userName:'testUserName',
            userGender: 'male'
        } ]});
        utilV3.userUtil = jest.fn.mockReturnValue({ getUserName: 'testUserName' })
    }); // 初始化mock一些全局公共方法,数据和数据库返回的结果
    afterEach(() => {
        jest.resetAllMocks(); // 最后要重置所有mock;
    });
    it('User msg test', async () => {
        const getUserMsg = await aFunHanler();
        expect(Query.getList).toHaveBeenCalled();
        expect(utilV3.userUtil).toHaveBeenCalled();
        expect(getUserMsg).toEqual(
            {
                userName:'testUserName',
                userGender: 'male'
            }
        );
    });// 测试用例,根据需求可以书写多个it测试用例。
}); // 整体测试域

nodejs + jest 总结

nodejs后端使用jest进行单元测试相对来说比较简单明了,只需要说明jest.mock,jest.requireActualjest.fn

1. jest.fn

这个是单元测测试中用到最多的方法,可以模拟方法,任何方法同步的,异步的,模拟完还可以返回一些我们需要的值。这个方法主要是为了去除项目中一些对于测试无关的中间方法,或者一些无法调用的公共方法(比如查询数据库),就必须使用。这个方法还能很好的提高测试文件的效率。

2. jest.mock

jest.mock('公共模块名', () => ({...模拟模块的解构}))这个是可以传入一个模块名和工厂方法,这样返回的值,就可以用来模拟这个调用的模块,常用于一些公共模块名,比如上面例子的comon-module模块。因为引入的公共模块中经常要解构导出一些方法等,这些方法很难被直接调用,所以一般会使用这个方法,在本地mock模拟一个这样的公共模块,从而跳过整个公共模块,专注于自己需要测试的方法模块,提高测试效率。

  • 对于处理经常要解构出来的方法的mock实现问题:后来在实际操作中发现,使用 jest.mock('公共模块名', () => ({...模拟模块的解构})) 只能在初始化的过程实现一次(一次性的),因为mock的模块没有导出相应的解构方法变量,因此很难根据不同的测试用例中数据的变化来模拟这个模块解构方法返回值的区别,进而如果遇到需要模拟模块返回不同返回值的情况,就无奈了
  • 解决方法是:
  1. jest.mock整个模块,(让模块被jest整块模拟)
  2. 然后从模块中导出相应方法的实例变量(导出jest模拟的模块中需要的方法)
  3. 然后根据不同的测试用例的条件,改变这个变量的实现即可。(然后再实现这个方法) 如下图代码:
jest.mock('./isTrueForGetData');
import {isTrue} from './isTrueForGetData';
describe('xxxxx',() => {
    it('true', () => {
        isTrue.mockImplementation(() =>  true);
    });
    it('false', () => {
        isTrue.mockImplementation(() =>  false);
    });
})

3. jest.requireActual

jest.requireActual 返回的是整个真实模块,经常是配合jest.mock在本地模拟模块。因为可能在某些公共方法中调用了大量的公共模块的方法并且很复杂,此时就要使用jest.requireActual('公共模块名')来导出这个真实的模块来配合使用。

4. jest.spyOn

jest.spyOn(module, funcName) 可以返回某个模块module中的这个函数名funcNamemock函数(jest.fn()), 然后我们可以拿这个函数,做一些测试用例,例如toHaveBeenCalled(),toHaveBeenCalledWitdh(), toBeCalledTimes(num) 等等。

  • event.js:
//  ... 
import fetch from './fetch.js'
export default {
    async getList(param) {
        return await fetch.fetchList(param);
    }
}

  • test.js:
import event from './src/event.js'
import fetch from './src/fetch.js'
test('event test be called',async () => {
    const fetchFunc = jest.spyOn(fetch, 'fetchList');
    await event.getList();
    expect(fetchFunc).toHaveBeenCalled();
    expect(fetchFunc).toHaveBeenCalledTimes(1);
}):

5.jest.fn().mockImplementation(cbFun)

很多函数不仅要模拟一个函数返回的值jest.fn().mockReturnValue(result),有时候还需要模拟这个函数的内部的操作(比如 类似Promise的一些方法I最为普遍),此时就需要使用jest.fn().mockImplementation(cbFun), 其中这个cbFun就是你要赋值给模拟函数的内部实现。下面我们就来模拟一个

  • getListApi.js
import axios from 'axios';
function getList(params) {
    return new Promise((res,rej) => {
        if(param.isShowList) {
            res(true)
        } else {
            rej(false);
        }
    })
};
const cb = async (param, axiosIns) => {
    const {isPost} = await axiosIns.post(param.id);
    return { isPost };
}
async function callBackFun(cb) {
    const param = {
        id: '1231312';
    }
    return await cb(param, axios);
}
export { getList,callBackFun}
  • listhandler.js
import { getList } from 'getListApi.js';
getList(value).then(res => {
    console.log(res);
}).catch(err => {
    console.log(err);
})
  • test1.js
jest.mock('getListApi.js'); // jest.mock 操作 getListApi.js 内部内容,把函数都会转成 jest.fn() 一样的模拟函数。效果是相同的
import { getList } from 'getListApi.js';
getList.mockImplementation((value) => {
    if(value.isShowList) {
        return {
            then: jest.fn().mockImplementation((res) => { console.log(res)),
        }
    } else {
       return {
            then: jest.fn().mockResolveValue({
                catch: jest.fn().mockImplementation((err) => { console.log(err)),
            })
        } 
    }
});
  • test2.js 还有一种就是需要调用这个回调函数,因为有些函数只是一个架子,而他的回调函数才是他的真正的函数内容。
import Api from 'getListApi.js';
Api.callBackFun = jest.fn().mockImplementation(async (callBack) => {
    const param = {id:'testId};
    const axios = {
        post: jest.fn().mockResolveValue({
            isPost: true
        }}
    }
    await callBack(param,axios);
});

6. test singleFile or singlefold

  • package.json
"script": {
    "test": "jest",
    "coverage": "jest --coverage || exit 0",
    "test:singleFile": "jest test/functions/myfold/mytest.js",
    "test:fileList": "jest test/functions/myfold/mytest.js **/**/**.js ...",
    "test:singleFold": "jest --watchAll --testPathPattern=test/functions/myfold"
}
npm run test:singleFile
npm run test:singleFold
npm run test:fileList

7. jest.restoreAllmocks() vs jest.resetAllmocks()

  • reset : 重置。restore: 恢复
  • 从字面意思上,就是前者是重置所有的Mock。后者是恢复所有的Mock。
  • 这两个一般使用在 afterEach(() => {...}), 这两个函数都是在测试用例开始之前调用。而他们最大的区别就是
  1. jest.restoreAllmocks(): 恢复本地测试文件(test.js)中的在全局设定的Mock,并且返回值不变。
  2. jest.resetAllmocks()重置本地测试文件(test.js)中的全局设定的Mock, 并且用一个空函数代替,返回值为undefined
  3. 前者是我们常规的操作,而jest.resetAllmocks()一般运用于,我们只想在本地测试文件(test.js)中Mock我们需要的值, 而不受全局Mock的返回值影响

8. jest.fn().mockResolveValue() vs jest.fn().mockReturnValue()

  • jest.fn().mockResolveValue(): 意味着jest认为你要模拟的函数时一个异步函数(async)
  • jest.fn().mockReturnValue() : 意味着jest认为你要模拟的函数时一个同步函数。 平时使用单个函数可能差别不适很大,但使用链接调用的函数时,就会出现问题,因为同步函数异步函数本身在事件循环机制中就存在着巨大的差别。

eg:

  • api.js
export const handler = async () => {
    //...
    const data = await Api.getApiByOptions({type:'get'}).get({id:'123312'});
    //...
}

要mock这个函数,这个函数 是一个同步函数 Api.getApiByOptions() 返回一个get属性,然后get()是一个异步函数的过程。因此:

  • test.js
import Api from 'api';
//... success
Api.getApiByOptions = jest.fn().mockReturnValue({
    get: jest.fn().mockResolveValue({
        query:'testQuery'
    })
});
// ...or
Api.getApiByOptions = jest.fn().mockImplementation(() => ({
    get: jest.fn().mockResolveValue({
        query:'testQuery'
    })
}));
//... fail
// Api.getApiByOptions = jest.fn().mockResolveValue({
//     get: jest.fn().mockResolveValue({
//         query:'testQuery'
//     })
// });
// ...or
// Api.getApiByOptions = jest.fn().mockImplementation(async () => ({
//        get: jest.fn().mockResolveValue({
//            query:'testQuery'
//        })
//    }));