天天看點

基于VUE的前端單元測試應用(Jest、Langchain)

作者:閃念基因

01

為什麼需要單元測試

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

對于一些長期多人維護的大項目,在沒有單元測試的情況下,隔一段時間很可能由于疏忽重新踩坑

有了單元測試我們就可以為這些問題點編寫對應的測試代碼,每次送出代碼前都執行一遍,可以極大的降低相同 bug 重複出現的機率。

将複雜的代碼拆解成為更簡單、更容易測試的片段,某種程度上編寫單元測試的過程會潛移默化的提高我們代碼的品質(TDD)。

基于VUE的前端單元測試應用(Jest、Langchain)

02

如何寫單元測試

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

單元測試一般包含以下幾個部分:

    • 被測試的對象是什麼(元件、mixins、utils…)
    • 要測試該對象的什麼功能(props、method、emit、頁面渲染…)
    • 實際得到的結果
    • 期望的結果
    • mock

具體到某個單元測試,往往包含以下幾個步驟:

    • 準備階段:構造參數,建立 mock 等
    • 執行階段:用構造好的參數執行被測試代碼
    • 斷言階段:用實際得到的結果與期望的結果比較,以判斷該測試是否正常
    • 清理階段:清理準備階段對外部環境的影響,移除在準備階段建立的執行個體等

針對大而複雜的項目時,單元測試應該圍繞那些可能會出錯的地方及邊界情況。

03

前端單元測試工具

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

在前端領域,有多種單元測試工具可供選擇。一些常見的單元測試工具包括:

基于VUE的前端單元測試應用(Jest、Langchain)
    • 斷言(Assertions):用于判斷結果是否符合預期。有些架構需要單獨的斷言庫。
    • 異步測試:有些架構對異步測試支援良好。
    • Mock:用于特殊處理某些資料,比如隔離非必要第三方庫/元件
    • 代碼覆寫率:計算語句/分支/函數/行覆寫率

考慮到上手難度以及功能全面性,考慮使用的測試工具為:JEST

基于VUE的前端單元測試應用(Jest、Langchain)

04

JEST快速開始

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

1、安裝:npm install --save-dev jest, 如果測試的為VUE架構,需要再借助vue test utils工具(https://v1.test-utils.vuejs.org/zh/)

如果使用vue-cli,選擇 "Manually select features" 和 "Unit Testing",以及 "Jest" 作為 test runner即可

2、一旦安裝完成,cd 進入項目目錄中并運作 npm run test:unit即可

3、編寫測試

這裡舉個簡單例子(PayState.vue)

<template>
  <div class="result-type-wrapper">
    <Icon class="result-icon succ" v-if="state == 'success'"></Icon>
    <p class="result-title">{{ title }}</p>
    <p class="opera-tips">{{ tips }}</p>
    <slot></slot>
  </div>
</template>
 
<script>
export default {
  props: ['title', 'tips', 'state']
};
</script>           

在 tests/unit 中建立一個 payState.test.js。在其内容中,引入 PayState.vue,以及 shallowMount 方法,并添加測試的概要:

import payState from '../PayState.vue';
import { shallowMount } from '@vue/test-utils';
 
describe('payState.vue', () => {
  it('v-if 驗證', () => {
    let wrapper = shallowMount(payState, { 挂載選項 })
    expect(wrapper.findComponent('.result-icon.succ').exists()).toBeFalsy();
  })
})           
    • describe 一般概述了測試會包含什麼,可以了解成檔案夾的概念
    • it (别名test)表示測試應該完成的主題中一段單獨的職責。随着我們為元件添加更多特性,在測試中就會添加更多 it 塊
    • expect表示作出斷言,我們可以看到期望的和實際的結果,也能看到期望是在哪一行失敗的。
基于VUE的前端單元測試應用(Jest、Langchain)
    • 關于斷言中比對器的使用,可以參考文章:JEST比對器(https://jestjs.io/zh-Hans/docs/using-matchers)
    • 而挂載選項的話,參考Vue test utils的官網文檔:挂載選項(https://v1.test-utils.vuejs.org/zh/api/options.html#context)

05

Vue可測試的内容

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

這裡列舉一些在VUE中能測試到的内容,具體是否需要測試,可以按實際情況分析,如果隻是擷取資料,沒有任何業務邏輯,可以忽略

Props

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

1)通過在加載一個元件時傳遞 propsData,就可以設定 props 以用于測試

const wrapper = shallowMount(CreditCard, {
  propsData: {
    showFooter: false
  }
})           

2)可測試的内容:值的邊界情況,以及特殊字元的表現,非要求必傳的時候值的表現情況

Computed 計算屬性

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

1)可以使用 shallowMount 并斷言渲染後的代碼來測試計算屬性

例如,假設你有一個計算屬性 fullName,它由 firstName 和 lastName 計算而來,你可以編寫測試來確定 fullName 在 firstName 和 lastName 改變時傳回正确的值。

it('計算屬性 fullName 正确計算', () => {
  const wrapper = shallowMount(MyComponent, {
    propsData: {
      firstName: 'John',
      lastName: 'Doe',
    },
  });
 
  expect(wrapper.vm.fullName).toBe('John Doe');
  wrapper.setData({ firstName: 'Jane' });
  expect(wrapper.vm.fullName).toBe('Jane Doe');
});           

2)可測試的内容:值的邊界情況,以及特殊字元的表現

測試元件渲染輸出

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

1) v-if/v-show 是否符合預期,常用到的斷言對象分别是dom.exists() 及 dom.isVisible()

2) 類名和DOM屬性測試,常用到的斷言對象分别是dom.classes() 及 dom.attributes()

測試元件方法

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

1)模拟使用者行為:通過findComponent或者findAllComponents來擷取DOM或者自元件,并通過trigger觸發注冊的事件,進而斷言結果是否符合預期

2)對于一些mixins引入的外部函數,如想判斷是否被調用,可以通過mock的方式以及toBeCalled的比對器來判斷

it('emit功能驗證', () => {
    wrapper.findComponent('.point-card-close').trigger('click');
    expect(outsideMock).toBeCalled();
    expect(outsideMock).toHaveBeenCalledTimes(1);
})           

3)emit的事件可以通過emitted方法來擷取

expect(wrapper.emitted().foo).toBeTruthy()
expect(wrapper.emitted().foo[0]).toEqual([123])           

測試 vue-router

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

1)通過在shallowMount渲染元件的時候傳入mock資料,來模拟$route、$router對象

const wrapper = shallowMount(Payment, {
  mocks: {
    $route: {
        query: {}
    },
    $router: {
        replace: jest.fn()
    }
  }
})           

測試mixin

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

在元件中或全局注冊mixin、挂載元件、最後檢查mixin是否産生了預期的行為

import MyComponent from '@/components/MyComponent';
import MyMixin from '@/mixins/MyMixin';
import { shallowMount } from '@vue/test-utils';
 
it('測試 mixins 修改狀态和資料', () => {
  const wrapper = shallowMount(MyComponent, {
    mixins: [MyMixin],
  });
 
  // 確定 mixin 修改了元件的資料
  expect(wrapper.vm.mixinData).toBe('Mixin Data');
 
  // 確定 mixin 修改了元件的狀态
  expect(wrapper.vm.$store.state.mixinState).toBe(true);
  })
})           

測試VUEX

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

主要有兩種方式:

    • 單獨測試store中的每一個部分:我們可以把store中的mutations、actions和getters單獨劃分,分别進行測試。(小而聚焦,但是需要模拟Vuex的某些功能)
    • 組合測試store:我們不拆分store,而是把它當做一個整體,我們測試store執行個體,進而希望它能按期望輸出(避免互相影響執行個體,使用vue test utils提供的localVue)

快照測試

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

簡單的解釋就是擷取代碼的快照,并将其與以前儲存的快照進行比較,如果新的快照與前一個快照不比對,測試會失敗。

當一個快照測試用例失敗時,它提示我們元件相較于上一次做了修改。如果是計劃外的,測試會捕獲異常并将它輸出提示我們。如果是計劃内的,那麼我們就需要更新快照。

食用方法 :expect(wrapper.element).toMatchSnapshot()

06

測試報告與覆寫率

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

覆寫率可以簡單了解為已被測試代碼,它可以從一定程度上衡量我們對代碼測試的充分性。原則上我們追求的單元測試覆寫率目标是100%,但業務場景多的情況幾乎是不可能。

是以我們可以隻針對核心底層的子產品書寫單元測試,核心複雜功能盡量覆寫率做到最高,業務類的酌情處理。

四個概念:

語句覆寫率:是不是每個語句都執行了

分支覆寫率:是不是每個if代碼塊都執行了

函數覆寫率:是不是每個函數都調用了

行覆寫率:是不是每一行都執行了

基于VUE的前端單元測試應用(Jest、Langchain)

也可以打開對應的報告查閱未覆寫到的子產品内容,并進行對應的修改

基于VUE的前端單元測試應用(Jest、Langchain)
    • 「7x」表示在測試中這條語句執行了 7 次
    • 「I」是測試用例 if 條件未進入,即沒有 if 為真的測試用例
    • 「E」是測試用例沒有測試 if 條件為 false 時的情況
    • 即測試用例中 if 條件一直都是 true,得寫一個 if 條件為 false 的測試用例,即不走進 if 條件裡面的代碼,這個 E 才會消失

關于覆寫率的門檻值,已經比對的檔案,具體可以參考jest.config.js檔案,這裡設定為80

coverageThreshold: {
  global: {
    branches: 80,
    functions: 80,
    lines: 80,
    statements: 80
  }
},           

07

結合LangChain生成基礎單測代碼

基于VUE的前端單元測試應用(Jest、Langchain)
基于VUE的前端單元測試應用(Jest、Langchain)

不同于功能或算法庫,編寫Vue的單元測試用例時,常常會發現存在許多通用且重複的部分。是以,可以考慮借助AI的能力來輔助生成基本的Jest單元測試代碼。

盡管生成的單元測試代碼可以作為起點,幫助編寫基本的測試用例,但由于代碼中通常包含一些業務特定的邏輯,可能需要進行二次處理。是以,生成的測試代碼僅供參考,需要根據具體情況進行調整和補充。

源碼參考:https://code.37ops.com/zhouguilin/openai-code-generator/-/blob/ai-unit-test/src/unit-creator.js

基于VUE的前端單元測試應用(Jest、Langchain)

這是生成的某個測試檔案效果執行個體:

通過AI的協助,我們已經能夠生成基本的測試用例代碼,包括render、methods、computed、watch以及slot。這顯著降低了我們編寫重複代碼的時間成本,然後把重點放在特殊業務邏輯的測試用例編寫上。

import { shallowMount } from '@vue/test-utils';
import CreditCard from '../CreditCard.vue';
 
jest.mock('@utiles/officialStore', () => ({
  RES_HOST: 'mocked-res-host'
}));
 
jest.mock('@store/officialWebStore', () => ({
  GET_CARD_SCHEMES_ACTION: 'mocked-get-card-schemes-action',
  SET_CREDIT_CARD: 'mocked-set-credit-card'
}));
 
jest.mock('vuex', () => ({
  mapState: jest.fn()
}));
 
describe('CreditCard', () => {
  let wrapper;
 
  beforeEach(() => {
    wrapper = shallowMount(CreditCard, {
      propsData: {
        curPayType: {
          currency: 'USD'
        },
        curLocationVal: 'mocked-location',
        discountTransMount: 100,
        curCoins: {
          TRANS_AMOUNT: 50
        },
        isCoins: true
      },
      mocks: {
        $store: {
          state: {
            gameId: 'mocked-game-id'
          },
          commit: jest.fn(),
          dispatch: jest.fn()
        },
        window: {
          webstorev2DataLayer: {
            push: jest.fn()
          }
        }
      },
      slots: {
        default: '<div class="default-slot">Default Slot Content</div>',
        namedSlot: '<div class="named-slot">Named Slot Content</div>'
      },
      scopedSlots: {
        contextualSlot: '<div class="contextual-slot" v-bind="props">Contextual Slot Content</div>'
      }
    });
  });
 
  it('renders the component', () => {
    expect(wrapper.exists()).toBe(true);
  });
 
  it('renders the credit card container when curPayType is provided', () => {
    expect(wrapper.find('.credit-card-container').exists()).toBe(true);
  });
 
  it('does not render the credit card container when curPayType is not provided', () => {
    const wrapperWithoutCurPayType = shallowMount(CreditCard);
    expect(wrapperWithoutCurPayType.find('.credit-card-container').exists()).toBe(false);
  });
 
  it('renders the card item when creditCardList is provided', () => {
    expect(wrapper.findAll('.card-item').length).toBe(2);
  });
 
  it('does not render the card item when creditCardList is not provided', () => {
    const wrapperWithoutCreditCardList = shallowMount(CreditCard);
    expect(wrapperWithoutCreditCardList.findAll('.card-item').length).toBe(0);
  });
 
  it('selects the credit card when clicked', () => {
    const cardItem = wrapper.find('.card-item');
    cardItem.trigger('click');
    expect(wrapper.vm.curCreditCard).toBe('card1');
  });
 
  it('calls the getCardSchemes method when created', () => {
    expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('mocked-get-card-schemes-action', {
      currency: 'USD',
      game_id: 'mocked-game-id',
      location: 'mocked-location',
      amount: 50
    });
  });
 
  it('calls the getCardSchemes method when discountTransMount is updated', () => {
    wrapper.setProps({ discountTransMount: 200 });
    expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('mocked-get-card-schemes-action', {
      currency: 'USD',
      game_id: 'mocked-game-id',
      location: 'mocked-location',
      amount: 200
    });
  });
 
  it('calls the getCardSchemes method when curCoins is updated and isCoins is true', () => {
    wrapper.setProps({ curCoins: { TRANS_AMOUNT: 100 } });
    expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('mocked-get-card-schemes-action', {
      currency: 'USD',
      game_id: 'mocked-game-id',
      location: 'mocked-location',
      amount: 100
    });
  });
 
  it('computes the creditCardList correctly', () => {
    wrapper.setData({
      creditCardList: ['card1', 'card2']
    });
    expect(wrapper.vm.creditCardList).toEqual(['card1', 'card2']);
  });
 
  it('computes the curCreditCard correctly', () => {
    wrapper.setData({
      curCreditCard: 'card1'
    });
    expect(wrapper.vm.curCreditCard).toBe('card1');
  });
 
  it('computes the letter correctly', () => {
    wrapper.setData({
      letter: {
        pleaseCard: 'Please select a card'
      }
    });
    expect(wrapper.vm.letter).toEqual({
      pleaseCard: 'Please select a card'
    });
  });
 
  it('watches the discountTransMount property and calls the getCardSchemes method when it changes', () => {
    wrapper.setProps({ discountTransMount: 200 });
    expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('GET_CARD_SCHEMES_ACTION', {
      currency: 'USD',
      game_id: 'mocked-game-id',
      location: 'mocked-location',
      amount: 200
    });
  });
 
  it('watches the curCoins property and calls the getCardSchemes method when it changes and isCoins is true', () => {
    wrapper.setProps({ curCoins: { TRANS_AMOUNT: 100 } });
    expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('GET_CARD_SCHEMES_ACTION', {
      currency: 'USD',
      game_id: 'mocked-game-id',
      location: 'mocked-location',
      amount: 100
    });
  });
 
  it('renders the default slot content', () => {
    expect(wrapper.find('.default-slot').exists()).toBe(true);
    expect(wrapper.find('.default-slot').text()).toBe('Default Slot Content');
  });
 
  it('renders the named slot content', () => {
    expect(wrapper.find('.named-slot').exists()).toBe(true);
    expect(wrapper.find('.named-slot').text()).toBe('Named Slot Content');
  });
 
  it('renders the contextual slot content with the correct props', () => {
    expect(wrapper.find('.contextual-slot').exists()).toBe(true);
    expect(wrapper.find('.contextual-slot').text()).toBe('Contextual Slot Content');
    expect(wrapper.find('.contextual-slot').attributes('cur-pay-type')).toBe('{"currency":"USD"}');
    expect(wrapper.find('.contextual-slot').attributes('cur-location-val')).toBe('mocked-location');
    expect(wrapper.find('.contextual-slot').attributes('discount-trans-mount')).toBe('100');
    expect(wrapper.find('.contextual-slot').attributes('cur-coins')).toBe('{"TRANS_AMOUNT":50}');
    expect(wrapper.find('.contextual-slot').attributes('is-coins')).toBe('true');
  });
});           

作者:加鴻

來源-微信公衆号:三七互娛技術團隊

出處:https://mp.weixin.qq.com/s/yFM_LzvmYV9Xp-GaCLm1Ig

繼續閱讀