詳解Jest結合Vue-test-utils使用的初步實踐

這篇文章主要介紹了詳解Jest結合Vue-test-utils使用的初步實踐,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨着小編來一起學習學習吧

介紹

Vue-test-utils是Vue的官方的單元測試框架,它提供了一系列非常方便的工具,使我們更加輕鬆的爲Vue構建的應用來編寫單元測試。主流的 JavaScript 測試運行器有很多,但 Vue Test Utils 都能夠支持。它是測試運行器無關的。

Jest,是由Facebook開發的單元測試框架,也是Vue推薦的測試運行器之一。Vue對它的評價是:

Jest 是功能最全的測試運行器。它所需的配置是最少的,默認安裝了 JSDOM,內置斷言且命令行的用戶體驗非常好。不過你需要一個能夠將單文件組件導入到測試中的預處理器。我們已經創建了 vue-jest 預處理器來處理最常見的單文件組件特性,但仍不是 vue-loader 100% 的功能。

我認爲可以這樣理解,Vue-test-utils在Vue和Jest之前提供了一個橋樑,暴露出一些接口,讓我們更加方便的通過Jest爲Vue應用編寫單元測試。

安裝

通過Vue-cli創造模板腳手架時,可以選擇是否啓用單元測試,並且選擇單元測試框架,這樣Vue就幫助我們自動配置好了Jest。

如果是後期添加單元測試的話,首先要安裝Jest和Vue Test Utils:

npm install --save-dev jest @vue/test-utils

然後在package.json中定義一個單元測試的腳本。

// package.json
{
 "scripts": {
  "test": "jest"
 }
}

爲了告訴Jest如何處理*.vue文件,需要安裝和配置vue-jest預處理器:

npm install --save-dev vue-jest

接下來在jest.conf.js配置文件中進行配置:

module.exports = {
 moduleFileExtensions: ['js', 'json', 'vue'],
 moduleNameMapper: {
  '^@/(.*)$': '<rootDir>/src/$1'
 },
 transform: {
  '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
  '.*\\.(vue)$': '<rootDir>/node_modules/vue-jest'
 },
}

其他的具體的配置可以參考官方文檔。

配置好了之後,就可以開始編寫單元測試了。

import { mount } from '@vue/test-utils'
import Component from './component'

describe('Component', () => {
 test('是一個 Vue 實例', () => {
  const wrapper = mount(Component)
  expect(wrapper.isVueInstance()).toBeTruthy()
 })
})

上面的例子中,就是通過vue-test-utils提供的mount方法來掛載組件,創建包裹器和Vue實例

如果不使用vue-test-utils也是可以掛載組件的:

import Vue from 'vue';
import Test1 from '@/components/Test1';

const Constructor = Vue.extend(HelloWorld);
const vm = new Constructor().$mount();

啓用單元測試的命令:

npm run unit

可以在後面加上-- --watch啓動監聽模式

別名配置

使用別名在Vue中很常見,可以讓我們避免使用複雜、易錯的相對路徑:

import Page from '@/components/Test5/Test5'

上面的@就是別名,在使用Vue-cli搭建的項目中,默認已經在webpack.base.conf.js中對@進行了配置:

module.exports = {
 ...
 resolve: {
  extensions: ['.js', '.vue', '.json'],
  alias: {
   'vue$': 'vue/dist/vue.esm.js',
   '@': path.join(__dirname, '..', 'src')
  }
 },
}

同樣,使用Jest時也需要在Jest的配置文件jest.conf.js中進行配置

"jest": {
 "moduleNameMapper": {
  '^@/(.*)$': "<rootDir>/src/$1",
 },
...

Shallow Rendering

創建一個App.vue:

<template>
 <div id="app">
  <Page :messages="messages"></Page>
 </div>
</template>

<script>
 import Page from '@/components/Test1'

 export default {
  name: 'App',
  data() {
   return {
    messages: ['Hello Jest', 'Hello Vue']
   }
  },
  components: {
   Page
  }
 }
</script>

然後創建一個Test1組件

<template>
 <div>
  <p v-for="message in messages" :key="message">{{message}}</p>
 </div>
</template>

<script>
  export default {
  props: ['messages'],
  data() {
   return {}
  }
 }
</script>

針對App.vue編寫單元測試文件App.spec.js

// 從測試實用工具集中導入 `mount()` 方法
import { mount } from 'vue-test-utils';
// 導入你要測試的組件
import App from '@/App';

describe('App.test.js', () => {
 let wrapper,
  vm;

 beforeEach(() => {
  wrapper = mount(App);
  vm = wrapper.vm;
  wrapper.setProps({ messages: ['Cat'] })
 });

 it('equals messages to ["Cat"]', () => {
  expect(vm.messages).toEqual(['Cat'])
 });

 // 爲App的單元測試增加快照(snapshot):
 it('has the expected html structure', () => {
  expect(vm.$el).toMatchSnapshot()
 })
});

執行單元測試後,測試通過,然後Jest會在test/__snapshots__/文件夾下創建一個快照文件App.spec.js.snap

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`App.test.js has the expected html structure 1`] = `
<div
 id="app"
>
 <div>
  <p>
   Cat
  </p>
 </div>
</div>
`;

 

通過快照我們可以發現,子組件Test1被渲染到App中了。

這裏面有一個問題:單元測試應該以獨立的單位進行。也就是說,當我們測試App時,不需要也不應該關注其子組件的情況。這樣才能保證單元測試的獨立性。比如,在created鉤子函數中進行的操作就會給測試帶來不確定的問題。

爲了解決這個問題,Vue-test-utils提供了shallow方法,它和mount一樣,創建一個包含被掛載和渲染的Vue組件的Wrapper,不同的創建的是被存根的子組件。

這個方法可以保證你關心的組件在渲染時沒有同時將其子組件渲染,避免了子組件可能帶來的副作用(比如Http請求等)

所以,將App.spec.js中的mount方法更改爲shallow方法,再次查看快照

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`App.test.js has the expected html structure 1`] = `
<div
 id="app"
>
 <!---->
</div>
`;

可以看出來,子組件沒有被渲染,這時候針對App.vue的單元測試就從組件樹中被完全隔離了。��

測試DOM結構

通過mount、shallow、find、findAll方法都可以返回一個包裹器對象,包裹器會暴露很多封裝、遍歷和查詢其內部的Vue組件實例的便捷的方法。

其中,find和findAll方法都可以都接受一個選擇器作爲參數,find方法返回匹配選擇器的DOM節點或Vue組件的Wrapper,findAll方法返回所有匹配選擇器的DOM節點或Vue組件的Wrappers的WrapperArray。

一個選擇器可以是一個CSS選擇器、一個Vue組件或是一個查找選項對象。

CSS選擇器:可以匹配任何有效的CSS選擇器

  • 標籤選擇器 (div、foo、bar)
  • 類選擇器 (.foo、.bar)
  • 特性選擇器 ([foo]、[foo="bar"])
  • id 選擇器 (#foo、#bar)
  • 僞選擇器 (div:first-of-type)
  • 符合選擇器(div > #bar > .foo、div + .foo)

Vue組件:Vue 組件也是有效的選擇器。

查找選項對象:

  • Name:可以根據一個組件的name選擇元素。wrapper.find({ name: 'my-button' })
  • Ref:可以根據$ref選擇元素。wrapper.find({ ref: 'myButton' })

這樣我們就可以對DOM的結構進行驗證:

describe('Test for Test1 Component', () => {
 let wrapper,
  vm;

 beforeEach(() => {
  // wrapper = mount(App);
  wrapper = shallow(Test1, {
   propsData: {
    messages: ['bye']
   }
  });
 });

 it('is a Test1 component', () => {
  // 使用Vue組件選擇器
  expect(wrapper.is(Test1)).toBe(true);
  // 使用CSS選擇器
  expect(wrapper.is('.outer')).toBe(true);
  // 使用CSS選擇器
  expect(wrapper.contains('p')).toBe(true)
 });
});

還可以進行一步對DOM結構進行更細緻的驗證:

// exists():斷言 Wrapper 或 WrapperArray 是否存在。
it('不存在img', () = > {
 expect(wrapper.findAll('img').exists()).toBeFalsy()
});

// isEmpty():斷言 Wrapper 並不包含子節點。
it('MyButton組件不爲空', () = > {
 expect(wrapper.find(MyButton).isEmpty()).toBeFalsy()
});

// attributes():返回 Wrapper DOM 節點的特性對象
// classes():返回 Wrapper DOM 節點的 class 組成的數組
it('MyButton組件有my-class類', () = > {
 expect(wrapper.find(MyButton).attributes().class).toContain('my-button');
 expect(wrapper.find(MyButton).classes()).toContain('my-button');
})

測試樣式

UI的樣式測試爲了測試我們的樣式是否複合設計稿預期。同時通過樣式測試我們可以感受當我們code變化帶來的UI變化,以及是否符合預期。

inline style :如果樣式是inline style,可以使用hasStyle來驗證,也可以使用Jest的Snapshot Testing最方便。

// hasStyle:判斷是否有對應的內聯樣式
it('MyButton組件有my-class類', () = > {
  expect(wrapper.find(MyButton).hasStyle('padding-top', '10')).toBeTruthy()
})

CSS:屬於E2E測試,把整個系統當作一個黑盒,只有UI會暴露給用戶用來測試一個應用從頭到尾的流程是否和設計時候所想的一樣 。有專門的E2E測試框架。比較流行的E2E測試框架有nightwatch等,關於E2E測試框架的介紹可以參考這篇文章。

測試Props

父組件向子組件傳遞數據使用Props,而子組件向父組件傳遞數據則需要在子組件出發父組件的自定義事件

當測試對父組件向子組件傳遞數據這一行爲時,我們想要測試的當我們傳遞給子組件一個特定的參數,子組件是否會按照我們所斷言的那樣變現。

在初始化時向子組件傳值,使用的方法是propsData。

const wrapper = mount(Foo, {
 propsData: {
  foo: 'bar'
 }
})

也可以使用setProps方法:

const wrapper = mount(Foo)
wrapper.setProps({ foo: 'bar' })

我們傳遞給Test1組件的messages一個['bye']數組,來驗證是否存在:

beforeEach(() = > {
 wrapper = mount(Test1, {
  propsData: {
   messages: ['bye']
  }
 });
});

// props:返回 Wrapper vm 的 props 對象。
it('接收到了bye作爲Props', () = > {
 expect(wrapper.props().messages).toContain('bye')
});

有時候會對Props的Type、默認值或者通過validator對Prop進行自定義的驗證

props: {
 messages: {
  type: Array,
  required: true,
  validator: (messages) = > messages.length > 1,
  default () {
   return [0, 2]
  }
 }
},

通過Vue實例的$options獲取包括Props在內的初始化選項:

// vm.$options返回Vue實例的初始化選項
describe('驗證Props的各個屬性', () = > {
 wrapper = mount(Test1, {
  propsData: {
   messages: ['bye', 'bye', 'bye']
  }
 });
 const messages = wrapper.vm.$options.props.messages;
 it('messages is of type array', () = > {
  expect(messages.type).toBe(Array)
 });
 it('messages is required', () = > {
  expect(messages.required).toBeTruthy()
 });
 it('messages has at least length 2', () = > {
  expect(messages.validator && messages.validator(['a'])).toBeFalsy();
  expect(messages.validator && messages.validator(['a', 'a'])).toBeTruthy();
 });
 wrapper.destroy()
});

測試自定義事件

自定義事件要測試點至少有以下兩個:

  • 測試事件會被正常觸發
  • 測試事件被觸發後的後續行爲符合預期

具體到Test1組件和MyButton組件來看:

TEST1組件:

// TEST1
<MyButton class="my-button" style="padding-top: 10px" buttonValue="Me" @add="addCounter"></MyButton>

// 省略一些代碼

methods: {
 addCounter(value) {
  this.count = value
 }
},

MyButton組件:

<button @click="increment">Click {{buttonValue}} {{innerCount}}</button>、

// 省略一些代碼

data() {
 return {
  innerCount: 0
 }
},
computed: {},
methods: {
 increment() {
  this.innerCount += 1;
  this.$emit('add', this.innerCount)
 }
},

要測試的目的是:

1. 當MyButton組件的按鈕被點擊後會觸發increment事件
2. 點擊事件發生後,Test1組件的addCounter函數會被觸發並且結果符合預期(及數字遞增)

首先爲MyButton編寫單元測試文件:

describe('Test for MyButton Component', () => {
 const wrapper = mount(MyButton);

 it('calls increment when click on button', () => {
  // 創建mock函數
  const mockFn = jest.fn();
  // 設置 Wrapper vm 的方法並強制更新。
  wrapper.setMethods({
   increment: mockFn
  });
  // 觸發按鈕的點擊事件
  wrapper.find('button').trigger('click');
  expect(mockFn).toBeCalled();
  expect(mockFn).toHaveBeenCalledTimes(1)
 })
});

通過setMethods方法用mock函數代替真實的方法,然後就可以斷言點擊按鈕後對應的方法有沒有被觸發、觸發幾次、傳入的參數等等。

現在我們測試了點擊事件後能觸發對應的方法,下面要測試的就是increment方法將觸發Test1組件中自定義的add方法

// increment方法會觸發add方法
it('triggers a addCounter event when a handleClick method is called', () = > {
 const wrapper = mount(MyButton);

 // mock自定義事件
 const mockFn1 = jest.fn();
 wrapper.vm.$on('add', mockFn1);

 // 觸發按鈕的點擊事件
 wrapper.find('button').trigger('click');
 expect(mockFn1).toBeCalled();
 expect(mockFn1).toHaveBeenCalledWith(1);

 // 再次觸發按鈕的點擊事件
 wrapper.find('button').trigger('click');
 expect(mockFn1).toHaveBeenCalledTimes(2);
 expect(mockFn1).toHaveBeenCalledWith(2);
})

這裏使用了$on方法,將Test1自定義的add事件替換爲Mock函數

對於自定義事件,不能使用trigger方法觸發,因爲trigger只是用DOM事件。自定義事件使用$emit觸發,前提是通過find找到MyButton組件

// $emit 觸發自定義事件
describe('驗證addCounter是否被觸發', () = > {
 wrapper = mount(Test1);
 it('addCounter Fn should be called', () = > {
  const mockFn = jest.fn();
  wrapper.setMethods({
   'addCounter': mockFn
  });
  wrapper.find(MyButton).vm.$emit('add', 100);
  expect(mockFn).toHaveBeenCalledTimes(1);
 });
 wrapper.destroy()
});

測試計算屬性

創建Test2組件,實現功能是使用計算屬性將輸入框輸入的字符翻轉:

<template>
 <div class="wrapper">
  <label for="input">輸入:</label>
  <input id="input" type="text" v-model="inputValue">
  <p>輸出:{{outputValue}}</p>
 </div>
</template>

<script>
 export default {
  name: 'Test2',
  props: {
   needReverse: {
    type: Boolean,
    default: false
   }
  },
  data() {
   return {
    inputValue: ''
   }
  },
  computed: {
   outputValue () {
    return this.needReverse ? ([...this.inputValue]).reverse().join('') : this.inputValue
   }
  },
  methods: {},
  components: {}
 }
</script>

<style scoped>
 .wrapper {
  width: 300px;
  margin: 0 auto;
  text-align: left;
 }
</style>

在Test2.spec.js中,可以通過wrapper.vm屬性訪問一個實例所有的方法和屬性。這隻存在於 Vue 組件包裹器中。

describe('Test for Test2 Component', () => {
 let wrapper;

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

 afterEach(() => {
  wrapper.destroy()
 });

 it('returns the string in normal order if reversed property is not true', () => {
  wrapper.setProps({needReverse: false});
  wrapper.vm.inputValue = 'ok';
  expect(wrapper.vm.outputValue).toBe('ok')
 });

 it('returns the string in normal order if reversed property is not provided', () => {
  wrapper.vm.inputValue = 'ok';
  expect(wrapper.vm.outputValue).toBe('ok')
 });

 it('returns the string in reversed order if reversed property is true', () => {
  wrapper.setProps({needReverse: true});
  wrapper.vm.inputValue = 'ok';
  expect(wrapper.vm.outputValue).toBe('ko')
 })

});

測試監聽器

Vue提供的watch選項提供了一個更通用的方法,來響應數據的變化。

爲Test添加偵聽器:

watch: {
 inputValue: function(newValue, oldValue) {
  if (newValue.trim().length > 0 && newValue !== oldValue) {
   this.printNewValue(newValue)
  }
 }
},
methods: {
 printNewValue(value) {
  console.log(value)
 }
},

爲了測試,首先開始測試前將console的log方法用jest的spyOn方法mock掉,最好在測試結束後通過mockClear方法將其重置,避免無關狀態的引入。

describe('Test watch', () = > {
  let spy;
  beforeEach(() = > {
   wrapper = shallow(Test2);
   spy = jest.spyOn(console, 'log')
  });
  afterEach(() = > {
   wrapper.destroy();
   spy.mockClear()
  });
}

然後執行給inputValue賦值,按照預期,spy方法會被調用

it('is called with the new value in other cases', () = > {
 wrapper.vm.inputValue = 'ok';
 expect(spy).toBeCalled()
});

但是在執行之後我們發現並非如此,spy並未被調用,原因是:

watch中的方法被Vue**推遲**到了更新的下一個循環隊列中去異步執行,如果這個watch被觸發多次,只會被推送到隊列一次。這種緩衝行爲可以有效的去掉重複數據造成的不必要的性能開銷。

所以當我們設置了inputValue爲'ok'之後,watch中的方法並沒有立刻執行,但是expect卻執行了,所以斷言失敗了。

解決方法就是將斷言放到$nextTick中,在下一個循環隊列中執行,同時在expect後面執行Jest提供的done()方法,Jest會等到done()方法被執行纔會結束測試。

it('is called with the new value in other cases', (done) = > {
 wrapper.vm.inputValue = 'ok';
 wrapper.vm.$nextTick(() = > {
  expect(spy).toBeCalled();
  done()
 })
});

在測試第二個情況時,由於對inputValue賦值時spy會被執行一次,所以需要清除spy的狀態,這樣才能得出正確的預期:

it('is not called with same value', (done) = > {
 wrapper.vm.inputValue = 'ok';
 wrapper.vm.$nextTick(() = > {
  // 清除已發生的狀態
  spy.mockClear();
  wrapper.vm.inputValue = 'ok';
  wrapper.vm.$nextTick(() = > {
   expect(spy).not.toBeCalled();
   done()
  })
 })
});

測試方法

單元測試的核心之一就是測試方法的行爲是否符合預期,在測試時要避免一切的依賴,將所有的依賴都mock掉。

創建Test3組件,輸入問題後,點擊按鈕後,使用axios發送HTTP請求,獲取答案

<template>
 <div class="wrapper">
  <label for="input">問題:</label>
  <input id="input" type="text" v-model="inputValue">
  <button @click="getAnswer">click</button>
  <p>答案:{{answer}}</p>
  <img :src="src">
 </div>
</template>

<script>
 import axios from 'axios';

 export default {
  name: 'Test3',
  data() {
   return {
    inputValue: 'ok?',
    answer: '',
    src: ''
   }
  },
  methods: {
   getAnswer() {
    const URL = 'https://yesno.wtf/api';
    return axios.get(URL).then(result => {
     if (result && result.data) {
      this.answer = result.data.answer;
      this.src = result.data.image;
      return result
     }
    }).catch(e => {})
   }
  }
 }
</script>

<style scoped>
 .wrapper {
  width: 500px;
  margin: 0 auto;
  text-align: left;
 }
</style>

 這個例子裏面,我們僅僅關注測試getAnswer方法,其他的忽略掉。爲了測試這個方法,我們需要做的有:

  • 我們不需要實際調用axios.get方法,需要將它mock掉
  • 我們需要測試是否調用了axios方法(但是並不實際觸發)並且返回了一個Promise對象
  • 返回的Promise對象執行了回調函數,設置用戶名和頭像

我們現在要做的就是mock掉外部依賴。Jest提供了一個很好的mock系統,讓我們能夠很輕易的mock所有依賴,前面我們用過jest.spyOn方法和jest.fn方法,但對於上面的例子來說,僅使用這兩個方法是不夠的。

我們現在要mock掉整個axios模塊,使用的方法是jest.mock,就可以mock掉依賴的模塊。

jest.mock('dependency-path', implementationFunction)

在Test3.spec.js中,首先將axios中的get方法替換爲我們的mock函數,然後引入相應的模塊

jest.mock('axios', () => ({
 get: jest.fn()
}));
import { shallow } from 'vue-test-utils';
import Test3 from '@/components/Test3';
import axios from 'axios';

然後測試點擊按鈕後,axios的get方法是否被調用:

describe('Test for Test3 Component', () => {
 let wrapper;

 beforeEach(() => {
  axios.get.mockClear();
  wrapper = shallow(Test3);
 });

 afterEach(() = > {
  wrapper.destroy()
 });

 // 點擊按鈕後調用了 getAnswer 方法
 it('getAnswer Fn should be called', () => {
  const mockFn = jest.fn();
  wrapper.setMethods({getAnswer: mockFn});
  wrapper.find('button').trigger('click');
  expect(mockFn).toBeCalled();
 });

 // 點擊按鈕後調用了axios.get方法
 it('axios.get Fn should be called', () => {
  const URL = 'https://yesno.wtf/api';
  wrapper.find('button').trigger('click');
  expect(axios.get).toBeCalledWith(URL)
 });
});

測試結果發現,雖然我們的mock函數被調用了,但是控制檯還是報錯了,原因是我們mock的axios.get方法雖然被調用了,但是並沒有返回任何值,所以報錯了,所以下一步我們要給get方法返回一個Promise,查看方法能否正確處理我們返回的數據

jest.fn()接受一個工廠函數作爲參數,這樣就可以定義其返回值

const mockData = {
 data: {
  answer: 'mock_yes',
  image: 'mock.png'
 }
};
jest.mock('axios', () => ({
 get: jest.fn(() => Promise.resolve(mockData))
}));

getAnswer是一個異步請求,Jest提供的解決異步代碼測試的方法有以下三種:

  1. 回調函數中使用done()參數
  2. Pomise
  3. Aysnc/Await

第一種是使用在異步請求的回調函數中使用Jest提供的叫做done的單參數,Jest會等到done()執行結束後纔會結束測試。

我們使用第二種和第三種方法來測試getAnswer方法的返回值,前提就是在方法中返回一個Promise。(一般來說,在被測試的方法中給出一個返回值會讓測試更加容易)。 Jest會等待Promise解析完成。 如果承諾被拒絕,則測試將自動失敗。

// axios.get方法返回值(Promise)
it('Calls get promise result', () = > {
 return expect(wrapper.vm.getAnswer()).resolves.toEqual(mockData);
});
 

或者可以使用第三種方法,也就是使用async和await來測試異步代碼:

// 可以用 Async/Await 測試 axios.get 方法返回值
it('Calls get promise result 3', async() = > {
 const result = await wrapper.vm.getAnswer();
 expect(result).toEqual(mockData)
});

Jest都提供了resolves和rejects方法作爲then和catch的語法糖:

it('Calls get promise result 2', () = > {
 return wrapper.vm.getAnswer().then(result = > {
  expect(result).toEqual(mockData);
 })
});

it('Calls get promise result 4', async() = > {
 await expect(wrapper.vm.getAnswer()).resolves.toEqual(mockData)
});

mock依賴

我們可以創建一個__mocks__文件夾,將mock文件放入其中,這樣就不必在每個測試文件中去單獨的手動mock模塊的依賴

在__mocks__文件夾下創建axios.js文件:

// test/__mocks__/axios.js
const mock = {
 get: jest.fn(() => Promise.resolve({
  data: {
   answer: 'mock_yes',
   image: 'mock.png'
  }
 }))
};
export default mock

這樣就可以將Test3.spec.js中的jest.mock部分代碼移除了。Jest會自動在__mocks__文件夾下尋找mock的模塊,但是有一點要注意,模塊的註冊和狀態會一直被保存,所有如果我們在Test3.spec.js最後增加一條斷言:

// 如果不清除模塊狀態此條斷言會失敗
it('Axios should not be called here', () = > {
 expect(axios.get).not.toBeCalled()
});

因爲我們在beforeEach中添加了axios.get的狀態清除的語句 axios.get.mockClear(),所以上面的斷言會通過,否則會失敗。

也可以用另外resetModules和clearAllMocks來確保每次開始前都重置模塊和mock依賴的狀態。

beforeEach(() = > {
 wrapper = shallow(Test3);
 jest.resetModules();
 jest.clearAllMocks();
});

我們在項目中有時候會根據需要對不同的Http請求的數據進行Mock,以MockJS爲例,一般每個組件(模塊)都有對應的mock文件,然後通過index.js導入到系統。Jest也可以直接將MockJS的數據導入,只需要在setup.js中導入MockJS的index.js文件即可

測試插槽

插槽(slots)用來在組件中插入、分發內容。創建一個使用slots的組件Test4

// TEST4
<MessageList>
  <Message v-for="message in messages" :key="message" :message="message"></Message>
</MessageList>

// MessageList
<ul class="list-messages">
 <slot></slot>
</ul>

// Message
<li>{{message}}</li>

在測試slots時,我們的關注點是slots中的內容是否在組件中出現在該出現的位置,測試方法和前面介紹的測試DOM結構的方法相同。

具體到例子中來看,我們要測試的是:Message組件是否出現在具有list-messages的類的ul中。在測試時,爲了將slots傳遞給MessageList組件,我們在MessageList.spec.js中的mount或者shallow方法中使用slots屬性

import { mount } from 'vue-test-utils';
import MessageList from '@/components/Test4/MessageList';

describe('Test for MessageList of Test4 Component', () => {
 let wrapper;

 beforeEach(() => {
  wrapper = mount(MessageList, {
   slots: {
    default: '<div class="fake-msg"></div>'
   }
  });
 });

 afterEach(() => {
  wrapper.destroy()
 });

 // 組件中應該通過slots插入了div.fake-msg
 it('Messages are inserted in a ul.list-messages element', () => {
  const list = wrapper.find('ul.list-messages');
  expect(list.contains('div.fake-msg')).toBeTruthy()
 })
});

爲了測試內容是否通過插槽插入了組件,所以我們僞造了一個div.fake-msg通過slots選項傳入MessageList組件,斷言組件中應該存在這個div

不僅如此,slots選項還可以傳入組件或者數組:

import AnyComponent from 'anycomponent'

mount(MessageList, {
 slots: {
  default: AnyComponent // or [AnyComponent, AnyComponent]
 }
})

這裏面有一個問題,例如我們想測試Message組件是否通過插槽插入了MessageList組件中,我們可以將slots選項中傳入Message組件,但是由於Message組件需要傳入message作爲Props,所以按照上面的說明,我們應該這樣做:

beforeEach(() = > {
 const fakeMessage = mount(Message, {
  propsData: {
   message: 'test'
  }
 });
 wrapper = mount(MessageList, {
  slots: {
   default: fakeMessage
  }
 })
});

對應的斷言是:

// 組件中應該通過slots插入了Message,並且傳入的文本是test
it('Messages are inserted in a ul.list-messages element', () = > {
 const list = wrapper.find('ul.list-messages');
 expect(list.contains('li')).toBeTruthy();
 expect(list.find('li').text()).toBe('test')
})

但是這會失敗,查了資料,貌似不能通過這種方式mounted的組件傳入slots中。

雖然如此,我們可以而通過渲染函數(render function)來作爲一種非正式的解決方法:

const fakeMessage = {
 render(h) {
  return h(Message, {
   props: {
    message: 'test'
   }
  })
 }
};
wrapper = mount(MessageList, {
 slots: {
  default: fakeMessage
 }
})

測試命名插槽(Named Slots)

測試命名插槽和默認插槽原理相同,創建Test5組件,裏面應用新的MessageList組件,組件中增加一個給定名字爲header的插槽,並設定默認內容:

<div>
 <header class="list-header">
  <slot name="header">This is a default header</slot>
 </header>
 <ul class="list-messages">
  <slot></slot>
 </ul>
</div>

在Test5中就可以使用這個命名插槽:

<MessageList>
 <header slot="header">Awesome header</header>
 <Message v-for="message in messages" :key="message" :message="message"></Message>
</MessageList>

對MessageList組件進行測試時,首先測試組件中是否渲染了命名插槽的默認內容:

// 渲染命名插槽的默認內容
it('Header slot renders a default header text', () = > {
 const header = wrapper.find('.list-header');
 expect(header.text()).toBe('This is a default header')
});

然後測試插槽是否能插入我們給定的內容,只需要將mount方法中的slots選項的鍵值default改爲被測試的插槽的name即可:

// 向header插槽中插入內容
it('Header slot is rendered withing .list-header', () = > {
 wrapper = mount(MessageList, {
  slots: {
   header: '<header>What an awesome header</header>'
  }
 });
 const header = wrapper.find('.list-header');
 expect(header.text()).toBe('What an awesome header')
})

測試debounce

我們經常使用lodash的debounce方法,來避免一些高頻操作導致的函數在短時間內被反覆執行,比如在Test6組件中,對button的點擊事件進行了debounce,頻率爲500ms,這就意味着如果在500ms內如果用戶再次點擊按鈕,handler方法會被推遲執行:

<template>
 <div class="outer">
  <p>This button has been clicked {{count}}</p>
  <button @click="addCounter">click</button>
 </div>
</template>

<script>
 import _ from 'lodash';
 export default {
  data() {
   return { count: 0 }
  },
  methods: {
   addCounter: _.debounce(function () {
    this.handler()
   }, 500),
   handler() {
    this.count += 1;
   }
  }
 }
</script>

在編寫Test6的單元測試時,我們有一個這樣的預期:當addCounter方法被觸發時,500ms內沒有任何後續操作,handler方法會被觸發

如果沒有進行特殊的處理,單元測試文件應該是這樣的:

import { shallow } from 'vue-test-utils';
import Test6 from '@/components/Test6';

describe('Test for Test6 Component', () => {
 let wrapper;

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

 afterEach(() => {
  wrapper.destroy()
 });

 it('test for lodash', () => {
  const mockFn2 = jest.fn();
  wrapper.setMethods({ handler: mockFn2 });
  wrapper.vm.addCounter();
  expect(mockFn2).toHaveBeenCalledTimes(1);
 })
});

測試結果發現,addCounter被觸發時handler方法並沒有執行

因爲lodash中debounce方法涉及到了setTimeout,`hanlder方法應該是在500ms後執行,所以在此時執行時方法沒有執行。

所以我們需要在Jest中對setTimeout進行特殊的處理:Jest提供了相關的方法,我們需要使用的是jest.useFakeTimers()和jest.runAllTimers()

前者是用來讓Jest模擬我們用到的諸如setTimeout、setInterval等計時器,而後者是執行setTimeout、setInterval等異步任務中的宏任務(macro-task)並且將需要的新的macro-task放入隊列中並執行,更多信息的可以參考官網的timer-mocks。

所以對test6.spec.js進行修改,在代碼開始增加jest.useFakeTimers(),在觸發addCounter方法後通過jest.runAllTimers()觸發macor-task任務

jest.useFakeTimers();

import { shallow } from 'vue-test-utils';
import Test6 from '@/components/Test6';
import _ from 'lodash';

describe('Test for Test6 Component', () => {
 let wrapper;

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

 afterEach(() => {
  wrapper.destroy()
 });

 it('test for lodash', () => {
  const mockFn2 = jest.fn();
  wrapper.setMethods({ handler: mockFn2 });
  wrapper.vm.addCounter();

  jest.runAllTimers();

  expect(mockFn2).toHaveBeenCalledTimes(1);
 })
});

結果還是失敗,報錯原因是:

Ran 100000 timers, and there are still more! Assuming we've hit an infinite recursion and bailing out…

程序陷入了死循環,換用Jest提供額另外一個API:jest.runOnlyPendingTimers(),這個方法只會執行當前隊列中的macro-task,遇到的新的macro-task則不會被執行

將jest.runAllTimers()替換爲jest.runOnlyPendingTimers()後,上面的錯誤消失了,但是handler仍然沒有被執行

在查了許多資料後,這可能是lodash的debounce機制與jest的timer-mocks 無法兼容,如果有人能夠解決這個問題希望能夠指教。

這樣的情況下,我們退而求其次,我們不去驗證addCounter是否會被debounce,因爲debounce是第三方模塊的方法,我們默認認爲是正確的,我們要驗證的是addCounter能夠正確觸發handler方法即可。

所以我們可以另闢蹊徑,通過mock將lodash的debounce修改爲立即執行的函數,我們要做的是爲lodash的debounce替換爲jest.fn(),並且提供一個工廠函數,返回值就是傳入的函數

import _ from 'lodash';

jest.mock('lodash', () => ({
 debounce: jest.fn((fn => fn))
}));

在如此修改後,測試通過,handler方法正確執行

同一個方法的多次mock

在一個組件中,我們可能會多次用到同一個外部的方法,但是每次返回值是不同的,我們可能要對它進行多次不同的mock

舉個例子,在組件Test7中,mounted的時候forData返回一個數組,經過map處理後賦給text,點擊getResult按鈕,返回一個0或1的數字,根據返回值爲result賦值

<template>
 <div class="outer">
  <p>{{text}}</p>
  <p>Result is {{result}}</p>
  <button @click="getResult">getResult</button>
 </div>
</template>

<script>
 import { forData } from '@/helper';
 import axios from 'axios'

 export default {
  data() {
   return {
    text: '',
    result: ''
   }
  },
  async mounted() {
   const ret = await forData(axios.get('text.do'));
   this.text = ret.map(val => val.name)
  },
  methods: {
   async getResult() {
    const res = await forData(axios.get('result.do'));
    switch (res) {
     case 0 : {
      this.result = '000';
      break
     }
     case 1 : {
      this.result = '111';
      break
     }
    }
   },
  }
 }
</script>

針對getResult方法編寫單元測試,針對兩種返回值編寫了兩個用例,在用例中將forData方法mock掉,返回值是一個Promise值,再根據給定的返回值,判斷結果是否符合預期:

describe('Test for Test7 Component', () => {
 let wrapper;

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

 afterEach(() => {
  wrapper.destroy()
 });

 it('test for getResult', async () => {
  // 設定forData返回值
  const mockResult = 0;
  const mockFn = jest.fn(() => (Promise.resolve(mockResult)));
  helper.forData = mockFn;

  // 執行
  await wrapper.vm.getResult();
  // 斷言
  expect(mockFn).toHaveBeenCalledTimes(1);
  expect(wrapper.vm.result).toBe('000')
 });

 it('test for getResult', async () => {
  // 設定forData返回值
  const mockResult = 1;
  const mockFn = jest.fn(() => (Promise.resolve(mockResult)));
  helper.forData = mockFn;

  // 執行
  await wrapper.vm.getResult();
  // 斷言
  expect(mockFn).toHaveBeenCalledTimes(1);
  expect(wrapper.vm.result).toBe('111')
 })
});

運行測試用例,雖然測試用例全部通過,但是控制檯仍然報錯了:

(node:17068) UnhandledPromiseRejectionWarning: TypeError: ret.map is
not a function

爲什麼呢?

原因就是在於,在第一個用例運行之後,代碼中的forData方法被我們mock掉了,所以在運行第二個用例的時候,執行mounted的鉤子函數時,forData返回值就是我們在上個用例中給定的1,所以使用map方法會報錯

爲了解決這個問題,我們需要在beforeEach(或afterEach)中,重置forData的狀態,如果在代碼中使用了MockJS的情況下,我們只需要讓默認的forData獲取的數據走原來的路徑,由MockJS提供假數據即可,這樣我們只需要在一代碼的最開始將forData保存,在beforeEach使用restoreAllMocks方法重置狀態,然後在恢復forData狀態,然後每個用例中針對forData進行單獨的mock即可

const test = helper.forData;

describe('Test for Test7 Component', () => {
 let wrapper;

 beforeEach(() => {
  jest.restoreAllMocks();
  helper.forData = test;
  wrapper = shallow(Test7);
 });

 afterEach(() => {
  wrapper.destroy()
 });

 // 用例不變

如果沒有使用MockJS,那麼都需要我們提供數據,就需要在afterEach中提供mounted時需要的數據:

beforeEach(() = > {
 jest.restoreAllMocks();
 const mockResult = [{ name: 1}, {name: 2}];
 helper.forData = jest.fn(() = > (Promise.resolve(mockResult)));
 wrapper = shallow(Test7);
});
 

這樣處理過後,運行用例通過,並且控制檯也不會報錯了。

如果是在同一個方法中遇到了需要不同返回結果的forData,比如下面的getQuestion方法:

async getQuestion() {
 const r1 = await forData(axios.get('result1.do'));
 const r2 = await forData(axios.get('result2.do'));
 const res = r1 + r2;
 switch (res) {
  case 2:
   {
    this.result = '222';
    break
   }
  case 3:
   {
    this.result = '333';
    break
   }
 }
},

通過forData發出了兩個不同的HTTP請求,返回結果不同,這時我們在測試時就需要使用mockImplementationOnce方法,這個方法mock的函數只被調用一次,多次調用時就會根據定義時的順序依次調用mock函數,所以測試用例如下:

it('test for getQuestion', async() = > {
 // 設定forData返回值
 const mockFn = jest.fn()
  .mockImplementationOnce(() = > (Promise.resolve(1)))
  .mockImplementationOnce(() = > (Promise.resolve(2)));
 helper.forData = mockFn;
 // 執行
 await wrapper.vm.getQuestion();
 // 斷言
 expect(mockFn).toHaveBeenCalledTimes(2);
 expect(wrapper.vm.result).toBe('333')
});

測試用例通過,並且控制檯無報錯。

以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持神馬文庫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章