1

前言

对客户交付高质量的产品是企业的核心目标之一,而单元测试是实现这一目标的重要手段之一。通过单元测试,可以确保产品的每个部分都经过了严格的测试,降低产品出现缺陷的概率,提高产品的可靠性和稳定性。同时,单元测试的结果可以为客户提供更加准确的产品质量报告,帮助客户更好地了解产品的优点和缺陷。此外,单元测试还可以提高开发人员的信心和积极性,促进团队的合作和创新,为客户提供更加优质的产品和服务。

单元测试是客户交付高质量产品的重要保证之一,企业应该高度重视单元测试工作,不断完善和优化测试流程和方法。

然而作为一个前端开发者来说,我们所承担的不单单只是保证开发任务的完成,在交付所完成的项目之前更要保证的是质量问题,如何保证交付的质量是一个很值得探讨的问题,大多数开发者在开发过程中会针对当前所开发的内容进行自测,但是避免不了会有一些疏漏或者测试不到位的地方,导致一些很常见的bug的的出现。也可能对于一个方法或者组件的调整导致引用其方法或组件的其他组件受到影响而没有进行测试导致交付的bug

所以单元测试还是非常有必要的,但是更多的时候缺忽略了单元测试的重要性,一个完整的项目单元测试的存在还是非常重要的,这篇博客将会带你重新认识单元测试,另一方便将教会你从零开始搭建环境和如何使用单元测试。

为什么要写单元测试

  1. 必要性:JavaScript缺少类型检查,编译期间无法定位到错误,单元测试可以帮助你测试多种异常情况。
  2. 正确性:测试可以验证代码的正确性,在上线前做到心里有底。
  3. 自动化:通过console虽然可以打印出内部信息,但是这是一次性的事情,下次测试还需要从头来过,效率不能得到保证。通过编写测试用例,可以做到一次编写,多次运行。
  4. 保证重构:互联网行业产品迭代速度很快,迭代后必然存在代码重构的过程,那怎么才能保证重构后代码的质量呢?有测试用例做后盾,就可以大胆的进行重构。

单元测试和端对端测试

对于大多数开发者来说对于Unit(俗称:单元测试)可能听说的比较多,对于E2E(端到端测试)不是那么特别的了解。那么两者之间有什么区别呢?

单元测试

单元测试(Unit)是站在程序员的角度测试,unit测试是把代码看成一个一个的组件,从而实现每一个组件的单独测试,测试内容主要是组件内的每一个函数的执行结果或返回值是否和测试中断言的结果一致。

端对端测试

端对端测试(E2E)是把我们的程序堪称是一个黑盒子,我不懂你内部是怎么实现的,我只负责打开浏览器,把测试内容在页面上输入一遍,看是不是我想要得到的结果。

unit测试是程序员写好自己的逻辑后可以很容易的测试自己的逻辑返回的是不是都正确。e2e代码是测试所有的需求是不是都可以正确的完成,而且最终要的是在代码重构,js改动很多之后,需要对需求进行测试的时候测试代码是不需要改变的,你也不用担心在重构后不能达到客户的需求。

单元测试原则

在编写单元测试时,我们需要mock掉那些依赖于外部系统或库的组件,例如数据库、网络请求等。这样可以确保测试单元独立于外部环境和其他单元的状态,只测试当前单元的功能。而对于那些依赖于内部方法或类的组件,则可以直接进行测试,因为它们是当前单元的一部分。

对于ComposeApi模块,我们应该为其编写独立的单元测试,并使用真实的实现进行测试,因为它是被其他使用者所调用的。这样可以确保ComposeApi模块的代码质量和可维护性,并且在使用ComposeApi模块时,可以保证其功能正常。而对于使用ComposeApi模块的其他模块,在编写单元测试时,应该将其依赖Mock掉,以确保测试只关注当前模块的逻辑,不受其他依赖的影响。这样可以确保测试单元独立于外部环境和其他单元的状态,只测试当前模块的功能。

准备工作

在进行单元测试之前,需要选择适合自己的测试工具。本文采用Jest作为测试工具,因为Jest支持断言和覆盖率测试,具有写法简单、功能强大等优点。使用Jest可以帮助我们更好地进行单元测试,提高代码质量和可靠性。

开始搭建环境我们应该先对以下知识点有所了解:

  1. vite
  2. typescript
  3. vue3

搭建单元测试环境

这里默认你已经有一了一个可以添加测试环境的项目(如果没有请自行创建)。

首先安装对应的依赖:

yarn add jest --dev                  
yarn add @types/jest --dev          
yarn add babel-jest --dev          
yarn add @babel/preset-env --dev
yarn add @vue/vue3-jest --dev 
yarn add ts-jest --dev
yarn add @vue/cli-plugin-unit-jest --dev
yarn add @vue/test-utils@next --dev
yarn add @babel/preset-typescript --dev
yarn add babel-plugin-transform-vite-meta-env --dev
yarn add jest-environment-jsdom --dev
yarn add babel-plugin-transform-import-meta --dev

注:这里使用的是yarn安装的依赖,不要过于纠结包管理工具,根据自己的喜好自行选择即可。

依赖安装完成之后,在src目录下创建tests文件夹,这个文件夹用来存放关于测试相关的文件。

在根目录创建jest.config.js对Jest进行初始化的基本配置:

module.exports = {
  cache: false,
  collectCoverage: true,
  //  babel预设
  transform: {
    "^.+\\.vue$": "@vue/vue3-jest",       //  支持导入Vue文件
    "^.+\\.jsx?$": "babel-jest",    //  支持import语法
    '^.+\\.tsx?$': 'ts-jest',       //  支持ts
    '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub' //  支持导入css文件
  },
  moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'vue'],
  //  路径别名
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  testEnvironment: 'jsdom',
  testEnvironmentOptions: {
    customExportConditions: ['node', 'node-addons'],
  },
  testMatch: [
    '**/tests/**/*.test.[jt]s?(x)',
  ],
  preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel'
};

由于这里使用的是TypeScript也需要对tsconfig.json进行调整,让测试文件也可以支持TypeScript:

//  省略了其他配置项,其他配置根据项目要求自行配置即可
{
  "compilerOptions": {
    //  ...
    "types": ["vite/client", "jest"]    //  指定类型为jest
  },
  "include": [
    // ...
    "tests/**/*.ts"     // 指定单元测试路径
  ]
}

因为Node无法运行TypeScript这里需要使用babelTypeScript进行编译,要配置babel的相关配置,在根目录创建babel.config.js

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current"
        }
      }
    ]
  ],
  plugins: [
    '@babel/plugin-transform-arrow-functions',
    'babel-plugin-transform-vite-meta-env',
    ['babel-plugin-transform-import-meta', { module: 'ES6' }]
  ],
};

对环境配置完成之后为了方便调用测试命令,可以在package.jsonscripts添加快捷指令:

{
  "scripts": {
    //  ...
    "test": "jest"
  },
}

常用断言

  1. toBe(): 测试具体的值
  2. toEqual(): 测试对象类型的值
  3. toBeCalled(): 测试函数被调用
  4. toHaveBeenCalledTimes(): 测试函数被调用的次数
  5. toHaveBeenCalledWith(): 测试函数被调用时的参数
  6. toBeNull(): 结果是null
  7. toBeUndefined(): 结果是undefined
  8. toBeDefined(): 结果是defined
  9. toBeTruthy(): 结果是true
  10. toBeFalsy(): 结果是false
  11. toContain(): 数组匹配,检查是否包含
  12. toMatch(): 匹配字符型规则,支持正则
  13. toBeCloseTo(): 浮点数
  14. toThrow(): 支持字符串,浮点数,变量
  15. toMatchSnapshot(): jest特有的快照测试
  16. not.toBe(): 前面加上.not就是否定形式

创建第一个单元测试

对单元测试环境配置完成之后,接下来可以添加测试文件了:

// test/index.test.ts

test("1+1=2", () => {
  expect(1+1).toBe(2);
});

添加完成之后执行命令:

yarn test

看到控制台输出:

 PASS  tests/index.test.ts
 
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 |                   
----------|---------|----------|---------|---------|-------------------

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.798 s
Ran all test suites.
✨  Done in 5.32s.

模块测试

这里的模块而言主要针对的是页面,也就是常说的router部分,这部分主要包括组件的自定义事件以及整体UI功能正确性验证。

页面结构测试

通过mountshallowMountfindfindAll方法都可以返回一个包裹器对象,包裹器会暴露很多封装、遍历和查询其内部的Vue组件实例的便捷的方法。

findfindAll方法都可以都接受一个选择器作为参数,find方法返回匹配选择器的DOM节点或Vue组件的WrapperfindAll方法返回所有匹配选择器的DOM节点或Vue组件的WrappersWrapperArray

情景设想:

Home下有一个p标签,class.outer,里面的内容为Aaron

import Home from "@/pages/Home.vue";
import { mount } from "@vue/test-utils";

describe('Test for Home Page', () => {

    let wrapper;

    beforeEach(() => {
        wrapper = shallow(Home);
    });

    it('get tag dom', () => {
        // 使用Vue组件选择器
        expect(wrapper.is(Test1)).toBe(true);
        // 使用CSS选择器
        expect(wrapper.is('.outer')).toBe(true);
        // 使用CSS选择器
        expect(wrapper.contains('p')).toBe(true)
        // 内容是否正确
        const contentText = wrapper.find('p').text();
        expect(contentText).toBe("Aaron")
    });
    
    it('has tag', () = > {
        //  isEmpty():断言 Wrapper 并不包含子节点。
        expect(wrapper.find("button").isEmpty()).toBeFalsy();
        //  exists():断言 Wrapper 或 WrapperArray 是否存在。
        expect(wrapper.findAll('img').exists()).toBeFalsy()
    });
    
    it('has className', () = > {
        // attributes():返回 Wrapper DOM 节点的特性对象
        expect(wrapper.find('p').attributes().class).toContain('outer');
        // classes():返回 Wrapper DOM 节点的 class 组成的数组
        expect(wrapper.find('p').classes()).toContain('outer');
    });
    
    it('has style', () = > {
        // hasStyle:判断是否有对应的内联样式
        expect(wrapper.find("p").hasStyle('padding-top', '10')).toBeTruthy()
    });
});
路由测试

路由是基于浏览器环境而言的,在单元测试中是没有办法正常使用路由的,需要通过jest.mock方法模拟出vue-router环境。

jest.mock("vue-router", () => {
  const realModule = jest.requireActual("vue-router");
  const mockRouter = {
    ...realModule,
    currentRoute: {},
    useRouter: () => ({
      push(route: any) {
        mockRouter.currentRoute.value = route;
      },
      currentRoute: mockRouter.currentRoute
    })
  };
  return mockRouter;
});

在上述代码中,通过函数所返回的对象,模拟了一个vue-router的环境,并当环境触发push操作的时候更改了currentRoute的值。

情景设想:

假设现在有两个路由配置,分别是HomeAbout,在Home页面中有一个按钮,点击之后跳转到About页面。

现在基于现有情景去写单元测试,具体代码如下:

import Home from "@/pages/Home.vue";
import { useRouter } from 'vue-router';

test("Home.vue GoAbout", async () => {
  const wrapper = mount(Home as any);
  const oBtn1 = wrapper.find("#btn1");
  await oBtn1.trigger("click");
  const router = useRouter();
  const routerName = router.currentRoute?.value?.name;
  expect(routerName).toBe("About");
});

这里需要注意,在代码中调用push时所传递的参数会影响到最终的断言结果。如上代码,在正式环境代码中调用push时所传入的是push({ name: "About" }),所以在最终断言时使用About为最终需要断言的结果。

状态管理测试

状态管理工具有很多,本文中所使用的状态管理工具是pinia,需要对其进行安装。

# npm
npm install pinia -S

# yarn
yarn add pinia -S

pinia已经内置了单元测试的mock环境,我们只需要简单的配置一下即可。

import { setActivePinia, createPinia } from 'pinia';

beforeEach(() => {
  setActivePinia(createPinia());
});

情景设想:

Home下有一个button点击之后需要更改pinia中的一个值(fooValue),更改后的值为张三

现在基于现有情景去写单元测试,具体代码如下:

import Home from "@/pages/Home.vue";
import { useHomeStore } from "@/store/module/Home";

test("Home.vue Change Pinia", () => {
  const homeStore = useHomeStore();
  const vm = mount(Home as any);
  const oBtn = vm.find("#btn");
  oBtn?.trigger("click");
  expect(homeStore.fooValue).toBe("Aaron");
});

注意这里需要吧对应的store文件引入进来,才可以读取到更改后的值。

数据请求测试

在开发中更多的需求是当点击某一个按钮时去出发一个请求或者去该变一个值。这种情况通常也是通过模拟的方式解决。

情景设想:

Home下有一个button点击之后需要进行一个请求ajax或者一个异步操作,去验证最后所得到的值是不是所需要的。

现在基于现有情景去写单元测试,具体代码如下:

import Home from "@/pages/Home.vue";

it('Home onGetAjax', (done) => {
  const wrapper = mount(Home as any, {
    setup() { 
      const data = ref({});
      const onGetAjax = async () => {
        data.value = mockData.data;
      }
      return { onGetAjax, data }
    }
  });
  wrapper.find('#btn3').trigger('click');
  wrapper.vm.$nextTick(async () => {
  await wrapper.vm.onGetAjax();
    expect(wrapper.vm.data).toEqual(mockData.data)
    done();
  });
});

上述onGetAjax中要模拟所有的数据处理操作,当然这里也可以使用axios进行真实的数据请求。除了这种方式还有另外一种方式处理。

import Home from "@/pages/Home.vue";
//  quantityBaseList 最终的mock数据
import { quantityBaseList } from "./quantityBase.mock";

const ajaxMock = (vai) => vai();

const getData = () => {
  return new Promise((res) => {
    res(quantityBaseList)
  })
};

jest.mock("axios", () => {
  const mAxiosInstance = {
    get: jest.fn()
  };
  return {
    create: jest.fn(() => mAxiosInstance),
  };
});

// 这里必须要写
// 需要在这里mock一下请求数据的函数
jest.mock('@/api/Home', () => { 
  return {
    getCreateCustomerData: jest.fn(getData)
  }
});

it("Home getTabeList", () => {
  return wrapper.vm.$nextTick(async (res) => {
    const result = await ajaxMock(getData);
    expect(result.data.items).toEqual(quantityBaseList.data.items);
    expect(result.data.totalCount).toEqual(quantityBaseList.data.totalCount);
  });
});

上述代码直接mock了请求数据的方法,当数据请求完成之后所需要的后续操作进行断言也是可以的。

组件测试

单元测试环境配置成功,接下来也就是重中之重的环节对组件进行测试。单元测试由于是跑在node环境中的,所以很多情况不能直接去使用真实开发环境中的内容测试,所以很多情况需要在做单元测试是手动模拟。

组件参数测试

上面说了很多关于页面相关的,除了页面之外也会对组件进行相关的测试。关于组件的话最常见的可能就是props参数。

情景设想:

一个组件需要接收一个propmodel<object>,只需要测试一下这个model是否可以正常接收。

let model = ref({
  name: "张三"
});

it("provide/inject", () => {
 let parent = mount(Detail as any, {
    props: { model },
  });
  expect(child.vm.parentProps?.model).toEqual(model);
});
自定义事件测试

自定义事件一般指的是,当组件内部执行完某一事件之后,需要通知上一层做出一些响应操作,系统开发中会经常用到。

情景设想:

Home下有一个Foo的组件,Foo中有一个按钮当这个按钮点击的时候Home需要执行一个函数。

import Home from "@/pages/Home.vue";
import Foo from "@/components/Foo/index.vue";

describe('Test for Foo Component', () => {
 wrapper = mount(Home as any);
 
 it('addCounter Fn should be called', () = > {
    const mockFn = jest.fn();
    wrapper.setMethods({
        'addCounter': mockFn
    });
    wrapper.find(Foo).vm.$emit('add', 100);
    expect(mockFn).toHaveBeenCalledTimes(1);
 });
 
 wrapper.destroy()
});

这里使用了$on方法,将Home自定义的add事件替换为Mock函数,对于自定义事件,不能使用trigger方法触发,因为trigger只是用DOM事件。自定义事件使用$emit触发,前提是通过find找到Foo组件。

计算属性测试

计算属性是一个数据, 依赖另外一些数据计算而来的结果,当一个变量的值,需要用另外变量计算而得来。对于计算属性的应用场景也是蛮多的。

情景设想:

FooText组件中,有一个input标签,通过计算属性把input输入的值进行反转,并输出到了组件中一个p标签中。

import FooText from "@/components/FooText/index.vue";

describe('Test for Foo Component', () => {

    beforeEach(() => {
        wrapper = shallow(FooText);
    });
    
    afterEach(() => {
        wrapper.destroy()
    });

    it('test computed', () => {
        //  可以通过 setProps 设置props属性
        wrapper.setProps({needReverse: false});
        wrapper.vm.inputValue = 'ok';
        expect(wrapper.vm.outputValue).toBe('ok');
    });
    
});
数据监听测试

当一个值发生变化时需要执行一些其他操作,一般用于组件封装时,当外部数据发生变化之后,组件内部需要对组件状态进行调整。

情景设想:

FooText组件中,通过watch监听inputvalue的值变化后需要执行一个函数。

import FooText from "@/components/FooText/index.vue";

describe('Test watch', () = > {
    let spy;
  
    beforeEach(() = > {
        wrapper = shallow(FooText);
        spy = jest.spyOn(console, 'log');
    });
    
    afterEach(() = > {
        wrapper.destroy();
        spy.mockClear()
    });
    
    it('is called with the new value in other cases', () = > {
        // 对inputValue赋值时spy会被执行一次,所以需要清除spy的状态
        // 清除已发生的状态
        spy.mockClear();
        wrapper.vm.inputValue = 'ok';
        return wrapper.vm.$nextTick(() = > {
            expect(spy).not.toBeCalled();
        });
    });
};
依赖注入测试

项目中会用到provide/inject这种依赖注入的情况,这种情况一般会涉及到父子组件嵌套的情况。

情景设想:

两个组件分别是DetailDetailItemDetail组件中通过provide向下传入了model,需要测试DetailItem通过inject接收到的内容是否是父组件传入的。

import Detail from "@/components/Detail/index.vue";
import DetailItem from "@/components/DetailItem/index.vue";

it("provide/inject", () => {
    let parent = mount(Detail, {
      props: { model },
      slots: {
        default: DetailItem
      }
    });
    let child = parent.findComponent(DetailItem);
    expect(child.vm.parentProps?.model).toEqual(model);
});
插槽测试

对于slot的测试也是必不可少的,特别有的时候时候slot所暴露出来的参数是否正确,也是让人关系的。

情景设想:

DetailItem组件可以使用slot对内容进行渲染,slot暴露出来了value参数以供渲染使用,测试value是否正确。

it("render slots", () => { 
    const wrapper = mount(DetailItem, {
      global: {
        provide: {
          "detail": reactive({
            model,
            align: "left",
            labelWidth: "100px"
          })
        },
      },
      slots: {
        default: (item) => `<b>${item.value}</b>`
      },
      props: {
        label,
        prop,
        align: "left",
        labelWidth: "100px"
      }
    });
    expect(`<b>${model.value[prop]}</b>`).toBe(wrapper.find(".w-full").text())
})

测试报告说明与使用

sonarQube是一款代码质量管理工具,可以用于检查代码质量、安全性和可维护性等方面。它支持多种编程语言和技术栈,并提供了丰富的指标和报告,帮助开发团队更好地管理和优化代码质量。

对于前端项目,sonarQube可以使用前端的测试报告来评估代码质量。首先,需要在前端项目中配置好测试框架,并生成测试报告。将测试报告导入到sonarQube中,以便sonarQube可以读取并分析它们。sonarQube支持多种测试报告格式,例如JUnitSurefireCobertura等。可以根据前端测试框架生成的测试报告格式,选择相应的sonarQube测试报告插件进行导入。

sonarQube中配置好前端项目的检查项和指标,例如代码覆盖率、代码复杂度、代码重复率等。sonarQube会根据测试报告和检查项,生成相应的代码质量报告和指标分析。开发团队可以根据这些报告和指标,优化和管理前端代码质量。

当执行完单元测试之后会在项目根目录下生成一个名为coverage的文件夹,文件夹中clover.xmllcov.info存放的即是覆盖率相关的内容。

sonar-project.properties的运行命令中添加:

// xml的位置
sonar.testExecutionReportPaths=test/covrage/test-report.xml   
// lcov.info的位置
sonar.javascript.lcov.reportPaths=test/covrage/lcov.info 

jest执行完会生成一个覆盖率统计表,所有在覆盖率统计文件夹下的文件都会被检测,覆盖率指标:

  • File:文件路径
  • Statements: 语句覆盖率,执行到每个语句
  • Branches: 分支覆盖率,执行到每个if代码块
  • Functions: 函数覆盖率,调用到程式中的每一个函数
  • Lines: 行覆盖率, 执行到程序中的每一行
------------------|---------|----------|---------|---------|-------------------
File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s                                          
------------------|---------|----------|---------|---------|-------------------
All files         |   63.95 |    56.42 |   70.31 |   67.68 |                                                            
 src/components   |   68.84 |    65.11 |   67.85 |    73.8 |                                                            
  Foo.vue         |   67.91 |    65.11 |   66.66 |   73.17 | 
------------------|---------|----------|---------|---------|-------------------

总结

单元测试是提高软件开发质量的重要手段,它可以帮助企业提高产品质量和客户满意度,从而为企业的长期发展奠定坚实的基础。通过单元测试,企业可以及时发现并解决代码中的问题,提高代码的可维护性和可扩展性,降低代码维护成本和风险,为未来的开发工作提供更好的基础。同时,单元测试还可以提高开发人员的信心和积极性,促进团队的合作和创新,为企业创造更多的价值和竞争优势。

在进行单元测试时,需要注意的是单元测试并不是一项简单的工作,需要测试者具备一定的技术和经验。测试者需要充分考虑测试用例的覆盖率和测试结果的准确性,确保测试的有效性和可靠性。此外,测试者还需要了解项目需求和功能,根据实际情况进行测试设计和测试用例编写,从而保证测试的全面性和完整性。

总之,单元测试是软件开发过程中不可或缺的一环,它可以帮助企业提高软件开发质量和客户满意度,为企业的长期发展奠定坚实的基础。因此,企业应该高度重视单元测试工作,积极推广单元测试理念,不断提高测试水平和技术能力,为企业的未来发展注入新的动力和活力。


Aaron
4k 声望6.1k 粉丝

Easy life, happy elimination of bugs.