基于jest的前端自动化测试

1,840 阅读11分钟

前端测试简介

黑盒&白盒

  • 黑盒测试一般也被称为功能测试,黑盒测试要求测试人员将程序看作一个整体,不考虑其内部结构和特性,只是按照期望验证程序是否能正常工作
  • 白盒测试是基于代码本身的测试,一般指对代码逻辑结构的测试。

测试分类

单元测试(Unit Testing)

单元测试是指对程序中最小可测试单元进行的测试,例如测试一个函数一个模块一个组件..

集成测试(Integration Testing)

将已测试过的单元测试函数进行组合集成暴露出的高层函数或类的封装,对这些函数或类进行的测试

端到端测试(E2E Testing)

模拟用户的行为,例如点击,输入等操作。然后观察页面中的元素展现形式是否和预期一致。以此来判断是否通过测试

TDD&BDD

TDD是测试驱动开发(Test-Driven Development)

TDD的原理是在开发功能,业务代码之前,先编写单元测试用例代码

BDD是行为驱动开发(Behavior-Driven Development)

开发者、测试人员一起合作,分析软件的需求,然后将这些需求写成一个个的故事。开发者负责填充这些故事的内容,保证程序实现效果与用户需求一致。

小结

TDD是先写测试再开发 (一般都是单元测试,白盒测试);而BDD则是按照用户的行为来开发,再根据用户的行为编写测试用例 (一般都是集成测试,黑盒测试)

为什么选择 Jest

自动化测试的执行通常需要测试规范、断言、mock、覆盖率工具等支持,上述工具在繁荣的 Node.js 生态中有很多优秀实现

  • 测试框架:提供一些方便的语法来描述测试用例,常见的测试框架有Jasmine, MochaJest
  • 断言库:提供语义化方法,用于对参与测试的值做各种各样的判断。这些语义化方法会返回测试的结果,要么成功、要么失败。常见的断言库有 Should.js, Chai.js 等。
  • 测试覆盖率工具:用于统计测试用例对代码的测试情况,生成相应的报表,比如 istanbul。 但组合起来使用会带来两个问题
  • 多种工具的选择和学习有一定的成本
  • 把多个工具组合成特定测试解决方案的配置复杂

Jest 是 Facebook 出品的一个测试框架,相对其他测试框架,其有几大特点:

  • 内置了常用的测试工具,比如自带断言、Mock、测试覆盖率工具,实现了开箱即用。
  • 作为一个面向前端的测试框架, Jest 可以利用其特有的快照功能,通过比对 UI 代码生成的快照文件,实现对 Vue、React 等框架的自动测试。
  • Jest 的测试用例是并行执行的,而且只执行发生改变的文件所对应的测试,测试速度快。

jest 安装

npm install jest --save-dev

在 package.json 中添加脚本配置

"scripts": {
    "test": "jest"
  }

这样就可以使用npm run test执行测试代码了

牛刀小试

创建目录

├── src
│   └── math.js
├── test
│   └── math.test.js
├── jest.config.js
└── package.json

创建src/math.js

export function add(a, b) {
  return a + b;
}

创建test/math.test.js


import { add } from "./math";
test("add方法", () => {
  expect(add(1, 2)).toBe(3);
});

其中expect(add(1, 2)).toBe(3)是一句断言,toBeJest 管它叫 matcher (匹配器)

执行测试命令

npm test

默认文件匹配规则 匹配 test 文件夹下的 .js 文件(.jsx .ts .tsx 也可以) 匹配所有后缀为 .test.js 或 .spec.js 的文件(.jsx .ts .tsx 也可以) 可以通过根目录下的 jest.config.js 文件自定义测试文件匹配规则

jest配置文件

根目录下的jest.config.js文件可以自定义jest的详细配置,jest的相关配置也可以在package.json内,为了可读性最好放在独立配置文件

已有的项目添加jest配置文件:

npx jest --init

jest命令行工具

异步模块的测试

jest对异步方法提供了几种测试支持

//async.js
export const fetchDataCallback = (fn) => {
  axios.get("http://www.dell-lee.com/react/api/demo.json").then((res) => {
    fn(res.data);
  });
};

export const fetchData = () => {
  return axios.get("http://www.dell-lee.com/react/api/demo.json");
};

回调

test 方法的第二个函数传入 done 可以用来标识回调执行完成

//async.test.js
//不要这样写!此测试就在没有调用回调函数前结束
test("fetchDataCallback", () => {
  fetchDataCallback((res) => {
    expect(res).toEqual({
      success: true,
    });
  });
});

//正确写法
test("fetchDataCallback", (done) => {
  fetchDataCallback((res) => {
    try {
      expect(res).toEqual({
        success: true,
      });
      done();
    } catch (err) {
      done(err);
    }
  });
});

promise

test("fetchData", () => {
  return fetchData().then((res) => {
    expect(res.data).toEqual({
      success: true,
    });
  });
});

一定要把 Promise 做为返回值,否则测试用例会在异步方法执行完之前结束,如果希望单独测试 resolve 可以使用另外一种书写方式

test("fetchData", () => {
  return expect(fetchData()).resolves.toMatchObject({
    data: {
      success: true,
    },
  });
});

async/await

async/await 测试比较简单,只要外层方法声明为 async 即可

test("fetchData", async () => {
  const res = await fetchData();
  expect(res.data).toEqual({
    success: true,
  });
});

测试用例钩子函数

写测试用例的时候经常需要在运行测试前做一些预执行,和在运行测试后进行一些清理工作,Jest 提供辅助函数来处理这个问题

一次性设置

如果相关任务全局只需要执行一次,可以使用 beforeAllafterAll

beforeAll(() => {
  init()
});

afterAll(() => {
  clear()
});

test("addOne,", () => {
  counter.addOne();
  expect(counter.number).toBe(1);
});

test("minusOne,", () => {
  counter.minusOne();
  expect(counter.number).toBe(-1);
});

多次重复

如果在每个测试任务开始前需要执行数据初始化工作、结束后执行数据清理工作,可以使用 beforeEachafterEach

import Counter from "./hook";
let counter = null;

beforeEach(() => {
 counter = new Counter();
});

afterEach(() => {
  counter=null
});

test("addOne,", () => {
  counter.addOne();
  expect(counter.number).toBe(1);
});

test("minusOne,", () => {
  counter.minusOne();
  expect(counter.number).toBe(-1);
});

作用域

默认情况下,beforeafter 的块可以应用到文件中的每个测试。 可以通过 describe 块来将测试分组。 当 beforeafter 的块在 describe 块内部时,则其只适用于该 describe 块内的测试


  
  describe("分组1", () => {
    beforeAll(()=>{})
    beforeEach(() => {});
    afterRAch(()=>{})
    afterAll(()=>{})
    
    test("addOne,", () => {
      counter.addOne();
      expect(counter.number).toBe(1);
    });

    test("addTwo,", () => {
      counter.addTwo();
      expect(counter.number).toBe(2);
    });
  });

  describe("分组2", () => {
   beforeAll(()=>{})
    beforeEach(() => {});
    afterRAch(()=>{})
    afterAll(()=>{})
    
    test("minusOne,", () => {
      counter.minusOne();
      expect(counter.number).toBe(-1);
    });

    test("minusTwo,", () => {
      counter.minusTwo();
      expect(counter.number).toBe(-2);
    });
  });
});

jest 中的 mock

在很多时候测试用例需要在相关环境下才能正常运行,jest 提供了丰富的环境模拟支持

mock函数

使用 jest.fn() mock一个函数,mock函数有.mock 属性

test("returnCallback", () => {
  //创建mock函数
  const func = jest.fn();
  //模拟函数返回值
  func.mockReturnValue("callbackDone");
  returnCallback(func);
  //mock属性,函数被调用及返回值信息
  expect(func.mock.results[0].value).toBe("callbackDone");
});

mock Ajax请求

某些功能依赖了axios发异步请求,在实际测试的时候我们希望直接返回既定结果,不用发请求,就可以 mock axios

//ajax
export const getData = () => {
  return axios.get("/api").then((res) => res.data);
};
//test
import Axios from "axios";
//mock axios,不会真正发送ajax请求
jest.mock("axios");

test("getData", async () => {
  //模拟ajax请求返回结果
  Axios.get.mockResolvedValue({ data: "hello" });
   // 也可以模拟其实现
  // axios.get.mockImplementation(() => Promise.resolve(resp));

  await getData().then((data) => {
    expect(data).toBe("hello");
  });
});

Timer Mocks

对使用延时器模块进行测试,由于延时时间不一定,每次测试都要等待,影响测试效率,jest 能够模拟延时器,加快测试用例执行

export const timer = (callback) => {
  setTimeout(() => {
    callback();
  }, 3000);
};
jest.useFakeTimers();
test("timer", () => {
  const fn = jest.fn();
  timer(fn);
  //快进3000ms
  jest.advanceTimersByTime(3000);
  expect(fn).toHaveBeenCalledTimes(1);
});

mock模块

假如有一个模块引用另一个模块的方法

import Util from "./util";
export const demoFun = () => {
  const util = new Util();
  util.a();
  util.b();
};

使用 jest.mock(模块名) 可以mock一个模块

jest.mock("./util");
import Util from "./util";
import { demoFun } from "./demoFun";
test("测试demoFun", () => {
  demoFun();
  expect(Util).toHaveBeenCalled();
  expect(Util.mock.instances[0].a).toHaveBeenCalled();
  expect(Util.mock.instances[0].b).toHaveBeenCalled();
});

jest.mock发现Util是一个类,会自动把类的构造函数和方法变成jest.fn()

const Util = jest.fn();
Util.init = jest.fn();
Util.a = jest.fn();
Util.b = jest.fn();

snapShoot 快照

项目中经常有一些配置文件。比如:

//config.js
export const generateConfig = () => {
  return {
    server: "http://localhost",
    port: 8080,
  };
};

那测试它的测试用例可以这样写

import { generateConfig } from './snapshot.js'
test('测试 generateConfig', () => {
    expect(generateConfig()).toEqual({
        server: 'http://localhost',
        port: '8080'
    })

当配置文件不断增加时,要不断同步测试用例,jest提供snapshoot,我们可以这样写

import { generateConfig } from './snapshot.js'

test("toMatchSnapshot", () => {
  expect(generateConfig()).toMatchSnapshot();
});

toMatchSnapshot() 会为expect 的结果做一个快照并与前面的快照做匹配,如果前面没有快照那就会保存当前生成的快照,如果确实要改配置文件,那就要更新快照

假设配置中存在随机的变量,如下

export const generateConfig  = () => {
    return {
        server: 'http://localhost',
        port: '8080',
        time: new Date()
    }

因为每次生成的快照都与之前的不同,所以测试用例不会通过,在case中可以使用expect.any()


import { generateConfig } from './snapshot.js'
 
test('测试 generateConfig', () => {
    expect(generateConfig()).toMatchSnapshot({
        time: expect.any(Date)
    })


之前使用snapshoot都会生成一个单独的快照文件,另一种方法行内快照,需要先安装pretter

npm install pretter --save

修改测试用例

test("toMatchInlineSnapshot", () => {
  expect(generateConfig()).toMatchInlineSnapshot({
    date: expect.any(Date),
  });
});

运行测试用例,快照被保存到测试用例代码中

import { generateConfig } from "./snapshot.js";
 
test("测试 generateConfig", () => {
  expect(generateConfig()).toMatchInlineSnapshot(
    {
      time: expect.any(Date)
    },
    `
    Object {
      "port": "8080",
      "server": "http://localhost",
      "time": Any<Date>,
    }
  `
  );
});

测试覆盖率报告

jest 还提供了测试覆盖率的支持,执行命令 npm test -- --coverage 或者配置 package.json

"scripts": {
    "test": "jest",
    "coverage": "jest --coverage"
  }

执行测试命令即可 命令执行完成会在项目根目录添加coverage文件夹,使用浏览器打开coverage/lcov-report/index.html文件,有可视化的测试报告

Vue中集成jest测试

搭载测试环境

对于新项目来说,可以使用VUE-CLI工具创建项目

只需要在选择配置时选Unit Testing单元测试,并选择JEST作为测试框架即可

而对于现有项目而言(针对@vue/cli)想增加jest测试模块。运行以下命令行就会帮我们去安装jest模块。

vue add unit-jest

jest配置

集成 Jest 后,会在根目录下生成一个jest.config.js文件。并配置了@vue/cli-plugin-unit-jest,这个预设。预设配置为如下内容。

odule.exports = {
  moduleFileExtensions: [
    "js",
    "jsx",
    "json",
    // tell Jest to handle *.vue files
    "vue"
  ],
  transform: {
    // process *.vue files with vue-jest
    "^.+\\.vue$": require.resolve("vue-jest"),
    ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": require.resolve(
      "jest-transform-stub"
    ),
    "^.+\\.jsx?$": require.resolve("babel-jest")
  },
  transformIgnorePatterns: ["/node_modules/"],
  // support the same @ -> src alias mapping in source code
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1"
  },
  testEnvironment: "jest-environment-jsdom-fifteen",
  // serializer for snapshots
  snapshotSerializers: ["jest-serializer-vue"],
  testMatch: ["**/tests/unit/**/*.spec.[jt]s?(x)", "**/__tests__/*.[jt]s?(x)"],
  // https://github.com/facebook/jest/issues/6766
  testURL: "http://localhost/",
  watchPlugins: [
    require.resolve("jest-watch-typeahead/filename"),
    require.resolve("jest-watch-typeahead/testname")
  ]
};

这个预设可能不能满足需求,我们可以对他进行一些拓展

挂载组件

要测试Vue组件行为,首先要开启渲染过程,在vue里面也就是需要先挂载组件。 挂载组件,需要将组件转换为一个构造函数。而vue组件选项只是一个普通的JavaScript对象。这时可以使用Vue.extend方法从选项中创建一个vue构造函数:

import Vue from 'vue';
import TodoList from "../todoList";
const Cons = Vue.extend(TodoList);

现在就可以使用new操作符创建实例:

const vm = new Cons();

Vue创建实例时,不会自动挂载生成DOM节点,需要手动调用$mount方法:

const vm = new Cons().$mount()

当调用完mountvue会生成DOM节点,可以使用实例中mount,vue会生成DOM节点,可以使用实例中el属性访问这些节点:

expect(vm.$el.textContent).toContain('todoList')

Jest会在jsdom库创建的虚拟浏览器环境中运行测试用例,所以vue组件的单元测试虽然要创建DOM树,并不意味必须在浏览器环境中运行。


import TodoList from "../todoList";
import Vue from "vue";

describe("todolist.vue", () => {
  const msg = "todoList";
  const  Cstor = Vue.extend(TodoList);
  const vm = new  Cstor().$mount();
  it("render", () => {
    expect(vm.$el.textContent).toContain(msg);
  });
});

运行测试用例

npm run test:unit

Vue Test Utils

挂载组件需要自己去创建构造函数并且手动挂载,vue官方的单元测试实用工具库Vue Test Utils,会让Vue组件单元测试变得更加简单。它包含一些辅助方法可以实现组件挂载、与组件交互以及断言组件输出。

mount挂载

Vue Test Utils会导出mount方法,该方法在接收一个组件后,会将其挂载并返回一个包含被挂载组件实例vm的包装器对象。包装器不仅仅只有实例vm,还包括一些辅助方法。其中一个方法就是text,它返回实例的textContent。

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

 it("renders msg when mounted", () => {
   const nsg="todoList"
   //使用mount方法挂载组件
   const wrapper = mount(TodoList);
   //检查文本内容,text方法返回组件渲染的所有文本
   expect(wrapper.text()).toContain(msg)
 });

shallowMount挂载

shallowMount也是挂载组件的方法,与mount不同的是,他只渲染一层组件树,挂载之前对所有子组件存根,shallowMount能确保对一个组件进行独立测试避免因子组件的渲染输出影响结果

测试方案选型

TDD+单元测试

  • TDD一般结合单元测试使用,是白盒测试
  • 先写测试用例再写代码
  • 测试重点在代码
  • 测试覆盖率高
  • 代码量大,尤其对vue组件测试时,与业务代码耦合度高
  • 过于独立,不能保证模块组合起来仍然好用,安全性低

BDD+集成测试

  • 先写代码再写测试用例
  • BDD一般结合集成测试使用,事黑盒测试
  • 测试覆盖率相对低
  • 测试重点在UI(DOM)

小结:

一个项目中不是必须只有一种测试方式,可以根据实际情况组合使用,对工具函数,工具类,重点在代码逻辑的测试可采用TDD+单元测试的方式,对Vue单文件组件一类的,重点在UI(DOM)的测试可采用BDD+集成测试的方式,合理采用高效的测试方法可以加快开发速度,提高代码质量,尽早发现并去除代码中的 BUG。