天天看點

研效優化實踐:聊聊單元測試那些事兒

研效優化實踐:聊聊單元測試那些事兒

作者:ciuwaalu,騰訊安全平台部背景開發

研發效能提升是一個系統化的龐大工程,它涵蓋了軟體傳遞的整個生命周期,涉及到産品、架構、開發、測試、運維等各個環節。而單元測試作為軟體中最小可測試單元的檢查驗證環節,可以說是這個龐大工程中最細緻但又不可忽視的一個細節因素。本文内容梳理自安全平台部測試效能提升的經驗實踐,從零開始介紹探讨單測的方法論和優化思路,期望為大家帶來參考,歡迎共同交流。

什麼是單元測試?

在最開始,我們先看看大家認為的單元測試是什麼:

在計算機程式設計中,單元測試是一種軟體測試方法,通過該方法對源代碼的各個單元(一個或多個計算機程式子產品的集合以及相關的控制資料、使用過程和操作過程)進行測試以确定它們是否符合使用要求。—— 維基百科《Unit testing》

一個單元測試是一段自動化的代碼,這段代碼調用被測試的工作單元,之後對這個單元的單個最終結果的某些假設進行檢驗。單元測試幾乎都是用單元測試架構編寫的。單元測試容易編寫,能快速運作。單元測試可靠、可讀,并且可維護。隻要産品代碼不發生變化,單元測試的結果是穩定的。—— Roy Osherove《單元測試的藝術》

以上這些定義為了嚴謹起見,都是長長的一大段。在這裡,我們結合工程實踐經驗,給出一個“太長不看”版的定義,這個定義不太嚴謹但更為簡單:

開發同學 在 編碼階段 以 函數方法 為粒度編寫測試用例,檢驗 代碼邏輯 的正确性。

在這個一句話定義裡,有四個核心要素:

  • 角色:開發同學

    單元測試是開發同學工作的一部分,而不是測試同學的工作内容。

  • 階段:編碼階段

    單元測試是在開發編碼階段進行的,而不是轉測試之後才開始的。

  • 粒度:函數方法

    單元測試主要針對函數方法,而不是整個子產品或系統。

  • 檢驗:代碼邏輯

    單元測試主要驗證函數方法中的代碼邏輯實作,而不是子產品接口、系統架構、使用者需求。

結合測試 V 型圖,可以清晰看到單元測試在項目周期中所處的位置階段。

研效優化實踐:聊聊單元測試那些事兒

單元測試有什麼好處?

我們不打算羅列《單元測試的N大優勢》《寫單元測試的N大好處》,隻說一條最核心的:單元測試可以盡早發現編碼中的低級錯誤。

越早發現問題,也越容易解決問題。很顯然:

  • 如果問題在編碼階段、由開發同學通過單元測試發現,開發同學可以立即修複
  • 如果問題在轉測之後、由測試同學發現,可能會走缺陷單,修複流程時間長,影響項目進展
  • 如果問題在測試階段未被發現,而在上線後才觸發,需要運維同學復原,甚至可能會導緻現網事故

來自微軟的資料,不同測試階段發現BUG的平均耗時,供參考:

  • 單元測試階段,平均耗時 3.25 小時
  • 內建測試階段,平均耗時 6.25 小時 (+92%)
  • 系統測試階段,平均耗時 11.5 小時 (+254%)

低級錯誤造成重大損失的例子實在太多了。有了單元測試,可以避免 面向運氣開發,面向復原釋出,打破“不知道有沒有BUG ~ 上線出事復原 ~ 緊急修複 ~ 代碼品質逐漸劣化 ~ 不知道有沒有新BUG” 的惡性循環。

黑盒與白盒

在軟體測試理論中,常常将被測試對象視為一個盒子,這個神秘的盒子接受一些輸入,并做某些處理工作,産生特定的輸出結果。

研效優化實踐:聊聊單元測試那些事兒

在構造輸入資料進行測試時:

  • 如果知道盒子的用途,但不知道盒子的構造,就是黑盒測試
  • 如果知道盒子的用途,也知道盒子的構造,就是白盒測試

白盒測試一般隻在單元測試中使用,黑盒測試在單元測試、內建測試等各個階段都可以使用。

我們以下方這個函數為例子,看看單元測試中如何應用黑盒與白盒測試。首先需要明确,設計單元測試,我們肯定是知道這個函數的具體用途、輸入參數和傳回結果的含義(即知道盒子的用途):

// 從 IPv4 封包中提取源 IP 位址
uint32_t GetSrcAddrFromIPv4Packet(const void *buffer, size_t size);           

複制

如果我們手上隻有編譯好的二進制庫檔案,不知道函數的内部實作方式,通過想象這個函數在上線後會遇到什麼類型的輸入,設計了一些合法和非法的 IP 封包來做驗證,此時是 黑盒測試。

如果我們手上有函數源代碼,一邊看着函數實作,一邊根據代碼裡的分支、邏輯構造各種輸入,此時是 白盒測試:

比如看到函數内部的

if (buffer == nullptr) return -1;

設計了一個空緩沖區的用例;

比如看到函數内部的

if (size < sizeof(iphdr)) return -1;

 設計了緩沖區大小為 19Bytes 的用例。

在大部分情況下,我們是自己給自己寫的函數做單元測試,當運用黑盒測試的思路時,要 假裝 被測函數是别人寫的。

覆寫

在單元測試中,覆寫率是一個常用的評估名額。

所謂覆寫,可以簡單了解為 “被執行過”。具體來說:在某個測試用例中,執行了某行代碼,則可以說這行代碼“被覆寫”;同樣,當某個分支的真/假條件都被取到時,則可以說這個分支“被覆寫了”。

常見的覆寫可以分為這幾種:

  • 語句覆寫
  • 分支覆寫
  • 條件覆寫

假設我們有一個這麼一個待測函數:

int foo(int a, int b, int c, int d) {
    int result = 0;
    if (a && b)                        // 分支 1
        result += a;
    if (c || d)                        // 分支 2
        result += c;
    return result;
}           

複制

語句覆寫 是指 每條語句都被執行一次。當輸入

a=1, b=1, c=1, d=1

 一組用例時可以達到。

分支覆寫 是指 每個分支 真/假 條件都被執行一次。當輸入

a=1, b=1, c=1, d=1

以及

a=0, b=0, c=0, d=0

兩組用例時可以達到。

條件覆寫 是指 每個分支的條件組合方式都被執行一次。當輸入

a=1, b=1, c=1, d=1

(真真)、

a=1, b=0, c=1, d=0

(真假)、

a=0, b=1, c=0, d=1

(假真)、

a=0, b=0, c=0, d=0

(假假)四組用例時可以達到。

語句覆寫是最容易達到、也是最弱的覆寫方式。在工程實踐中,考慮到測試成本及測試效果,分支覆寫的覆寫率是最常使用的考察名額。

樁與驅動

假設我們還有這麼一個待測函數:

void foo(int a) {
    if (a > 0) {
        A();
    } else {
        B();
    }
}           

複制

foo()

調用了外部函數

A()

B()

假設

A()

是一個很重的函數(操作 DB、檔案或者網絡通信……),進行單元測試時,我們不希望引入這些外部依賴,而是希望調用

A()

時立即傳回一些提前準備好的“假資料”,這時需要“仿冒”一個

A()

,這個僞造過程就叫做 插樁,假冒的

A()

就稱為 樁函數(stub)。

在做測試時,需要寫一個函數來調用

foo()

,這個調用者就是 驅動(driver)。

單元測試簡單實踐

一個簡單的單元測試

一個單元測試用例至少包含:

  • 斷言
  • 輸入資料
  • 預期輸出

一個簡單但完整的單元測試看起來會是這樣的:

// 待測函數
int add(int a, int b) {
    return a + b;
}

// 測試用例
void TestAdd() {
//       被測對象      預期輸出
//         |||          |
    assert(add(1, 2) == 3);
//  ||||||     |  |
//   斷言      輸入資料
}

// 執行測試
int main() {
    TestAdd();
}           

複制

Given-When-Then

單元測試中 被測函數、斷言、輸入資料、預期輸出 幾個要素,可以通過經典模闆 Given-When-Then(GWT) 來做一些嚴謹的描述。

  • Given 描述測試的前置條件或初始狀态
  • When 描述測試過程中發生的行為
  • Then 描述測試結束後斷言輸出結果

使用 GWT 來描述上一節的用例:

assert(
  add(      // When  - 測試過程發生的行為 - 調用被測函數 add()
    1, 2    // Given - 測試前置條件和初始狀态 - 用例輸入參數
  )
  == 3      // Then  - 測試結束斷言輸出結果 - 斷言預期輸出
);           

複制

有些現代化的測試架構(例如 catch2)對 GWT 描述做了表達上的優化。下方粘貼了一段單元測試代碼示例,有對 GWT 更為具體的描述:

SCENARIO( "vectors can be sized and resized", "[vector]" ) {
    GIVEN( "A vector with some items" ) {
        std::vector<int> v( 5 );

        REQUIRE( v.size() == 5 );  // REQUIRE() 即 assert()
        REQUIRE( v.capacity() >= 5 );

        WHEN( "the size is increased" ) {
            v.resize( 10 );

            THEN( "the size and capacity change" ) {
                REQUIRE( v.size() == 10 );
                REQUIRE( v.capacity() >= 10 );
            }
        }
        WHEN( "the size is reduced" ) {
            v.resize( 0 );

            THEN( "the size changes but not capacity" ) {
                REQUIRE( v.size() == 0 );
                REQUIRE( v.capacity() >= 5 );
            }
        }
    }
}           

複制

組織結構

原則:單元測試盡可能以函數方法等較小粒度進行組織。

假設我們有下邊一個類,設計單元測試時,最好以各個功能函數為測試目标,而不是将類本身為測試目标:

// IPv4 封包解析
struct IPv4Parser {
    IPv4Parser(const void *buffer, size_t size);

    size_t   GetHeaderSize();   // 擷取頭部大小
    uint32_t GetSrcAddr();      // 擷取源 IP
    uint32_t GetDstAddr();      // 擷取目的 IP
};           

複制

建議:為

GetHeaderSize()

GetSrcAddr()

GetDstAddr()

分别構造不同的測試輸入資料。

不建議:為

IPv4Parser

類構造測試輸入資料,然後對

GetHeaderSize()

GetSrcAddr()

GetDstAddr()

使用同樣的資料進行單元測試。

常見的測試架構都支援通過測試套件(TestSuite)對測試用例(TestCase)在邏輯上進行組織,測試套件可以嵌套,整個單元測試可以組織為樹狀結構。

常見的測試架構還支援 Fixture。Fixture 是對測試環境進行組織,通過

SetUp()

TearDown()

函數,以友善進行測試開始前的準備工作,以及測試完成後的清理工作。Fixture 一般會與測試套件結合使用。

研效優化實踐:聊聊單元測試那些事兒

組織單元測試的幾點準則:

  • 輕量:不要有過多的前置條件或外部依賴

輕量的測試用例易于重複執行,友善重制和定位問題。

  • 獨立:同一個測試套件的不同的用例互相獨立

    測試用例之間盡量獨立,避免依賴,可亂序執行,結果穩定複現。

  • 隔離:使用測試套件隔離資源

    使用測試套件與 Fixture 隔離測試用例的資源依賴,以友善管理。

用例設計

設計單元測試用例中有很多方法:等價類劃分、邊界值分析、路徑測試……

在實踐中,我們可以設計覆寫 正常流程 & 異常流程 兩大類用例:

  • 正常流程通過輸入合法的 典型資料、邊界值 看基本功能是否正确實作
  • 異常流程通過輸入非法資料看異常處理流程是否符合預期

一個函數的内部實作可能是 異常處理-正常流程-異常處理-正常流程 的重複,比如這樣:

size_t IPv4Parser::GetHeaderSize() {
  // 異常處理
  if (buffer_size < sizeof(iphdr)) return 0;

  // 正常流程
  auto ip = (const iphdr*) buffer;

  // 異常處理
  if (ip->version != 4) return false;

  // ...
}           

複制

是以我們在設計測試用例時,可以:

  1. 首先設計覆寫 正常流程 的用例,構造一些合法的輸入:一個典型的 IP 封包,一個有擴充頭部的 IP 封包,一個帶有 TCP/UDP payload 的 IP 封包……
  2. 其次設計覆寫 異常流程 的用例,構造一些非法的輸入:空指針,不完整的 IP 頭,非 IP 協定……
  3. 最後再考慮一些邊界情況:一個不帶 payload 的 IP 封包,一個大小為 64K 上限的 IP 封包,一個頭部完整但payload 不完整的 IP 封包……

在設計測試用例過程中,可能會遇到被測函數需要與外部 DB、檔案、網絡互動的情況,這時候需要使用 Fakes/Stubs/Mocks 進行模拟:

  • Fakes:包含了生産環境下具體實作的簡化版本的對象

    比如模拟的資料庫對象、檔案描述符、網絡連接配接等。

  • Stubs:包含了預定義好的資料并且在測試時傳回給調用者的對象

    比如很多組預定義好的輸入、輸出資料,比如資料庫查詢結果。

  • Mocks:僅記錄它們的調用資訊的對象

    比如模拟的檔案儲存接口、資料發送接口等。

在實踐中通常并不糾結這幾個詞語的差別,常被統稱為 插樁,對應的工具也一般被稱作 Mock 工具。

C++ 單元測試

常見單元測試架構
研效優化實踐:聊聊單元測試那些事兒

GoogleTest 是老牌測試架構,功能完善,使用者很多。

Catch2 是現代化測試架構,提供了很多特色功能,依賴簡單,可以一試。

Boost.Test 是 Boost 自帶的測試架構,依賴 Boost 的程式可以直接使用,功能強大。

一些 Mock 工具
  • GoogleMock
    • 通過 C++ 多态實作對虛函數進行 Mock
    • 不支援 Free Function 以及非虛函數
    • 目前已經合并為 GoogleTest 的一個子子產品
  • 《效能優化實踐:C/C++單元測試萬能插樁工具》
    • 通過 Hook 函數入口實作用 Mock 函數無縫替換原始函數
    • 内部開源工具
  • MySQL Server Mock
    • MySQL 官方提供的服務端 Mock 工具
編譯參數選項
  • 開啟調試資訊:
    • -g

  • 關閉優化和代碼保護:
    • -O0

    • -fno-inline

    • -fno-access-control

  • 覆寫率:
    • --coverage

    • -fprofile-arcs

    • -ftest-coverage

Python 單元測試

點選閱讀《研效優化實踐:Python單測——從入門到起飛》。

小經驗分享

三條準則

單元測試必須經常跑

  • 錯誤做法:為了完成 KPI 寫了一堆測試,跑一次就不管了
  • 正确做法:持續內建,自動化運作

從增量到存量,從主要到次要

  • 從覆寫新子產品、新功能做起,單元測試先跑起來再說
  • 不要追求 100% 的覆寫率,但主要功能邏輯要完成覆寫測試

測試用例需要逐漸積累

  • 上線前已經有了第一批用例,每次疊代都會增加新用例來覆寫變更

實踐經驗

思路:以黑盒指導功能驗證,以白盒提升覆寫率

黑盒測試為主:

  • 黑盒測試驗證功能邏輯實作是否正确
  • 不關心内部實作方式,代碼優化重構用例仍可複用

白盒測試為輔:

  • 白盒測試關注黑盒測試用例遺漏的分支、路徑
  • 可以聚焦于異常處理邏輯是否合理
  • 項目工期緊時可推遲進行

可能踩到的坑

不要被高覆寫率騙了

  • 單元測試的目标是發現問題,不是追求高覆寫率
  • 宏、模闆等文法功能可能會使得覆寫率虛高

Debug/Release 目标結果不一緻

  • Debug 目标關閉優化,啟用堆棧保護,某些錯誤代碼可正常執行
  • 單測在 Debug 下跑完後,建議在 Release 下再跑一次

代碼合并導緻單測失敗

  • 小A和小B分别開發新功能,push 前單測都通過了,MR 後單測卻挂了
  • 使用持續內建發現問題

提高代碼的可測性

在編碼過程中,多多考慮代碼的可測性,可以讓單元測試事半功倍:

  • 開發過程及時編寫測試用例,邊開發邊測試,不要等全部開發完畢了才開始寫測試用例
  • 函數功能簡單,避免随機性,以免測試結果不穩定
  • 函數減少輸入輸出,使簡單的輸入資料組合可以完成測試覆寫
  • 遵循 SOLID 原則

最後

在實際研發與測試工作中,單元測試是保證代碼品質的有效手段,也是效能優化實踐的重要一環。安平研效團隊仍在持續探索優化中,若大家在工作中遇到相關問題,歡迎一起交流探讨,共同把研效工作做好、做強。