前端高質量交付產品利器之自動化測試

Aaron發表於2023-05-11

前言

對客戶交付高質量的產品是企業的核心目標之一,而單元測試是實現這一目標的重要手段之一。透過單元測試,可以確保產品的每個部分都經過了嚴格的測試,降低產品出現缺陷的機率,提高產品的可靠性和穩定性。同時,單元測試的結果可以為客戶提供更加準確的產品質量報告,幫助客戶更好地瞭解產品的優點和缺陷。此外,單元測試還可以提高開發人員的信心和積極性,促進團隊的合作和創新,為客戶提供更加優質的產品和服務。

單元測試是客戶交付高質量產品的重要保證之一,企業應該高度重視單元測試工作,不斷完善和最佳化測試流程和方法。

然而作為一個前端開發者來說,我們所承擔的不單單只是保證開發任務的完成,在交付所完成的專案之前更要保證的是質量問題,如何保證交付的質量是一個很值得探討的問題,大多數開發者在開發過程中會針對當前所開發的內容進行自測,但是避免不了會有一些疏漏或者測試不到位的地方,導致一些很常見的bug的的出現。也可能對於一個方法或者元件的調整導致引用其方法或元件的其他元件受到影響而沒有進行測試導致交付的bug

所以單元測試還是非常有必要的,但是更多的時候缺忽略了單元測試的重要性,一個完整的專案單元測試的存在還是非常重要的,這篇部落格將會帶你重新認識單元測試,另一方便將教會你從零開始搭建環境和如何使用單元測試。

為什麼要寫單元測試

  1. 必要性:JavaScript缺少型別檢查,編譯期間無法定位到錯誤,單元測試可以幫助你測試多種異常情況。
  2. 正確性:測試可以驗證程式碼的正確性,在上線前做到心裡有底。
  3. 自動化:透過console雖然可以列印出內部資訊,但是這是一次性的事情,下次測試還需要從頭來過,效率不能得到保證。透過編寫測試用例,可以做到一次編寫,多次執行。
  4. 保證重構:網際網路行業產品迭代速度很快,迭代後必然存在程式碼重構的過程,那怎麼才能保證重構後程式碼的質量呢?有測試用例做後盾,就可以大膽的進行重構。

單元測試和端對端測試

對於大多數開發者來說對於Unit(俗稱:單元測試)可能聽說的比較多,對於E2E(端到端測試)不是那麼特別的瞭解。那麼兩者之間有什麼區別呢?

單元測試

單元測試(Unit)是站在程式設計師的角度測試,unit測試是把程式碼看成一個一個的元件,從而實現每一個元件的單獨測試,測試內容主要是元件內的每一個函式的執行結果或返回值是否和測試中斷言的結果一致。

端對端測試

端對端測試(E2E)是把我們的程式堪稱是一個黑盒子,我不懂你內部是怎麼實現的,我只負責開啟瀏覽器,把測試內容在頁面上輸入一遍,看是不是我想要得到的結果。

unit測試是程式設計師寫好自己的邏輯後可以很容易的測試自己的邏輯返回的是不是都正確。e2e程式碼是測試所有的需求是不是都可以正確的完成,而且最終要的是在程式碼重構,js改動很多之後,需要對需求進行測試的時候測試程式碼是不需要改變的,你也不用擔心在重構後不能達到客戶的需求。

單元測試原則

在編寫單元測試時,我們需要mock掉那些依賴於外部系統或庫的元件,例如資料庫、網路請求等。這樣可以確保測試單元獨立於外部環境和其他單元的狀態,只測試當前單元的功能。而對於那些依賴於內部方法或類的元件,則可以直接進行測試,因為它們是當前單元的一部分。

對於ComposeApi模組,我們應該為其編寫獨立的單元測試,並使用真實的實現進行測試,因為它是被其他使用者所呼叫的。這樣可以確保ComposeApi模組的程式碼質量和可維護性,並且在使用ComposeApi模組時,可以保證其功能正常。而對於使用ComposeApi模組的其他模組,在編寫單元測試時,應該將其依賴Mock掉,以確保測試只關注當前模組的邏輯,不受其他依賴的影響。這樣可以確保測試單元獨立於外部環境和其他單元的狀態,只測試當前模組的功能。

準備工作

在進行單元測試之前,需要選擇適合自己的測試工具。本文采用Jest作為測試工具,因為Jest支援斷言和覆蓋率測試,具有寫法簡單、功能強大等優點。使用Jest可以幫助我們更好地進行單元測試,提高程式碼質量和可靠性。

開始搭建環境我們應該先對以下知識點有所瞭解:

  1. vite
  2. typescript
  3. vue3

搭建單元測試環境

這裡預設你已經有一了一個可以新增測試環境的專案(如果沒有請自行建立)。

首先安裝對應的依賴:

yarn add jest --dev                  
yarn add @types/jest --dev          
yarn add babel-jest --dev          
yarn add @babel/preset-env --dev
yarn add @vue/vue3-jest --dev 
yarn add ts-jest --dev
yarn add @vue/cli-plugin-unit-jest --dev
yarn add @vue/test-utils@next --dev
yarn add @babel/preset-typescript --dev
yarn add babel-plugin-transform-vite-meta-env --dev
yarn add jest-environment-jsdom --dev
yarn add babel-plugin-transform-import-meta --dev

注:這裡使用的是yarn安裝的依賴,不要過於糾結包管理工具,根據自己的喜好自行選擇即可。

依賴安裝完成之後,在src目錄下建立tests資料夾,這個資料夾用來存放關於測試相關的檔案。

在根目錄建立jest.config.js對Jest進行初始化的基本配置:

module.exports = {
  cache: false,
  collectCoverage: true,
  //  babel預設
  transform: {
    "^.+\\.vue$": "@vue/vue3-jest",       //  支援匯入Vue檔案
    "^.+\\.jsx?$": "babel-jest",    //  支援import語法
    '^.+\\.tsx?$': 'ts-jest',       //  支援ts
    '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub' //  支援匯入css檔案
  },
  moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'vue'],
  //  路徑別名
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  testEnvironment: 'jsdom',
  testEnvironmentOptions: {
    customExportConditions: ['node', 'node-addons'],
  },
  testMatch: [
    '**/tests/**/*.test.[jt]s?(x)',
  ],
  preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel'
};

由於這裡使用的是TypeScript也需要對tsconfig.json進行調整,讓測試檔案也可以支援TypeScript:

//  省略了其他配置項,其他配置根據專案要求自行配置即可
{
  "compilerOptions": {
    //  ...
    "types": ["vite/client", "jest"]    //  指定型別為jest
  },
  "include": [
    // ...
    "tests/**/*.ts"     // 指定單元測試路徑
  ]
}

因為Node無法執行TypeScript這裡需要使用babelTypeScript進行編譯,要配置babel的相關配置,在根目錄建立babel.config.js

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current"
        }
      }
    ]
  ],
  plugins: [
    '@babel/plugin-transform-arrow-functions',
    'babel-plugin-transform-vite-meta-env',
    ['babel-plugin-transform-import-meta', { module: 'ES6' }]
  ],
};

對環境配置完成之後為了方便呼叫測試命令,可以在package.jsonscripts新增快捷指令:

{
  "scripts": {
    //  ...
    "test": "jest"
  },
}

常用斷言

  1. toBe(): 測試具體的值
  2. toEqual(): 測試物件型別的值
  3. toBeCalled(): 測試函式被呼叫
  4. toHaveBeenCalledTimes(): 測試函式被呼叫的次數
  5. toHaveBeenCalledWith(): 測試函式被呼叫時的引數
  6. toBeNull(): 結果是null
  7. toBeUndefined(): 結果是undefined
  8. toBeDefined(): 結果是defined
  9. toBeTruthy(): 結果是true
  10. toBeFalsy(): 結果是false
  11. toContain(): 陣列匹配,檢查是否包含
  12. toMatch(): 匹配字元型規則,支援正則
  13. toBeCloseTo(): 浮點數
  14. toThrow(): 支援字串,浮點數,變數
  15. toMatchSnapshot(): jest特有的快照測試
  16. not.toBe(): 前面加上.not就是否定形式

建立第一個單元測試

對單元測試環境配置完成之後,接下來可以新增測試檔案了:

// test/index.test.ts

test("1+1=2", () => {
  expect(1+1).toBe(2);
});

新增完成之後執行命令:

yarn test

看到控制檯輸出:

 PASS  tests/index.test.ts
 
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 |                   
----------|---------|----------|---------|---------|-------------------

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.798 s
Ran all test suites.
✨  Done in 5.32s.

模組測試

這裡的模組而言主要針對的是頁面,也就是常說的router部分,這部分主要包括元件的自定義事件以及整體UI功能正確性驗證。

頁面結構測試

透過mountshallowMountfindfindAll方法都可以返回一個包裹器物件,包裹器會暴露很多封裝、遍歷和查詢其內部的Vue元件例項的便捷的方法。

findfindAll方法都可以都接受一個選擇器作為引數,find方法返回匹配選擇器的DOM節點或Vue元件的WrapperfindAll方法返回所有匹配選擇器的DOM節點或Vue元件的WrappersWrapperArray

情景設想:

Home下有一個p標籤,class.outer,裡面的內容為Aaron

import Home from "@/pages/Home.vue";
import { mount } from "@vue/test-utils";

describe('Test for Home Page', () => {

    let wrapper;

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

    it('get tag dom', () => {
        // 使用Vue元件選擇器
        expect(wrapper.is(Test1)).toBe(true);
        // 使用CSS選擇器
        expect(wrapper.is('.outer')).toBe(true);
        // 使用CSS選擇器
        expect(wrapper.contains('p')).toBe(true)
        // 內容是否正確
        const contentText = wrapper.find('p').text();
        expect(contentText).toBe("Aaron")
    });
    
    it('has tag', () = > {
        //  isEmpty():斷言 Wrapper 並不包含子節點。
        expect(wrapper.find("button").isEmpty()).toBeFalsy();
        //  exists():斷言 Wrapper 或 WrapperArray 是否存在。
        expect(wrapper.findAll('img').exists()).toBeFalsy()
    });
    
    it('has className', () = > {
        // attributes():返回 Wrapper DOM 節點的特性物件
        expect(wrapper.find('p').attributes().class).toContain('outer');
        // classes():返回 Wrapper DOM 節點的 class 組成的陣列
        expect(wrapper.find('p').classes()).toContain('outer');
    });
    
    it('has style', () = > {
        // hasStyle:判斷是否有對應的內聯樣式
        expect(wrapper.find("p").hasStyle('padding-top', '10')).toBeTruthy()
    });
});
路由測試

路由是基於瀏覽器環境而言的,在單元測試中是沒有辦法正常使用路由的,需要透過jest.mock方法模擬出vue-router環境。

jest.mock("vue-router", () => {
  const realModule = jest.requireActual("vue-router");
  const mockRouter = {
    ...realModule,
    currentRoute: {},
    useRouter: () => ({
      push(route: any) {
        mockRouter.currentRoute.value = route;
      },
      currentRoute: mockRouter.currentRoute
    })
  };
  return mockRouter;
});

在上述程式碼中,透過函式所返回的物件,模擬了一個vue-router的環境,並當環境觸發push操作的時候更改了currentRoute的值。

情景設想:

假設現在有兩個路由配置,分別是HomeAbout,在Home頁面中有一個按鈕,點選之後跳轉到About頁面。

現在基於現有情景去寫單元測試,具體程式碼如下:

import Home from "@/pages/Home.vue";
import { useRouter } from 'vue-router';

test("Home.vue GoAbout", async () => {
  const wrapper = mount(Home as any);
  const oBtn1 = wrapper.find("#btn1");
  await oBtn1.trigger("click");
  const router = useRouter();
  const routerName = router.currentRoute?.value?.name;
  expect(routerName).toBe("About");
});

這裡需要注意,在程式碼中呼叫push時所傳遞的引數會影響到最終的斷言結果。如上程式碼,在正式環境程式碼中呼叫push時所傳入的是push({ name: "About" }),所以在最終斷言時使用About為最終需要斷言的結果。

狀態管理測試

狀態管理工具有很多,本文中所使用的狀態管理工具是pinia,需要對其進行安裝。

# npm
npm install pinia -S

# yarn
yarn add pinia -S

pinia已經內建了單元測試的mock環境,我們只需要簡單的配置一下即可。

import { setActivePinia, createPinia } from 'pinia';

beforeEach(() => {
  setActivePinia(createPinia());
});

情景設想:

Home下有一個button點選之後需要更改pinia中的一個值(fooValue),更改後的值為張三

現在基於現有情景去寫單元測試,具體程式碼如下:

import Home from "@/pages/Home.vue";
import { useHomeStore } from "@/store/module/Home";

test("Home.vue Change Pinia", () => {
  const homeStore = useHomeStore();
  const vm = mount(Home as any);
  const oBtn = vm.find("#btn");
  oBtn?.trigger("click");
  expect(homeStore.fooValue).toBe("Aaron");
});

注意這裡需要吧對應的store檔案引入進來,才可以讀取到更改後的值。

資料請求測試

在開發中更多的需求是當點選某一個按鈕時去出發一個請求或者去該變一個值。這種情況通常也是透過模擬的方式解決。

情景設想:

Home下有一個button點選之後需要進行一個請求ajax或者一個非同步操作,去驗證最後所得到的值是不是所需要的。

現在基於現有情景去寫單元測試,具體程式碼如下:

import Home from "@/pages/Home.vue";

it('Home onGetAjax', (done) => {
  const wrapper = mount(Home as any, {
    setup() { 
      const data = ref({});
      const onGetAjax = async () => {
        data.value = mockData.data;
      }
      return { onGetAjax, data }
    }
  });
  wrapper.find('#btn3').trigger('click');
  wrapper.vm.$nextTick(async () => {
  await wrapper.vm.onGetAjax();
    expect(wrapper.vm.data).toEqual(mockData.data)
    done();
  });
});

上述onGetAjax中要模擬所有的資料處理操作,當然這裡也可以使用axios進行真實的資料請求。除了這種方式還有另外一種方式處理。

import Home from "@/pages/Home.vue";
//  quantityBaseList 最終的mock資料
import { quantityBaseList } from "./quantityBase.mock";

const ajaxMock = (vai) => vai();

const getData = () => {
  return new Promise((res) => {
    res(quantityBaseList)
  })
};

jest.mock("axios", () => {
  const mAxiosInstance = {
    get: jest.fn()
  };
  return {
    create: jest.fn(() => mAxiosInstance),
  };
});

// 這裡必須要寫
// 需要在這裡mock一下請求資料的函式
jest.mock('@/api/Home', () => { 
  return {
    getCreateCustomerData: jest.fn(getData)
  }
});

it("Home getTabeList", () => {
  return wrapper.vm.$nextTick(async (res) => {
    const result = await ajaxMock(getData);
    expect(result.data.items).toEqual(quantityBaseList.data.items);
    expect(result.data.totalCount).toEqual(quantityBaseList.data.totalCount);
  });
});

上述程式碼直接mock了請求資料的方法,當資料請求完成之後所需要的後續操作進行斷言也是可以的。

元件測試

單元測試環境配置成功,接下來也就是重中之重的環節對元件進行測試。單元測試由於是跑在node環境中的,所以很多情況不能直接去使用真實開發環境中的內容測試,所以很多情況需要在做單元測試是手動模擬。

元件引數測試

上面說了很多關於頁面相關的,除了頁面之外也會對元件進行相關的測試。關於元件的話最常見的可能就是props引數。

情景設想:

一個元件需要接收一個propmodel<object>,只需要測試一下這個model是否可以正常接收。

let model = ref({
  name: "張三"
});

it("provide/inject", () => {
 let parent = mount(Detail as any, {
    props: { model },
  });
  expect(child.vm.parentProps?.model).toEqual(model);
});
自定義事件測試

自定義事件一般指的是,當元件內部執行完某一事件之後,需要通知上一層做出一些響應操作,系統開發中會經常用到。

情景設想:

Home下有一個Foo的元件,Foo中有一個按鈕當這個按鈕點選的時候Home需要執行一個函式。

import Home from "@/pages/Home.vue";
import Foo from "@/components/Foo/index.vue";

describe('Test for Foo Component', () => {
 wrapper = mount(Home as any);
 
 it('addCounter Fn should be called', () = > {
    const mockFn = jest.fn();
    wrapper.setMethods({
        'addCounter': mockFn
    });
    wrapper.find(Foo).vm.$emit('add', 100);
    expect(mockFn).toHaveBeenCalledTimes(1);
 });
 
 wrapper.destroy()
});

這裡使用了$on方法,將Home自定義的add事件替換為Mock函式,對於自定義事件,不能使用trigger方法觸發,因為trigger只是用DOM事件。自定義事件使用$emit觸發,前提是透過find找到Foo元件。

計算屬性測試

計算屬性是一個資料, 依賴另外一些資料計算而來的結果,當一個變數的值,需要用另外變數計算而得來。對於計算屬性的應用場景也是蠻多的。

情景設想:

FooText元件中,有一個input標籤,透過計算屬性把input輸入的值進行反轉,並輸出到了元件中一個p標籤中。

import FooText from "@/components/FooText/index.vue";

describe('Test for Foo Component', () => {

    beforeEach(() => {
        wrapper = shallow(FooText);
    });
    
    afterEach(() => {
        wrapper.destroy()
    });

    it('test computed', () => {
        //  可以透過 setProps 設定props屬性
        wrapper.setProps({needReverse: false});
        wrapper.vm.inputValue = 'ok';
        expect(wrapper.vm.outputValue).toBe('ok');
    });
    
});
資料監聽測試

當一個值發生變化時需要執行一些其他操作,一般用於元件封裝時,當外部資料發生變化之後,元件內部需要對元件狀態進行調整。

情景設想:

FooText元件中,透過watch監聽inputvalue的值變化後需要執行一個函式。

import FooText from "@/components/FooText/index.vue";

describe('Test watch', () = > {
    let spy;
  
    beforeEach(() = > {
        wrapper = shallow(FooText);
        spy = jest.spyOn(console, 'log');
    });
    
    afterEach(() = > {
        wrapper.destroy();
        spy.mockClear()
    });
    
    it('is called with the new value in other cases', () = > {
        // 對inputValue賦值時spy會被執行一次,所以需要清除spy的狀態
        // 清除已發生的狀態
        spy.mockClear();
        wrapper.vm.inputValue = 'ok';
        return wrapper.vm.$nextTick(() = > {
            expect(spy).not.toBeCalled();
        });
    });
};
依賴注入測試

專案中會用到provide/inject這種依賴注入的情況,這種情況一般會涉及到父子元件巢狀的情況。

情景設想:

兩個元件分別是DetailDetailItemDetail元件中透過provide向下傳入了model,需要測試DetailItem透過inject接收到的內容是否是父元件傳入的。

import Detail from "@/components/Detail/index.vue";
import DetailItem from "@/components/DetailItem/index.vue";

it("provide/inject", () => {
    let parent = mount(Detail, {
      props: { model },
      slots: {
        default: DetailItem
      }
    });
    let child = parent.findComponent(DetailItem);
    expect(child.vm.parentProps?.model).toEqual(model);
});
插槽測試

對於slot的測試也是必不可少的,特別有的時候時候slot所暴露出來的引數是否正確,也是讓人關係的。

情景設想:

DetailItem元件可以使用slot對內容進行渲染,slot暴露出來了value引數以供渲染使用,測試value是否正確。

it("render slots", () => { 
    const wrapper = mount(DetailItem, {
      global: {
        provide: {
          "detail": reactive({
            model,
            align: "left",
            labelWidth: "100px"
          })
        },
      },
      slots: {
        default: (item) => `<b>${item.value}</b>`
      },
      props: {
        label,
        prop,
        align: "left",
        labelWidth: "100px"
      }
    });
    expect(`<b>${model.value[prop]}</b>`).toBe(wrapper.find(".w-full").text())
})

測試報告說明與使用

sonarQube是一款程式碼質量管理工具,可以用於檢查程式碼質量、安全性和可維護性等方面。它支援多種程式語言和技術棧,並提供了豐富的指標和報告,幫助開發團隊更好地管理和最佳化程式碼質量。

對於前端專案,sonarQube可以使用前端的測試報告來評估程式碼質量。首先,需要在前端專案中配置好測試框架,並生成測試報告。將測試報告匯入到sonarQube中,以便sonarQube可以讀取並分析它們。sonarQube支援多種測試報告格式,例如JUnitSurefireCobertura等。可以根據前端測試框架生成的測試報告格式,選擇相應的sonarQube測試報告外掛進行匯入。

sonarQube中配置好前端專案的檢查項和指標,例如程式碼覆蓋率、程式碼複雜度、程式碼重複率等。sonarQube會根據測試報告和檢查項,生成相應的程式碼質量報告和指標分析。開發團隊可以根據這些報告和指標,最佳化和管理前端程式碼質量。

當執行完單元測試之後會在專案根目錄下生成一個名為coverage的資料夾,資料夾中clover.xmllcov.info存放的即是覆蓋率相關的內容。

sonar-project.properties的執行命令中新增:

// xml的位置
sonar.testExecutionReportPaths=test/covrage/test-report.xml   
// lcov.info的位置
sonar.javascript.lcov.reportPaths=test/covrage/lcov.info 

jest執行完會生成一個覆蓋率統計表,所有在覆蓋率統計資料夾下的檔案都會被檢測,覆蓋率指標:

  • File:檔案路徑
  • Statements: 語句覆蓋率,執行到每個語句
  • Branches: 分支覆蓋率,執行到每個if程式碼塊
  • Functions: 函式覆蓋率,呼叫到程式中的每一個函式
  • Lines: 行覆蓋率, 執行到程式中的每一行
------------------|---------|----------|---------|---------|-------------------
File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s                                          
------------------|---------|----------|---------|---------|-------------------
All files         |   63.95 |    56.42 |   70.31 |   67.68 |                                                            
 src/components   |   68.84 |    65.11 |   67.85 |    73.8 |                                                            
  Foo.vue         |   67.91 |    65.11 |   66.66 |   73.17 | 
------------------|---------|----------|---------|---------|-------------------

總結

單元測試是提高軟體開發質量的重要手段,它可以幫助企業提高產品質量和客戶滿意度,從而為企業的長期發展奠定堅實的基礎。透過單元測試,企業可以及時發現並解決程式碼中的問題,提高程式碼的可維護性和可擴充套件性,降低程式碼維護成本和風險,為未來的開發工作提供更好的基礎。同時,單元測試還可以提高開發人員的信心和積極性,促進團隊的合作和創新,為企業創造更多的價值和競爭優勢。

在進行單元測試時,需要注意的是單元測試並不是一項簡單的工作,需要測試者具備一定的技術和經驗。測試者需要充分考慮測試用例的覆蓋率和測試結果的準確性,確保測試的有效性和可靠性。此外,測試者還需要了解專案需求和功能,根據實際情況進行測試設計和測試用例編寫,從而保證測試的全面性和完整性。

總之,單元測試是軟體開發過程中不可或缺的一環,它可以幫助企業提高軟體開發質量和客戶滿意度,為企業的長期發展奠定堅實的基礎。因此,企業應該高度重視單元測試工作,積極推廣單元測試理念,不斷提高測試水平和技術能力,為企業的未來發展注入新的動力和活力。

相關文章