一篇文章搞定前端單元測試框架 Jest

前言

雖然有很多前端團隊壓根現在甚至未來都不太可能使用單元測試,包括我自己的團隊,原因無非是耽誤時間,開發任務本身就比較重等等理由。

但是我覺得一味的圖快,永遠是飲鴆止渴,陷入惡性循環,項目快 \--> 代碼爛 \--> 修改和加功能花費更多的時間和精力 \--> 來不及做優化必須更快 \--> 項目快 \--> 代碼爛 \--> ... 無限循環。

這就是做單元測試我認爲最重要的原因就是,重構代碼時,確認功能沒有問題,不怕人員流動,功能遷移,最主要的是跟產品撕b,測試用例就是最好的證據😁。

業務項目用不到的話,如果你寫庫,不寫單測,可能用的同學都會有所顧忌,所以會寫單測是對高級以上前端必備的技能。

單元測試框架基本原理

例如如下的一個測試用例,感受一下基本的樣子長啥,我們後面會把其中用到的方法自己實現一個簡單版本

// 意思是字符串hello是否包含ll
test('測試字符串中是否包含 ll')() ={
    expect(findStr('hello')).toMatch('ll')
})

function findStr(str){
    return `${str} world`
}
複製代碼

我們可以簡單的實現一下上面測試用例用到的方法,test、expect、toMatch,這樣就算掌握了基本的測試框架原理

test

function test(desc, fn){
    try{
        fn();
        console.log(`✅  通過測試用例`)
    }catch{
        console.log(`❌ 沒有通過測試用例`)
    }
}
複製代碼

expect、toMatch

function expect(ret){
    return {
        toMatch(expRet){
            if(typeof ret === 'string'){ throw Error('') }
            if(!ret.includes(expRet)){ throw Error('') }
        }
    }
}
複製代碼

jest 基本配置

必備工具:

$ npm i -D jest babel-jest ts-jest @types/jest
複製代碼

參考配置 jest.config.js,測試文件均放在 tests 目錄中:下面的 testRegex 表示匹配的 tests 文件夾下的以 test 或者 spec 結尾的 jsx 或者 tsx 文件

module.exports = {
  transform: {
    '^.+\\.tsx?$''ts-jest',
  },
  testRegex: '(/tests/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
  moduleFileExtensions: ['tsx''ts''js''jsx''json''node'],
};
複製代碼

最後在 package.json 的 scripts 中加入

{
    test: "jest"
    // 如果要測試覆蓋率,後面加上--coverage
    // 如果要監聽所有測試文件 --watchAll
}
複製代碼

匹配器

匹配器(Matchers)是 Jest 中非常重要的一個概念,它可以提供很多種方式來讓你去驗證你所測試的返回值。舉個例子就明白什麼是匹配器了。

這裏的匹配器掃一眼即可,大概知道有那麼回事,用的時候查你想要的匹配器就行,不用刻意去記憶。

相等匹配,這是我們最常用的匹配規則

test('two plus two is four'() ={
  expect(2 + 2).toBe(4);
});
複製代碼

在這段代碼中 expact(2 + 2) 將返回我們期望的結果,通常情況下我們只需要調用expect就可以,括號中的可以是一個具有返回值的函數,也可以是表達式。後面的toBe 就是一匹配器。

下面列舉一些常用的匹配器:

普通匹配器

test('測試加法 3 + 7'() ={
  // toBe 匹配器 matchers object.is 相當於 ===
  expect(10).toBe(10)
})
複製代碼
test('toEqual 匹配器'() ={
  // toEqual 匹配器 只會匹配內容,不會匹配引用
  const a = { one: 1 }
  expect(a).toEqual({ one: 1 })
})
複製代碼

與真假有關的匹配器

test('toBeNull 匹配器'() ={
  // toBeNull
  const a = null
  expect(a).toBeNull()
})
複製代碼

toBeUndefined:只匹配 undefined

test('toBeUndefined 匹配器'() ={
  const a = undefined
  expect(a).toBeUndefined()
})
複製代碼

toBeDefined:與 toBeUndefined 相反,這裏匹配 null 是通過的

test('toBeDefined 匹配器'() ={
  const a = null
  expect(a).toBeDefined()
})
複製代碼

toBeTruthy:匹配任何 if 語句爲 true

test('toBeTruthy 匹配器'() ={
  const a = 1
  expect(a).toBeTruthy()
})
複製代碼

toBeFalsy:匹配任何 if 語句爲 false

test('toBeFalsy 匹配器'() ={
  const a = 0
  expect(a).toBeFalsy()
})
複製代碼

not:取反

test('not 匹配器'() ={
  const a = 1
  // 以下兩個匹配器是一樣的
  expect(a).not.toBeFalsy()
  expect(a).toBeTruthy()
})
複製代碼

數字

toBeGreaterThan:大於

test('toBeGreaterThan'() ={
  const count = 10
  expect(count).toBeGreaterThan(9)
})
複製代碼

toBeLessThan:小於

test('toBeLessThan'() ={
  const count = 10
  expect(count).toBeLessThan(12)
})
複製代碼

toBeGreaterThanOrEqual:大於等於

test('toBeGreaterThanOrEqual'() ={
  const count = 10
  expect(count).toBeGreaterThanOrEqual(10) // 大於等於 10
})
複製代碼

toBeLessThanOrEqual:小於等於

test('toBeLessThanOrEqual'() ={
  const count = 10
  expect(count).toBeLessThanOrEqual(10) // 小於等於 10
})
複製代碼

toBeCloseTo:計算浮點數

test('toBeCloseTo'() ={
  const firstNumber = 0.1
  const secondNumber = 0.2
  expect(firstNumber + secondNumber).toBeCloseTo(0.3) // 計算浮點數
})
複製代碼

字符串

toMatch:匹配某個特定項字符串,支持正則

test('toMatch'() ={
  const str = 'http://www.zsh.com'
  expect(str).toMatch('zsh')
  expect(str).toMatch(/zsh/)
})
複製代碼

數組

toContain:匹配是否包含某個特定項

test('toContain'() ={
  const arr = ['z''s''h']
  const data = new Set(arr)
  expect(data).toContain('z')
})
複製代碼

異常

toThrow

const throwNewErrorFunc = () ={
  throw new Error('this is a new error')
}
test('toThrow'() ={
  // 拋出的異常也要一樣纔可以通過,也可以寫正則表達式
  expect(throwNewErrorFunc).toThrow('this is a new error')
})
複製代碼

測試異步代碼

假設請求函數如下

const fethUserInfo = fetch('http://xxxx')
複製代碼

測試異步代碼有好幾種方式,我就推薦一種我認爲比較常用的方式

// fetchData.test.js

// 測試promise成功需要加.resolves方法
test('the data is peanut butter', async () ={
    await expect(fethUserInfo()).resolves.toBe('peanut butter');
});

// 測試promise成功需要加.rejects方法
test('the fetch fails with an error', async () ={
    await expect(fethUserInfo()).rejects.toMatch('error');
});
複製代碼

作用域

jest 提供一個 describle 函數來分離各個 test 測試用例,就是把相關的代碼放到一類分組中,這麼簡單,看個例子就懂了。

// 分組一
describe('Test xxFunction'() ={
  test('Test default return zero'() ={
      expect(xxFunction()).toBe(0)
  })

  // ...其它test
})

// 分組二
describe('Test xxFunction2'() ={
  test('Pass 3 can return 9'() ={
      expect(xxFunction2(3)).toBe(9)
  })

  // ...其它test
})
複製代碼

鉤子函數

jest 中有 4 個鉤子函數

我們舉例來說明爲什麼需要他們。

index.js 中寫入一些待測試方法

export default class compute {
  constructor() {
    this.number = 0
  }
  addOne() {
    this.number += 1
  }
  addTwo() {
    this.number += 2
  }
  minusOne() {
    this.number -= 1
  }
  minusTwo() {
    this.number -= 2
  }
}
複製代碼

假如我們要 在 index.test.js 中寫測試實例

import compute from './index'

const Compute = new compute()

test('測試 addOne'() ={
  Compute.addOne()
  expect(Compute.number).toBe(1)
})

test('測試 minusOne'() ={
  Compute.minusOne()
  expect(Compute.number).toBe(0)
})
複製代碼

我們接着看一下什麼情況下使用beforeAll,假如我們測試數據庫數據是否保存正確

這裏說到

// 模擬數據庫
const userDB = [
  { id: 1, name: '小明' },
  { id: 2, name: '小花' },
]

// 新增數據
const insertTestData = data ={
  // userDB,push數據
}

// 刪除數據
const deleteTestData = id ={
  // userDB,delete數據
}

// 全部測試完
afterAll(() ={
  console.log(userDB)
})

describe('Test about user data'() ={

  beforeAll(() ={
      insertTestData({ id: 99, name: 'CS' })
  })
  afterAll(() ={
      deleteTestData(99)
  })

})
複製代碼

jest 裏的 Mock

爲什麼要使用 Mock 函數?

在項目中,經常會碰見 A 模塊掉 B 模塊的方法。並且,在單元測試中,我們可能並不需要關心內部調用的方法的執行過程和結果,只想知道它是否被正確調用即可,甚至會指定該函數的返回值。此時,就需要 mock 函數了。

Mock 函數提供的以下三種特性,在我們寫測試代碼時十分有用:

jest.fn()

jest.fn()是創建 Mock 函數最常用的方式。

test('測試jest.fn()'() ={
  let mockFn = jest.fn();
  let result = mockFn(1);

  // 斷言mockFn被調用
  expect(mockFn).toBeCalled();
  // 斷言mockFn被調用了一次
  expect(mockFn).toBeCalledTimes(1);
  // 斷言mockFn傳入的參數爲1
  expect(mockFn).toHaveBeenCalledWith(1);
})
複製代碼

jest.fn()所創建的 Mock 函數還可以設置返回值定義內部實現返回Promise對象

test('測試jest.fn()返回固定值'() ={
  let mockFn = jest.fn().mockReturnValue('default');
  // 斷言mockFn執行後返回值爲default
  expect(mockFn()).toBe('default');
})

test('測試jest.fn()內部實現'() ={
  let mockFn = jest.fn((num1, num2) ={
    return num1 * num2;
  })
  // 斷言mockFn執行後返回100
  expect(mockFn(10, 10)).toBe(100);
})

test('測試jest.fn()返回Promise', async () ={
  let mockFn = jest.fn().mockResolvedValue('default');
  let result = await mockFn();
  // 斷言mockFn通過await關鍵字執行後返回值爲default
  expect(result).toBe('default');
  // 斷言mockFn調用後返回的是Promise對象
  expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})
複製代碼

2. jest.mock()

fetch.js文件夾中封裝的請求方法可能我們在其他模塊被調用的時候,並不需要進行實際的請求(請求方法已經通過單測或需要該方法返回非真實數據)。此時,使用jest.mock()去 mock 整個模塊是十分有必要的。

下面我們在src/fetch.js的同級目錄下創建一個src/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);
});
複製代碼

在測試代碼中我們使用了jest.mock('../src/fetch.js')去 mock 整個fetch.js模塊。如果註釋掉這行代碼,執行測試腳本時會出現以下報錯信息

從這個報錯中,我們可以總結出一個重要的結論:

在 jest 中如果想捕獲函數的調用情況,則該函數必須被 mock 或者 spy!

3. jest.spyOn()

jest.spyOn()方法同樣創建一個 mock 函數,但是該 mock 函數不僅能夠捕獲函數的調用情況,還可以正常的執行被 spy 的函數。實際上,jest.spyOn()jest.fn()的語法糖,它創建了一個和被 spy 的函數具有相同內部代碼的 mock 函數。

上圖是之前jest.mock()的示例代碼中的正確執行結果的截圖,從 shell 腳本中可以看到console.log('fetchPostsList be called!');這行代碼並沒有在 shell 中被打印,這是因爲通過jest.mock()後,模塊內的方法是不會被 jest 所實際執行的。這時我們就需要使用jest.spyOn()

// functions.test.js

import events from '../src/events';
import fetch from '../src/fetch';

test('使用jest.spyOn()監控fetch.fetchPostsList被正常調用', async() ={
  expect.assertions(2);
  const spyFn = jest.spyOn(fetch, 'fetchPostsList');
  await events.getPostList();
  expect(spyFn).toHaveBeenCalled();
  expect(spyFn).toHaveBeenCalledTimes(1);
})
複製代碼

執行npm run test後,可以看到 shell 中的打印信息,說明通過jest.spyOn()fetchPostsList被正常的執行了。

快照

快照就是對你對比的數據會存一份副本,啥意思呢,我們舉個例子:

這是index.js

export const data2 = () ={
  return {
    name: 'zhangsan',
    age: 26,
    time: new Date()
  }
}
複製代碼

index.test.js 中寫入一些測試實例

import { data2 } from "./index"

it('測試快照 data2'() ={
  expect(data2()).toMatchSnapshot({
    name: 'zhangsan',
    age: 26,
    time: expect.any(Date) //用於聲明是個時間類型,否則時間會一直改變,快照不通過
  })
})
複製代碼

執行npm run test會生成一個__snapshots__文件夾,裏面是生成的快照,當你修改一下測試代碼時,會提示你,快照不匹配。如果你確定你需要修改,按 u 鍵,即可更新快照。這用於 UI 組件的測試非常有用。

React 的 BDD 單測

接下來我們看下 react 代碼如何進行測試,用一個很小的例子來說明。

案例中引入了 enzyme。Enzyme 來自 airbnb 公司,是一個用於 React 的 JavaScript 測試工具,方便你判斷、操縱和歷遍 React Components 輸出。

我們達成的目的是檢測:

react 代碼如下

import React from 'react';
function Counter(){
    return (
        <ul>
            <li>
                <button id='counter1' className='button1'>counter1</button>
            </li>
            <li>
                <button id='counter2' className='button2'>counter2</button>
            </li>
        </ul>
    )
}
複製代碼

單測的文件:

import Counter from xx;
import { mount } from 'enzyme';

describle('測試APP',() ={
    test('用戶進入首頁,看到兩個按鈕,分別是counter1和counter2,並且按鈕文字也是counter1和counter2',()=>{
        const wrapper = mount(<Counter />);
        const button = wrapper.find('button');
        except(button).toHaveLength(2);
        except(button.at(0).text()).toBe('counter1');
        except(button.at(1).text()).toBe('counter2');
    })
})
複製代碼

Jest | 測試設置分類(describe)及作用域 [1]

jest 入門單元測試 [2]

關於本文

作者:孟祥_成都

https://juejin.cn/post/7092188990471667749

最後

歡迎關注【前端瓶子君】✿✿ヽ (°▽°) ノ✿

回覆「算法」,加入前端編程源碼算法羣!領取最新最熱的前端算法小書、面試小書以及海量簡歷模板,期待與你共進步!

回覆「交流」,吹吹水、聊聊技術、吐吐槽!

回覆「閱讀」,每日刷刷高質量好文!

如果這篇文章對你有幫助,「在看」是最大的支持

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/PZsYCKfij8MdxVxPq9B5Kg