前端單元測試那些事

很長一段時間以來,單元測試並非前端工程師應具有的一項技能,但隨着前端工程化的發展,項目日漸複雜化及代碼追求高複用性等,促使單元測試愈發重要,決定整個項目質量的關鍵因素之一html

1.單元測試的意義?

  • 大規模代碼重構時,能保證重構的正確性
  • 保證代碼的質量,驗證功能完整性

2.主流的前端測試框架了解

2.1 框架對比(主流前三)

  • Karma - 基於Node.js的JavaScript測試執行過程管理工具(Test Runner),讓你的代碼自動在多個瀏覽器(chrome,firefox,ie等)環境下運行
  • Mocha - Mocha是一個測試框架,在vue-cli中配合chai斷言庫實現單元測試( Mocha+chai )
  • jest -Jest 是 Facebook 開發的一款 JavaScript 測試框架。在 Facebook 內部普遍用來測試各類 JavaScript 代碼

市場份額

2.2 單元測試分類

  • TDD - (測試驅動開發)側重點偏向開發,經過測試用例來規範約束開發者編寫出質量更高、bug更少的代碼
  • BDD - (行爲驅動開發) 由外到內的開發方式,從外部定義業務成果,再深刻到能實現這些成果,每一個成果會轉化成爲相應的包含驗收標準

簡單來講就是TDD先寫測試模塊,再寫主功能代碼,而後能讓測試模塊經過測試,而BDD是先寫主功能模塊,再寫測試模塊前端

2.3 斷言庫

斷言指的是一些布爾表達式,在程序中的某個特定點該表達式值爲真,判斷代碼的實際執行結果與預期結果是否一致,而斷言庫則是講經常使用的方法封裝起來vue

主流的斷言庫有node

  • assert (TDD)
assert("mike" == user.name);
複製代碼
  • expect.js(BDD) - expect() 風格的斷言
expect(foo).to.be("aa");
複製代碼
  • should.js - BDD(行爲驅動開發)風格貫穿始終
foo.should.be("aa"); //should
複製代碼
  • chai(BDD/TDD) - 集成了expect()、assert()和 should風格的斷言

3.單元測試之 Jest 運用

Jest 是 Facebook 開源的一款 JS 單元測試框架,它也是 React 目前使用的單元測試框架,目前vue官方也把它看成爲單元測試框架官方推薦 。 目前除了 Facebook 外,Twitter、Airbnb 也在使用 Jest。Jest 除了基本的斷言和 Mock 功能外,還有快照測試、實時監控模式、覆蓋度報告等實用功能。 同時 Jest 幾乎不須要作任何配置即可使用。ios

我在項目開發使用jest做爲單元測試框架,結合vue官方的測試工具vue-util-testchrome

3.1 Jest 安裝

npm install --save-dev jest
npm install -g jest
複製代碼

3.2 Jest的配置文件

(1)添加方式vue-cli

  • 自動生成 Jest.config.js
npx jest --init
複製代碼

而後會有一些選擇,根據本身的實際狀況選擇npm

回車後會在項目目錄下自動生成 Jest.config.js配置文件,固然也能夠選擇第二種,手動建立

  • 手動建立並配置 Jest.config.js
const path = require('path');

module.exports = {
  verbose: true,
  rootDir: path.resolve(__dirname, '../../../'),
  moduleFileExtensions: [
    'js',
    'json',
    'vue',
  ],
  testMatch: [
    '<rootDir>/src/test/unit/specs/*.spec.js',
  ], 
  transform: {
    '^.+\\.js$': 'babel-jest',
    '.*\\.(vue)$': 'vue-jest',
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  transformIgnorePatterns: ['/node_modules/'],
  collectCoverage: false,
  coverageReporters: ['json', 'html'],
  coverageDirectory: '<rootDir>/src/test/unit/coverage', 
  collectCoverageFrom: [ 
    'src/components/**/*.(js|vue)',
    '!src/main.js',
    '!src/router/index.js',
    '!**/node_modules/**',
  ],
};
複製代碼

配置解析:json

  • testMatch - 匹配測試用例的文件
  • transform - 用 vue-jest 處理 *.vue 文件,用babel-jest 處理 *.js 文件
  • moduleNameMapper - 支持源代碼中相同的 @ -> src 別名
  • coverageDirectory - 覆蓋率報告的目錄,測試報告所存放的位置
  • collectCoverageFrom - 測試報告想要覆蓋那些文件,目錄,前面加!是避開這些文件

(2)jest命令行工具axios

{
  "name": "test",
  "version": "1.0.0",
  "scripts": {
    "unit": "jest --config src/test/unit/jest.conf.js --coverage",
  },
  dependencies": { "vue-jest": "^3.0.5", }, "devDependencies":{ "@vue/test-utils": "^1.0.0-beta.13", "babel-core": "^7.0.0-bridge.0", "babel-jest": "^21.2.0", "jest": "^21.2.1", } } 複製代碼
  • config - 配置jest配置文件路徑

  • coverage - 生成測試覆蓋率報告

    coverage是jest提供的生成測試覆蓋率報告的命令,須要生成覆蓋率報告的在package.json添加--coverage參數

(3) 單元測試文件命名

以spec.js結尾命名,spec是sepcification的縮寫。就測試而言,Specification指的是給定特性或者必須知足的應用的技術細節

(4)單元測試報告覆蓋率指標

執行: npm run unit

配置後執行該命令會直接生成coverage文件並在終端顯示各個指標的覆蓋率概覽

在網頁中打開coverage目錄下的index.html就能夠看到具體每一個組件的測試報告

  • 語句覆蓋率(statement coverage)是否每一個語句都執行了?
  • 分支覆蓋率(branch coverage)是否每一個函數都調用了?
  • 函數覆蓋率(function coverage)是否每一個if代碼塊都執行了?
  • 行覆蓋率(line coverage) 是否每一行都執行了?

當咱們完成單元測試覆蓋率達不到100%,不用慌,不用過分追求100%的覆蓋率,把核心的功能模塊測通便可,固然若是你要設置最低的覆蓋率檢測,能夠在配置中加入以下,若是覆蓋率低於你所設置的閾值(80%),則測試結果失敗不經過

//jest.config.js
coverageThreshold: {
    "global": {
      "branches": 80,
      "functions": 80,
      "lines": 80,
      "statements": 80
    }
  },
複製代碼

🚀官方文檔

3.3 Jest的經常使用斷言

expect(1+1).toBe(2)//判斷兩個值是否相等,toBe不能判斷對象,須要判斷對象要使用toEqual
expect({a: 1}).toEqual({a: 1});//會遞歸檢查對象的每一個字段
expect(1).not.toBe(2)//判斷不等
expect(n).toBeNull(); //判斷是否爲null
expect(n).toBeTruthy(); //判斷結果爲true
expect(n).toBeFalsy(); //判斷結果爲false
expect(value).toBeCloseTo(0.3); // 浮點數判斷相等
expect(compileAndroidCode).toThrow(ConfigError); //判斷拋出異常
複製代碼

3.4 Jest + Vue Test Utils 測試組件實例

Vue Test Utils 是 Vue.js 官方的單元測試實用工具庫,經過二者結合來測試驗證碼組件,覆蓋各功能測試

//kAuthCode
<template>
    <p class="kauthcode">
        <span class="kauthcode_btn" v-if="vertification" @click="handleCode"> 獲取驗證碼</span>
        <span v-else>{{timer}} 秒從新獲取</span>
    </p>
</template>
<script>
export default {
  name: 'KAuthCode',
  props: {
    phone: {
      type: String,
      require: true,
    },
    type: {
      type: String,
      default: '1',
      require: true,
      validator(t) {
        return ['1', '2'].includes(t);// 1手機 2郵箱
      },
    },
    validateType: {
      type: String,
      default: '1',
      validator(t) {
        return ['1', '2', '3'].includes(t);// 1 消息 2 表單 3自定義
      },
    },
  },
  data() {
    return {
      timer: 60,
      vertification: true,
    };
  },
  methods: {
    handleCode() {
      if (!this.phone) {
        switch (this.type) {
          case '1':
            this.$Message.warning('手機號碼不能爲空');
            break;
          case '2':
            this.$refs.formRef.validateField('code');
            break;
          default: break;
        }
        return;
      }
      this.getCode();
    },
    getCode() {
      let response;
      switch (this.type) {
        case '1':
          response = this.$api.login.getPhoneCode({ mobileNumber: this.phone });
          break;
        case '2':
          response = this.$api.login.getEmailCode({ email: this.phone });
          break;
        default: break;
      }
      response.then(() => {
        this.$Message.success('驗證碼發送成功');
        this.vertification = false;
        const codeTimer = setInterval(() => {
          this.timer -= 1;
          if (this.timer <= 0) {
            this.vertification = true;
            this.timer = 60;
            clearInterval(codeTimer);
          }
        }, 1000);
      });
    },
  },
};
</script>
<style lang="less" scoped>
    .kauthcode {
        span {
            display: inline-block;
            width: 100%;
        }
    }
</style>

複製代碼

測試文件

// kAuthCode.spec.js
import {createLocalVue, mount, shallowMount} from '@vue/test-utils';
import KAuthCode from '@/components/common/KAuthCode.vue';
import login from '@/service/modules/login.js';
import iviewUI from 'view-design';

const localVue = createLocalVue();
localVue.use(iviewUI);
const testPhone = '18898538706';

jest.mock('@/service/modules/login.js', () => ({
    getPhoneCode: () => Promise.resolve({
        data: {
            answer: 'mock_yes',
            image: 'mock.png',
        }
    })
}))
describe('KAuthCode.vue', () => {
    const option = {
        propsData: {
            // phone: testPhone,
            type: '2'
        },
        mocks: {
            $api: {
                login
            },
        },
    };

    beforeEach(() => {
        jest.useFakeTimers();
    });
    afterEach(() => {
        jest.clearAllTimers();
    });

    const wrapper = mount(KAuthCode, option);
    
    it('設置手機號碼', () => {
        const getCode = jest.fn();
        option.methods = {getCode};
        wrapper.find('.kauthcode_btn').trigger('click');
        expect(wrapper.vm.phone).toBe(testPhone);
    });

    it('沒有設置手機號碼應報錯', () => {
        wrapper.setData({type:'2'});
        const status = wrapper.find('.kauthcode_btn').trigger('click');
        expect(status).toBeFalsy();
    });
});

複製代碼

3.5 Vue-Test-Utils API

3.5.1 Wrapper

一個 Wrapper 是一個包括了一個掛載組件或 vnode,以及測試該組件或 vnode 的方法, 經過用mount(component,option)來掛載組件,獲得wrapper包裹器,可經過

  • wrapper.vm 訪問實際的 Vue 實例
  • wrapper.setData 修改實例
  • wrapper.find找到相應的dom並觸發事件`wrapper.find('.kauthcode_btn').trigger('click');
  • propsData - 組件被掛載時對props的設置
import {createLocalVue, mount, shallowMount} from '@vue/test-utils';
import KAuthCode from '@/components/common/KAuthCode.vue';

const option = {
        propsData: {
            // phone: testPhone,
            type: '2'
        },
        mocks: {
            $api: {
                login
            },
        },
    };
    
const wrapper = mount(KAuthCode, option);

複製代碼

ps: 也能夠經過shallowMount來掛載組件,區別在於shallowMount不會渲染子組件,詳細區別,能夠經過shallowMount和mount兩個方法分別掛載同組件並進行快照測試後查看所生成文件內容

3.5.2 CreateLocalVue

返回一個 Vue 的類供你添加組件、混入和安裝插件而不會污染全局的 Vue 類

import {createLocalVue, mount} from '@vue/test-utils';
import iviewUI from 'view-design';

const localVue = createLocalVue();
localVue.use(iviewUI);

複製代碼

3.5.3 測試鉤子

beforeEach和afterEach - 在同一個describe描述中,beforeAll和afterAll會在多個it做用域內執行,適合作一次性設置

  • beforeEach(fn) 在每個測試以前須要作的事情,好比測試以前將某個數據恢復到初始狀態
  • afterEach(fn) 在每個測試用例執行結束以後運行
  • beforeAll(fn) 在全部的測試以前須要作什麼
  • afterAll(fn) 在測試用例執行結束以後運行

調用順序: beforeAll => beforeEach => afterAll => afterEach

beforeEach(() => {
        jest.useFakeTimers();
    });
    afterEach(() => {
        jest.clearAllTimers();
    });
複製代碼

3.5.4 mock函數

三個與 Mock 函數相關的API,分別是jest.fn()、jest.spyOn()、jest.mock()

  • jest.fn() - 是建立Mock函數最簡單的方式,若是沒有定義函數內部的實現,jest.fn()會返回undefined做爲返回值,固然你也能夠給他設置返回值、定義內部實現或返回Promise對象,以下例:
// 斷言mockFn執行後返回值爲name
it('jest.fn()返回值', () => {
  let mockFn = jest.fn().mockReturnValue('name');
  expect(mockFn()).toBe('name');
})

//定義jest.fn()的內部實現並斷言其結果
it('jest.fn()的內部實現', () => {
  let mockFn = jest.fn((a, b) => {
    return a + b;
  })
  expect(mockFn(2, 2)).toBe(4);
})

//jest.fn()返回Promise對象
test('jest.fn()返回Promise', async () => {
  let mockFn = jest.fn().mockResolvedValue('name');
  let result = await mockFn();
  
  // 斷言mockFn經過await關鍵字執行後返回值爲name
  expect(result).toBe('name');
  
  // 斷言mockFn調用後返回的是Promise對象
  expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})
複製代碼
  • jest.mock() - jest.mock 會自動根據被 mock 的模塊組織 mock 對象。mock 對象將具備原模塊的字段和方法
// kAuthCode.spec.js

    jest.mock('@/service/modules/login.js', () => ({
       getPhoneCode: () => Promise.resolve({
           data: {
               answer: 'mock_yes',
               image: 'mock.png',
           }
       })
   }))

  it('設置手機號碼', () => {
       const getCode = jest.fn();
       option.methods = {getCode};
       wrapper.find('.kauthcode_btn').trigger('click');
       expect(getCode).toHaveBeenCalled()
       expect(wrapper.vm.phone).toBe(testPhone);
   });
   
複製代碼

須要用mock掉整個axios的請求,使用toHaveBeenCalled判斷這個方法是否被調用就能夠了

這個例子裏面,咱們只需關注getCode方法,其餘能夠忽略。爲了測試這個方法,咱們應該作到:

  • 咱們不須要實際調用axios.get方法,須要將它mock掉
  • 咱們須要測試是否調用了axios方法(可是並不實際觸發)而且返回了一個Promise對象
  • 返回的Promise對象執行了回調函數

注:有時候會存在一種狀況,在同個組件中調用同個方法,只是返回值不一樣,咱們可能要對它進行屢次不一樣的mock,這時候須要在beforeEach使用restoreAllMocks方法重置狀態

mock的目的:

  • 設置函數返回值
  • 獲取獲函數調用狀況
  • 改變本來函數的內部實現

4. ️踩坑點🏆

1.觸發事件 -   假設組件庫使用的是iview中對<Checkbox>提供的@change事件,可是當咱們進行
wrapper.trigger('change')時,是觸發不了的。<Button>的@click()和<button>的@click也是有區別的。

2。渲染問題 - 組件庫提供的組件渲染後的html,須要經過wrapper.html()來看,可能會與你從控
制臺看到的html有所區別,爲避免測試結果出錯,還應console.log一下wrapper.html()看一下實際的渲染結果

複製代碼

🚀 Vue Test Utils官網配置

相關文章
相關標籤/搜索