單測在商家前端業務中的實踐

得物技術發表於2022-12-27

1. 背景

商家系統是提供給得物商家在得物平臺上可以穩定運營的服務抓手,前端程式碼也伴隨著系統的發展而不斷壯大。這樣將導致文件卻更新不及時,最後想再透過這些文件回溯業務邏輯也非常困難。

且若程式碼結構上沒有關注,動輒就會產出一個大幾千行的檔案?,人員交替維護的時候很難理清裡面的邏輯,維護非常困難。

2. 前端單測的難點

為解決上述痛點,早在單測之前,團隊上已經做了一些其他事情來使文件更清晰、程式碼質量更高,如寫需求系分文件、透過整潔架構(The clean architecture)對程式碼進行分層、code review等等。但這些其實都只是外在的約束,只有內在的程式碼能真正經得住單測的推敲,才能更好的保障我們的程式碼質量。

但目前現狀是前端大部分情況下都沒有接觸到單測,僅在元件庫或工具類的專案裡有一些。這並不代表業務專案中前端就無法單測, 而是因為一些客觀原因,導致前端在單測上的投入相對較少。

  1. 前端開發的內容比較雜,一個需求不僅僅是功能函式的編寫,還有UI的展示、dom互動的繫結等等,且若想單測完全覆蓋,將包含非常多的內容,對業務前端來說成本太高。
  2. 前端UI框架層出不窮,在業務開發的時候,依賴框架也很容易將程式碼邏輯和UI等完全耦合在一起,導致一個檔案上千行,很難對這種程式碼找到單測的切入點。
  3. 單測上手本身就有一定的門檻,要寫出可維護性高的單測更不簡單,會讓不熟悉的人望而卻步。

3. 單測即文件

鑑於上面的第一個難點,前端涉及的內容太雜,我們肯定無法給所有的程式碼覆蓋單測,去測到程式碼的各個角落。再結合上我們自己本身的痛點(文件更新不及時,人員輪轉成本高),因此以“單測即文件”為目標,我們只用覆蓋業務邏輯上的單測即可,只關注業務流程的銜接,透過用例將業務流程講清楚,對於單測的分支覆蓋率也不做強硬的要求。

Use Cases

因此,要在團隊落地單測的第一步即是識別出實現業務邏輯的程式碼模組。若在較早的時候,想找到這個切入點可能還真沒有什麼好的方法,因為全是幾千行的大檔案,且邏輯和UI都耦合在一起。

正如前面所說,在單測推行前,我們已經做了一些程式碼準備工作。得益於“整潔架構”的推行,在開發需求的同時,已逐漸在對程式碼進行解耦重構,其核心就是依據各部分程式碼作用的不同將其拆分成不同的層次,在各層次間制定了明確的依賴原則達到與框架無關、與外部服務無關、並可測試的目的。

經過分層後,我們將業務邏輯主要都落在了usecase這一層,在我們的程式碼結構上,它的作用是將業務流程串聯起來,且它僅依賴entities(主要對服務端返回資料做適配和檢查)層,邏輯獨立不會因為依賴框架或UI的變化而無法執行。

相較於後端服務,前端應用通常並不會承載如計算、儲存等實實在在的業務邏輯,同時由於現在微服務架構的流行,前端應用往往會承擔很重的膠水邏輯,即將各個微服務的邏輯串聯在一起,從而跑通業務流程。

因此,前端在編寫usecase的時候,我們會更注重主子函式的拆分,讓主usecase更純粹的去描述業務流程,而將部分具體的實現拆分到子函式中去實現。

/*
    usecase聚焦流程的描述,諸如url連結拼接、活動期查詢等具體邏輯都拆分到了其他的模組中
*/
async function exportActivityLog({count, formValues}: {count: number;formValues: LogData}) {
  if (count > 5000) {
    message.error('匯出檔案數量不得超過5000!')
    return
  }
  const res = await checkIsDuringTheEventApi()
  if (res.isDuring) {
    message.error('活動期間,功能暫不可用,如有疑問聯絡運營');
    return
  }
  const url = generateDownloadUrl({ formValues })
  downloadExcelFile(url)
}

function generateDownloadUrl() {
  // 省略
}

因此,對usecase層寫單測,正是我們要找的最好切入點,其既能滿足我們將業務文件進行補充,同時又能有單測模組的產出,保障我們的程式碼質量和程式的穩定性。

4.單測實踐

在識別出要覆蓋單測的程式碼模組之後,下一步自然就是落地單測用例。

前面已說過,寫單測本身就有一定的門檻,但既然要寫就應寫可維護性和穩定性高的單測。否則程式碼稍微一重構,單測崩了?;或程式碼真崩了的時候,單測卻沒又透過了?。

根據前面的描述可以看出,我們對於用例的可讀性(文件性)和穩定性有極高的訴求,對於用例所測試的邏輯範圍要求不高,這個準則對於後續的單測用例的設計取捨會有很大的影響。

4.1 用例設計

首先我們需要確定設計用例的切入點,目前單測社群內比較流行的模式無非TDD和BDD兩種:

TDD:測試驅動開發,偏向於去測到函式的各個功能執行的結果是否符合預期,由於是透過先寫用例去驅動業務邏輯的實現,因此用例的設計往往更偏技術實現。

BDD:行為驅動開發,流程上是TDD模式的一種分支,區別在於在構思用例的時候更多的是以使用者行為(user story)的角度去考慮。


關於兩者更多的區別,大家可以網上查閱到更多的資料,這裡就不再贅述。為了我們單測的穩定可維護性,且以文件為導向的我們,自然是選用了BDD的模式,只測業務行為邏輯,不關注功能函式的輸出正確與否(這塊目前可在自測和測試兄弟團隊那邊幫忙保障)。這樣除非業務流程發生變更,否則程式碼一般的重構或調整都不會影響到單測的執行,不會造成單測的雪崩。

4.2 用例結構

在用例結構上,為了配合“單測即文件”的初衷並更好的配合BDD,我們在社群常見的AAA(Arrange-Act-Assert)和GWT(Given-When-Then)兩種結構之間選擇了後者。

無論AAA還是GWT最終都會形成一個三段式的用例結構,其區別仍然在於AAA的構思更傾向於技術實現,GWT更傾向於業務流程。雖然結構一樣,但設計出來的用例內容會有很大區別。

Given-When-Then

Given:一個上下文,指定和準備測試的預設
When:進行一系列操作,即所要執行的操作
Then:得到可觀察的結果,即需要檢測的斷言

我們根據GWT的提供了單測的基本模板,供組內同學寫單測時直接使用。

function init() {
  const checkIsDuringTheEventApi = jest.fn();
  const downloadExcelFile = jest.fn();
  const exportActivityLog = buildMakeExportActivityLog({checkIsDuringTheEventApi, downloadExcelFile})

  return {
    checkIsDuringTheEventApi,
    downloadExcelFile,
    exportActivityLog
  }
}

describe('spec', () => {
  it('test', () => {
    // Given  準備用例所需的上下文
    const { checkIsDuringTheEventApi, downloadExcelFile, exportActivityLog } = init();

    // When 呼叫待測的函式
    exportActivityLog()

    // Then  斷言
    expect('expect')
  })

對於一些校驗簡單模型的用例,透過init函式做一層封裝就夠用了。但對於業務邏輯比較複雜,欄位比較多的模型,直接利用原生資料進行初始化對用例的可讀性並不友好。

describe('spec', () => {
  it('個人賣家未發貨的訂單,允許進行取消操作', () => {
    // Bad case: 依賴欄位較多,這樣手動去創造欄位資料可讀性並不友好
    // 若case較多,這些欄位要手動構建多次
    action({
      status: Status.待發貨,
      merchantType: MerchantType.個人賣家,
      // ...還有一些其他必傳欄位
    })
  })
}

對於這種複雜場景,我們傾向於使用builder模式來構造資料,在較小的開發成本下保障用例的可讀性和可維護性。



describe('spec', () => {
    it('個人賣家未發貨的訂單,允許進行取消操作', () => {
        // Good case:透過builder實現邏輯的複用和資訊的聚焦
        const order = new OrderBuilder()
          .status("待發貨")
          .merchantType("個人賣家")
          .build()

        action(order)

    })
})

4.3 用例描述

既然是要作為文件使用,那用例描述上也顯得至關重要了。相比TDD對功能函式的單測,我們描述完全於GWT的用例結構對應(When時常會被省略掉),我們並不關心具體的技術實現細節,更多的是描述的這個業務的行為流程,思考函式最終想做什麼,達到什麼目的。基於意圖,把被測函式當做黑盒,不用關注其中間的實現細節,究竟生成了什麼臨時變數、迴圈了幾次、有什麼判斷等,而是透過用例描述將業務流程講清楚。

describe('匯出活動日誌', () => {
  it('匯出時,先查詢當前活動狀態,若狀態是未在進行中,則執行匯出操作', () => {
    // 省略...
  })
  it('匯出時,若匯出數量大於5000條,將不允許匯出', () => {
    // 省略...
  })
})

上面?是匯出活動日誌的一個操作,可以看出,用例的描述不會像測功能函式那樣精簡(入參是a,呼叫了啥函式必須返回b之類),但是將匯出活動時,相應的呼叫流程和條件描述了出來,這樣其他人在接手這塊業務時,透過這個用例就能清楚知道在匯出活動日誌時需求上有些什麼限制以及要做的操作。

4.4 用例斷言

在確定好用例的設計思路和結構之後,我們在用例的校驗內容上也做了一些取捨。針對社群上主導的經典測試(Classical)和模擬測試(Mockist)兩大陣營,結合“單測即文件“的理念,我們對於業務流程的驗證訴求非常強烈,因此選擇了後者。

Classical風格是儘可能的使用真實物件和函式,讓函式以及依賴都真實的執行;相對的,Mockist是想盡辦法去mock,主張將所呼叫的被測函式全部mock。存在即合理,兩個派各有利弊,並不存在一定誰好誰差。

要對用到的函式進行mock,在保證用例可維護性的前提下(比如不mock檔案路徑),我們需要對函式的依賴關係進行整理。得益於團隊整潔架構的落地,目前應用的usecase層都已經透過依賴倒置對依賴關係做了很好的管理(usecase只依賴entity)。

export default function buildMakeExportActivityLog({checkIsDuringTheEventApi,downloadExcelFile}) {
  async function exportActivityLog({count,formValues}) {
    if (count > 5000) {
      message.error('匯出檔案數量不得超過5000!')
      return
    }
    const res = await checkIsDuringTheEventApi()
    if (res.isDuring) {
      message.error('活動期間,功能暫不可用,如有疑問聯絡運營');
      return
    }
    const url = generateDownloadUrl({ formValues })
    downloadExcelFile(url)
  }
}

// index.ts
import {checkIsDuringTheEventApi} from '@/services/activity'
import {downloadExcelFile} from '@/utils'
import buildMakeExportActivityLog from './makeExportActivityLog'

export const exportActivityLog = buildMakeExportActivityLog({cancel,printSaleTicket})

可以看到checkIsDuringTheEventApi以及downloadExcelFile這兩個函式最終作為引數傳入到實際的函式中,他們一個將會去發起請求,一個是會呼叫window的方法進行下載,透過依賴倒置就能方便我們對其進行模擬,在單測時就不會去真實執行這兩個函式。

function init() {
  const checkIsDuringTheEventApi = jest.fn();
  const downloadExcelFile = jest.fn();
  const exportActivityLog = buildMakeExportActivityLog({checkIsDuringTheEventApi, downloadExcelFile})
  return {
    checkIsDuringTheEventApi,
    downloadExcelFile,
    exportActivityLog
  }
}

usecase中時常會有依賴的函式要去發起請求,在單測時我們不會去真實去發起這個請求,因此對於這類函式,我們都應mock掉,這樣可保障我們用例的速度和穩定性。當然實際在寫單測中,我們也不應該成為一個完全的mockist,無休止的進行mock,更好的方式是兩者結合,否則濫用mock反而會導致單測寫起來會更繁瑣(因為要去mock所有呼叫的函式實現或場景),而且真實程式碼寫起來也會很彆扭(所有外部函式都依賴倒置)。

一個用例正確與否,最終依賴的是最後的斷言,那對我們來說該怎樣進行斷言呢,如前面一直強調的一樣,我們測的是邏輯行為,因此需斷言的是某個行為的是否執行或者是否達到了什麼目的。結合前面的mock,我們可對函式的呼叫情況進行捕獲,針對上面發起取消退款的函式,斷言的例子如下:

describe('匯出活動日誌', () => {
  it('匯出時,先查詢當前活動狀態,若狀態是未在進行中,則執行匯出操作', () => {
    // 省略...
    expect(downloadExcelFile).toBeCalled()
  })

  it('匯出時,若匯出數量大於5000條,將不允許匯出', () => {
    // 省略...
    expect(downloadExcelFile).not.toBeCalled();
  })
})

如上,斷言的內容不是函式的實現細節,如引數是否正確,而是隻斷言行為是否執行,它能儘量保證做到若程式碼重構後,單測用例在不修改的情況下依然能健壯的執行,其只依賴需求的變更而做更改。同時為了維護用例的穩定性,單個用例我們通常僅執行一次斷言(單一職責),斷言的內容嚴格和描述的“Then”部分對應。

5. 結語

商家以“單測即文件”的理念為落地方向,在程式碼設計以及用例的構思、結構、斷言、描述等環節都做了一定取捨,最終在用例的書寫成本、穩定性、可讀性等各個方面取得了相對較好的平衡。

目前組內各個專案已逐漸沉澱了幾百個用例,團隊內相互支援或自己回顧時,透過這些用例就能知道這塊邏輯在做什麼事,在修改這些需求時透過測試用例也能儘快知道基本的業務邏輯,有了單測的保障,改起程式碼來更有底氣,程式碼結構上,也更加的合理。在大家逐漸熟悉單測後,後續更會慢慢做到功能函式、UI等的單測覆蓋,大家一起來保障商家前端業務的穩定發展。

參考文章:
“整潔架構”和商家前端的重構之路:
https://mp.weixin.qq.com/s/Sg...

The Difference Between TDD and BDD:
https://joshldavis.com/2013/0...
https://lassala.net/2017/07/2...

jest文件:
https://jestjs.io/zh-Hans/doc...

*文/淳猛 
關注得物技術,每週一三五晚18:30更新技術乾貨
要是覺得文章對你有幫助的話,歡迎評論轉發點贊~

相關文章