前端自动化测试

1,312 阅读14分钟

前端测试

编写测试代码的好处:

  • 更快的发现bug,让绝大多数bug在开发阶段发现,提高产品质量
  • 单元测试,通过运行测试代码,观察输入和输出,有时会比注释更让人理解你的代码。
  • 有利于代码重构,如果一个项目的测试代码写的比较完善,重构过程中改动时可以迅速的通过测试代码是否通过检查来查看重构是否正确,提高重构效率。
  • 编写测试代码的过程,可以让开发人员深入思考业务流程,让代码写的更加完善和规范。

缺点:

  • 并不是所有的项目都需要前端测试,写测试代码是需要花费一定时间的,当项目比较简单的时候,花时间写测试代码可能会影响开发效率。

TDD和BDD

TDD 测试驱动开发 Test-Driven Development

​ 在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

单元测试 unit testing

​ 提到TDD,一般就是指单元测试,也可以称为模块测试,指对软件中的最小可测试单元进行检查和验证,在前端中,单元可以理解为一个独立的模块文件,单元测试就是对这样一个模块文件的测试。

BDD 行为驱动开发 Behavior Driven Development

​ 就是先编写业务逻辑代码,然后以使得所有业务逻辑按照预期结果还行的目的,编写测试代码,是一种以用户行为来驱动开发过程的开发模式。

集成测试 Integration Testing

​ 是指对软件中所有的模块按照设计要求进行组装为完整的系统之后,进行检查和验证。在前端,集成测试可以理解为对多个模块实现的一个交互完整的交互流程进行测试。

对于多个模块组成的系统,需要首先将交互行为进行完善,才能按照预期行为编写测试代码。

所以提到BDD,一般就是指集成测试。

Jest测试框架

是一个令人愉快的JavaScript的测试框架。

示例代码

// 功能代码
export function findMax (arr) {
    return Math.max(...arr)
}
// 测试代码
test('findMax函数输出', () => {
    expect(findMax([1, 2, 4, 3])).toBe(4) 
})

1. 准备工作

  1. 初始化。 npm init -y

  2. 安装jest。 npm install --sace-dev jest

  3. 生成jest配置文件。 npx jest --init,生成一份jest的配置文件jest.config.js

    • 测试用例覆盖率: 将每次执行测试后生成的覆盖率报告保存下来需要找到jest.config.js文件下面的collectCoverage属性并设置为true。这样每次执行测试后就会生成一个coverage文件夹,其中coverage/lcov-report/index.html可以展示详细的测试用例覆盖率报告。
  4. 安装Babel。npm i babel-jest @babel/core @babel/preset-env -D,配置.babelrc如下:

    {
      presets:[
        [
          '@babel/preset-env',
          {
            targets:{
              node: 'current',
            }
          }
        ]
      ]
    }
    
  5. 建立相关测试代码文件目录。根目录中创建src文件夹并创建index.js入口文件。在项目根目录下新建__tests__目录,创建index.test.js文件存放测试用例。

  6. package.json文件中新增一条命令:

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

2. 基本使用

2.1 使用流程

采用TDD加单元测试的方式来使用jest进行测试:

在创建的index.js文件中写入如下代码:

function sum(a, b) {
    return a + b
}

function sayHi(e) {
    return 'hi ' + e
}
const dessert = function (name) {
    this.name = name
}

dessert.prototype = {
    enjoy: function () {
        return 'Enjoy the ' + this.name
    },
}

module.exports = { sum, sayHi, dessert }

编写测试用例:

let { sum, dessert, sayHi } = require('../index')
describe('test sum', () => {
    test('sum', () => {
        expect(sum(2, 3)).toBe(5)
    })
})
describe('test dessert feature', () => {
    test('enjoy the cake', () => {
        const cake = new dessert('cake')
        expect(cake.enjoy()).toBe('Enjoy the cake')
    })
})
describe('test sayHi', () => {
    test('hi', () => {
        expect(sayHi('cch')).toBe('hi cch!')
    })
})
  • describe 组合同一类的test用例。
  • test 描述具体的测试用例,单元测试的最小单元。
  • expect 每一个对测试结果的期望,通过expect中的返回值或者函数执行结果来和期望值进行对比。
2.2 覆盖率

npm coverage执行测试,生成测试覆盖率。

![image-20210531164553506](/Users/a58/Library/Application Support/typora-user-images/image-20210531164553506.png)

%Stmts 是语句覆盖率(statement coverage):是不是每个语句都执行了?

%Branch 分支覆盖率(branch coverage):是不是每个if代码块都执行了?

%Funcs 函数覆盖率(function coverage):是不是每个函数都调用了?

%Lines 行覆盖率(line coverage):是不是每一行都执行了?

2.3 匹配器
  • 真假相关

    • toBe() 严格相等,相当于===
    • toEqual()匹配器,只要内容相等就可以。
    • toBeNull()匹配器只匹配null值,不能匹配undefined的值。
    • toBeUndifined()匹配器可以匹配undefined值。
    • toBeDefined()匹配器的意思是只要定义过了,都可以匹配成功。
    • toBeTruthy()和toBeFalsy,是truefalse匹配器。
  • 数字相关

    • toBeLessThan()toBeGreaterThan(),小于或者大于一个数字时,就可以通过测试。
    • toBeGreaterThanOrEqual()toBeLessThanOrEqual(),小于等于或者大于等于。
    • toBeCloseTo(),消除JavaScript浮点精度错误的匹配器。
  • 字符串匹配相关

    • toMatch() 字符串包换关系的匹配器,检查字符串是否与正则表达式匹配,或者直接进行字符串匹配。
    • toContain() 数组的匹配器。
    • toThrow() 对异常进行处理的匹配器,可以检测一个方法会不会抛出异常。
    • not not匹配器是Jest中比较特殊的匹配器,意思就是相反或者说取反。

3. 常见问题

3.1 自动测试
  • 开启自动测试。每次修改测试用例,我们都手动输入yarn test ,这显得很low。可以通过配置package.json文件来设置。
  • 写单元测试的时候,同时运行 --watch 命令,每次保存都会自动运行,查看当前test语句是否通过。对watch模式的几个有用功能的简单介绍:
    1. a键运行所有测试代码
    2. f键只运行所有失败的测试代码
    3. p键按照文件名筛选测试代码(支持正则)
    4. t键按照测试名筛选测试代码(支持正则)
    5. q键盘推出watch模式
    6. enter键触发一次测试运行
  • 每写完一个测试文件, 都可以运行 --coverage 命令, 查看分支或者语句的覆盖率, 也可以定位到某个文件夹, 查看模块的覆盖率。
3.2 异步代码的测试
  • 接收一个回调函数时,无法确定回调是否执行完成,需要加入一个done方法作为参数,保证回调已经完成了。
import axios from 'axios'

export const fetchSuccessData = (fn) => {
    return axios.get('https://fe-mock.renrenaiche.cn/mock/60adc8397af972436c4c62bf/IsSuccess').then((res) => {
        fn(res.data)
    })
}
import { fetchSuccessData } from './fetchData.js'

test('fetchSuccessData ', (done) => {
    fetchSuccessData((data) => {
        // console.log(data);
        expect(data.success).toBeTruthy()
        expect(data).toEqual({
            success: true
        })
        done()
    })
})
  • 直接返回一个promise值的异步函数
export const fetchDataPromise = () => {
    return axios.get('https://fe-mock.renrenaiche.cn/mock/60adc8397af972436c4c62bf/IsSuccess')
}

test('fetchDataPromise ', () => {
    return fetchDataPromise().then((res) => {
        expect(res.data).toEqual({
            success: true
        })
    })
})

test('fetchDataPromise', () => {
  return expect(fetchDataPromise()).resolves.toMatchObject({
    data:{
      success: true
    }
  })
})
  • 不存在的接口。如果想用catch捕获异常,需要结合要使用expect.assertions(1)使用。 因为当使用catch时,只有出现异常的时候才会走这个方法,如果没有出现异常,就不会走这个测试方法,Jest就会默认这个用例通过了测试。 expect.assertions(1)的意思是“断言,必须需要执行一次expect方法才可以通过测试”。

    export const fetchData404 = () =>{
        return axios.get('https://fe-mock.renrenaiche.cn/mock/60adc8397af972436c4c62bf/IsSuccess111')
    }
    
    test('fetchData404 ', ()=>{
        expect.assertions(1) // 代码必须执行一次expect方法
        return fetchData404().catch((e)=>{
            console.log(e.toString())   
            expect(e.toString().indexOf('404')> -1).toBe(true)
    
          })
    })
    
    test('fetchData404 ', ()=>{
       return expect(fetchData404()).rejects.toThrow()
    })
    
  • async await的使用

    test('fetchDataPromise ', async () => {
        await expect(fetchDataPromise()).resolves.toMatchObject({
            data:{
                success: true
            }
        })
    })
    // 也可以这样写
    test('fetchDataPromise ', async () => {
        const res = await fetchDataPromise()
        expect(res.data.success).toBeTruthy
    })
    
    // 返回结果为404时
    test('fetchData404 async await', async () => {
        expect.assertions(2)
        try {
            await fetchData404()
        } catch (error) {
            console.log(error.toString());
            expect(error.toString()).toEqual('Error: Request failed with status code 404')
            expect(error.toString().indexOf('404') > -1).toBe(true)
        }
    })
    
3.3 jest中的钩子函数及其作用域
  • beforeAll()钩子函数的意思是在所有测试用例之前进行执行。
  • afterAll()钩子函数是在完成所有测试用例之后才执行的函数。
  • beforeEach()钩子函数,是在每个测试用例前都会执行一次的钩子函数。
  • afterEach()钩子函数,是在每次测试用例完成测试之后执行一次的钩子函数。
  • 钩子函数在父级分组可作用域子集,类似继承
  • 钩子函数同级分组作用域互不干扰,各起作用
  • 先执行外部的钩子函数,再执行内部的钩子函数
3.4 jest中的mock
  • mock function 对函数进行mock。
  • mock return value 对返回值进行mock。
  • 所有的 mock 函数都有一个特殊的 .mock 属性,它保存了关于此函数如何被调用、调用时的返回值的信息。
  • jest.fn() 进行某些有回调函数的测试,对功能返回值直接模拟。对功能中业务逻辑简化后的重新实现,将原有功能中对应的调用逻辑改为定义的测试数据。
  • jest.mock() 可以mock整个模块中的方法,当某个模块已经被单元测试100%覆盖时,使用jest.mock()去mock该模块。
  • jest.fn()是创建 Mock 函数最简单的方式,如果没有定义函数内部的实现,jest.fn() 会返回 undefined 作为返回值。
  • jest.fn()所创建的Mock函数还可以设置返回值,定义内部实现返回Promise对象
jest.fn() 相关使用
// mock.js
export const run = fn => {
   return fn('this is run!')
}
// mock.test.js
test('测试返回固定值', () => {
  const func = jest.fn()
  func.mockReturnValue('this is mock fn1')
  func.mockReturnValueOnce('this is fn2').mockReturnValue('thi is fn3')
  const a = run(func)
  const b = run(func)
  const c = run(func)
  console.log(a)   // this is mock fn2
  console.log(b)   // this is mock fn3
  console.log(c)   // this is mock fn1
})

test('测试jest.fn()初始化不传参,通过mockImplementation改变函数内容', () => {
  const func = jest.fn()
  func.mockImplementation(() => {
    return 'this is mock fn1'
  })
  func.mockImplementationOnce(() => {
    return 'this is mock fn2'
  })
  const a = run(func)
  const b = run(func)
  const c = run(func)
  console.log(a)   // this is mock fn2
  console.log(b)   // this is mock fn1
  console.log(c)   // this is mock fn1
})

test('测试jest.fn()内部实现', () => {
  let mockFn = jest.fn((num1, num2) => {
    return num1 * num2;
  })
  // 断言mockFn执行后返回100
  expect(mockFn(10, 10)).toBe(100);
})
jest.mock() 相关使用

前端测试时,不需要去调用真实的后台接口,需要模拟 axios/fetch 模块,让它不必调用api也能测试我们的接口调用是否正确。

// fetch.js
function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  };
};

// events.js
import fetch from './fetch';
 
export default {
  async getPostList() {
    return fetch.fetchPostsList(data => {
      console.log('fetchPostsList be called!');
      // do something
    });
  }
}
// 测试代码
import events from '../src/events';
import fetch from '../src/fetch';
 
jest.mock('../src/fetch.js');
 
test('mock 整个 fetch.js模块', async () => {
  expect.assertions(2);
  await events.getPostList();
  expect(fetch.fetchPostsList).toHaveBeenCalled();
  expect(fetch.fetchPostsList).toHaveBeenCalledTimes(1);
});

3.5 ES6中类的测试

如果只测试类的构造函数以及其中方法能够正确的运行的话,可以直接对类和其中的方法进行mock。这样可以避免如果类中的方法过于复杂,测试时执行效率较低的问题。

// util.js
class Util {
    init () {

    }
    fnA () {
        // console.log('fnA.');
    }
    fnB () {
        // console.log('fnB');
    }
}

export default Util


// demo.js
import Util from './util'
const demoFunction = (a, b) => {
    const util = new Util()
    util.fnA(a)
    util.fnB(b)
}

export default demoFunction;


// demoClass.test.js
jest.mock('../class/util.js') // 模拟util  优先执行,发现util是一个类,自动替换类中的构造函数和方法变为jest.fn()
// const util = jset.fn()
// util.fnA = jest.fn()
// util.fnB = jest.fn()
// jest.fn() 可以对是否执行做追溯,查看是否执行

// jest.mock('../class/util.js', ()=> {
//     const util = jest.fn(()=>{
//         console.log('constructor.');
//     })
//     Util.prototype.fnA = jest.fn(()=>{
//         console.log('this is fn A ...');
//     })
// })
import demoFunction from '../class/demo'
import Util from '../class/util'

test('测试 demoFunction', ()=> {

    demoFunction() // 调用这个方法之后,意味着mock的util执行了。
    expect(Util).toHaveBeenCalled();
    // console.log(Util.mock.instances[0]);
    expect(Util.mock.instances[0].fnA).toHaveBeenCalled()
    expect(Util.mock.instances[0].fnB).toHaveBeenCalled()
    // expect(Util.mock.instances[0].init).toHaveBeenCalled()  // demoFunction中没有执行
})
3.6 快照(snapshot)测试

在我们的日常开发中,总会写一些配置性的代码,它们大体不会变化,但是也会有小的变更,这样的配置可能如下:

// config.js
export default getConfig = () => {
  return {
    "author": "cch",
  	"name": "v1",
  	"port": 8000,
  	"server": "http://localhost",
  	"time": Any<Date>,
  }
}
// confog.test.js
import { getConfig } from './config'
test('getConfig测试', () => {
  expect(getConfig()).toEqual({
    "author": "cch",
  	"name": "v1",
  	"port": 8000,
  	"server": "http://localhost",
  	"time": Any<Date>,
  })
})

上述写法虽然可以通过测试,但是如果后续更改了配置文件,就需要同步修改测试代码,非常麻烦。因此jest中出现了快照测试toMatchSnapshot()

// confog.test.js
import { getConfig } from './config'
test('getConfig测试', () => {
  expect(getConfig()).toMatchSnapshot()
})

运行测试代码之后,会在项目根目录下生成一个__snapshots__文件夹,下面有一个snapshot.test.js.snap快照文件。

// snapshot.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`getConfig测试`] = `
Object {
  "author": "cch",
  "name": "v1",
  "port": 8000,
  "server": "http://localhost",
  "time": Any<Date>,
}
`;

jest会在运行toMatchSnapshot()的时候,首先检查有没有这个快照文件,如果没有,则生成,当我们改动配置内容时,比如把port改为8090,再次运行测试代码,测试不通过。

此时可以运行yarn test -- -u更新快照,或者直接按u更新快照。

Vue中的TDD与单元测试

1. 为什么要测试 ?

组件的单元测试有很多好处:

  • 提供描述组件行为的文档
  • 节省手动测试的时间
  • 减少研发新特性时产生的 bug
  • 改进设计
  • 促进重构

自动化测试使得大团队中的开发者可以维护复杂的基础代码。

2.直接用Jest测试Vue组件

当导入一个Vue组件时,它只是一个带有渲染函数和一些属性的对象或者函数。要测试组件行为,则首先得启动它并开启渲染过程。按照Vue的说法就是需要挂载组件。

挂载组件,需要将组件选项转换为一个Vue构造函数。而组件选项对象不是一个有效的构造函数,它只是一个普通的JavaScript对象。这时可以使用Vue.extend方法从选项中创建一个Vue构造函数,并使用new操作符来创建一个实例。

Vue使用el选项在文档中查找添加的被渲染DOM节点。但一般的组件构造函数并没有el选项,因此在创建实例时,它不会自动挂载并生成DOM节点,需要手动调用$mount方法。

当调用$mount时,Vue将生成一些DOM节点,可以使用实例中$el属性在测试中访问这些节点:

import Vue from 'vue';
import Home from '@/views/Home.vue';

describe('Home.vue', () => {
  it('renders msg when mounted', () => {
    const msg = 'Welcome to Your Vue.js App';
    // 使用Home选项创建一个新的Vue构造函数
    const Ctor = Vue.extend(Home);
    // 创建一个新的Vue实例并挂载该实例
    const vm = new Ctor().$mount();
    // 访问DOM元素,检查文本内容
    expect(vm.$el.textContent).toContain(msg)
  })
})

3. Vue Test Utils

Vue 组件单元测试的官方库。

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

import { mount } from '@vue/test-utils';
import Home from '@/views/Home.vue';

describe('Home.vue', () => {
  it('renders msg when mounted', () => {
    const msg = 'Welcome to Your Vue.js App';
    // 使用mount方法挂载组件
    const wrapper = mount(Home);
    // 检查文本内容
    expect(wrapper.text()).toContain(msg)
  })
})
3.1 常见问题
  1. shallowMountmount方法

    mount方法会渲染整个组件树,shallowMount方法仅渲染一层组件树。shallowMount不会渲染当前组件的子组件,mount会渲染当前组件和子组件。

    mount相似,shallowMount挂载一个组件并返回一个包装器。不同之处在于,shallowMount在挂载组件之前对所有子组件进行存根。

    shallowMount可以确保对一个组件进行独立测试,有助于避免测试中因子组件的渲染输出而混乱结果。

    describe('Home.vue', () => {
      it('renders msg when mounted', () => {
        const msg = 'Welcome to Your Vue.js App';
        // 使用shallowMount方法挂载组件
        const wrapper = shallowMount(Home);
        // 检查文本内容
        expect(wrapper.text()).toContain(msg)
      })
    })
    
  2. 测试prop属性

    import { shallowMount } from '@vue/test-utils';
    import Hello from '@/components/HelloWorld.vue';
    
    describe('HelloWorld.vue', () => {
      it('renders msg when mounted', () => {
        const msg = 'hello, world';
        // 使用shallowMount方法挂载组件
        const wrapper = shallowMount(Hello, {
          propsData: {
            msg
          }
        });
        // 检查文本内容
        expect(wrapper.text()).toContain(msg)
        // 直接查找对应元素并检查元素中的内容
        expect(wrapper.find('h1').text()).toContain(msg)
        expect(wrapper.props().msg).toBe(msg)
      })
    })
    
  3. 测试DOM属性 在Vue Test Utils包装器中,有一个attribute方法,可以返回组件属性对象。可以使用该对象来测试属性值。

    describe("About.vue", () => {
      test('dom 属性测试', () => {
        const wrapper = mount(About)
        // console.log(wrapper.find('a').attributes());
        expect(wrapper.find('a').attributes().href).toEqual("http://www.baidu.com")
        expect(wrapper.find('a').attributes().href).toBe("http://www.baidu.com")
      })
    })
    
  4. 测试对应样式 在Vue Test Utils包装器中,有一个classes方法,返回一个class数组。可以对此进行断言,查看元素是否具有一个class

    test('测试样式', () => {
        const wrapper = mount(About)
        // console.log(wrapper.vm)
        const target = wrapper.find('#c2')
        console.log('查看元素的class', target.classes());
        expect(target.classes()).toContain('c2')
        expect(target.element.style.color).toBe('red')
        expect(target.exists()).toBe(true)
      })
    

    toContain匹配器不仅可以检查一个字符串中是否包含另一个字符串,还能比较数组中的值。

    每一个包装器都包含一个element属性,它是对包装器包含的DOM根节点的引用。

  5. 常见方法:

    • attributes 返回包装DOM节点属性对象。如果提供了key,则返回key的值。

      import { mount } from '@vue/test-utils'
      import Foo from './Foo.vue'
      
      const wrapper = mount(Foo)
      expect(wrapper.attributes().id).toBe('foo')
      expect(wrapper.attributes('id')).toBe('foo')
      
    • classes 返回包装器DOM节点类。默认返回一个类名数组,如果提供了类名则返回一个布尔值。

      import { mount } from '@vue/test-utils'
      import Foo from './Foo.vue'
      
      const wrapper = mount(Foo)
      expect(wrapper.classes()).toContain('bar')
      expect(wrapper.classes('bar')).toBe(true)
      
    • isVisible 判断Wrapper是否可见,主要用来判断组件是否被v-show隐藏了

    • props 返回Wrapper vm的属性对象。如果提供了key,则返回key值。

    • setData 设置Wrapper vm数据。

    • triggerWrapperDOM节点上异步触发事件。trigger只对原生DOM事件有效。