C++語言的單元測試與程式碼覆蓋率

paul.pub發表於2018-11-23

對程式碼進行單元測試是幾乎每個軟體工程師都要完成的工作。本文以C++語言為基礎,講解如何進行單元測試並生成測試報告。

前言

測試是軟體開發過程中一個必須的環節,測試確保軟體的質量符合預期。

對於工程師自己來說,單元測試也是提升自信心的一種方式。

直接交付沒有經過測試的程式碼是不太好的,因為這很可能會浪費整個團隊的時間,在一些原本早期就可以發現的問題上。而單元測試,就是發現問題一個很重要的環節。

本文以C++語言為基礎,講解如何進行單元測試並生成測試報告。

在工具上,我們會使用下面這些:

  • GCC
  • CMake
  • Google Test
  • gcov
  • lcov

演示專案

為了方便本文的講解,我專門編寫了一個演示專案作為程式碼示例。

演示專案的原始碼可以在我的Github上獲取:paulQuei/gtest-and-coverage

你可以通過下面幾條命令下載和執行這個專案:

git clone https://github.com/paulQuei/gtest-and-coverage.git
cd gtest-and-coverage
./make_all.sh

要執行這個專案,你的機器上必須先安裝好前面提到的工具。如果沒有,請閱讀下文以瞭解如何安裝它們。

如果你使用的是Mac系統,下文假設你的系統上已經安裝了brew包管理器。如果沒有,請通過下面這條命令安裝它:

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

專案結構

演示專案的目錄結構如下:

.
├── CMakeLists.txt
├── googletest-release-1.8.1.zip
├── include
│   └── utility.h
├── make_all.sh
├── src
│   └── utility.cpp
└── test
    └── unit_test.cpp

這裡演示的內容是:以測試一個我們要提供的軟體庫為例,講解如何對其進行單元測試並生成測試報告。

為了簡單起見,這個軟體庫只有一個標頭檔案和一個實現檔案。

當然,在實際上的專案中,一個軟體庫會通常包含更多的檔案,不過這並不影響我們要說明的問題。

演示專案中的檔案說明如下:

檔名稱 說明
make_all.sh 入口檔案,會執行:編譯,測試和生成報告等所有工作
CMakeLists.txt 專案的編譯檔案
googletest-release-1.8.1.zip google test原始碼壓縮包
utility.h 待測試的軟體庫的標頭檔案
utility.cpp 待測試的軟體庫的實現檔案
unit_test.cpp 對軟體庫進行單元測試的程式碼

測試環境

演示專案在如下的環境中測試過。

  • MacBook Pro
    • 作業系統:macOS Mojave 10.14.1
    • 編譯器:Apple LLVM version 10.0.0 (clang-1000.11.45.2)
    • CMake:cmake version 3.12.1
    • Google Test: 1.8.1
    • lcov: lcov version 1.13
  • Ubuntu
    • 作業系統:Ubuntu 16.04.5 LTS
    • 編譯器:gcc (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609
    • CMake:cmake version 3.5.1
    • Google Test:1.8.1
    • lcov:lcov version 1.12

關於CMake

為了簡化編譯的過程,這裡使用CMake作為編譯工具。關於CMake的更多內容請參見請官網:https://cmake.org

關於如何安裝CMake請參見這裡:Installing CMake

另外,你也可以通過一條簡單的命令來安裝CMake:

Mac系統:

brew install cmake

Ubuntu系統

sudo apt install cmake

由於篇幅所限,這裡不打算對CMake做過多講解,讀者可以訪問其官網或者在網路上搜尋其使用方法。

這裡僅僅對演示專案中用到的內容做一下說明。演示專案中的CMakeLists.txt內容如下:

cmake_minimum_required(VERSION 2.8.11) ①
project(utility) ②

set(CMAKE_CXX_STANDARD 11) ③

set(GTEST googletest-release-1.8.1) ④
include_directories("./include" "${GTEST}/googletest/include/")
link_directories("build/gtest/googlemock/gtest/")

SET(CMAKE_CXX_FLAGS  "${CMAKE_CXX_FLAGS} --coverage") ⑤

add_library(${CMAKE_PROJECT_NAME}_lib src/utility.cpp) ⑥

add_executable(unit_test test/unit_test.cpp) ⑦
target_link_libraries(unit_test ${CMAKE_PROJECT_NAME}_lib gtest gtest_main pthread) ⑧

以編號為序,這段程式碼說明如下:

  1. 設定使用的CMake最低版本號為2.8.11。
  2. 指定專案的名稱為”utility”,專案名稱可以通過${CMAKE_PROJECT_NAME}進行引用。
  3. 指定使用C++11。
  4. 這裡的三行是編譯google test,並將其標頭檔案路徑和編譯結果的庫檔案路徑新增到環境中。因為後面在編譯單元測試程式碼的時候需要用到。
  5. 新增--coverage到編譯器flag中,這個引數是很重要的,因為這是生成程式碼覆蓋率所必須的。關於該編譯引數的說明見這裡:Program Instrumentation Options
  6. 編譯我們的軟體庫,這裡將生成libutility_lib.a庫檔案。
  7. 編譯單元測試的可執行檔案。
  8. 單元測試的可執行檔案需要連結我們開發的軟體庫以及google test的庫。另外,google test依賴了pthread,所以這個庫也需要。

關於測試

軟體測試有很多種分類方式。從測試的級別來說,可以大致分為:

  • 單元測試
  • 整合測試
  • 系統測試

這其中,單元測試是最區域性和具體的。它通常需要對程式碼中的每一個類和函式進行測試。

單元測試通常由開發者完成,需要針對程式碼邏輯進行測試。所以它是一種白盒測試

關於xUnit

xUnit是幾種單元測試框架的總稱。最早源於Smalltalk的單元測試框架SUnit,它是由Kent Beck開發的。

除此之外,還有針對Java語言的JUnit,針對R語言的RUnit。

在本文中,我們使用Google開發的xUnit框架:Google Test。

Google Test介紹

Google Test的專案主頁在Github上:Github: Google Test

實際上,這個專案中同時包含了GoogleTest和GoogleMock兩個工具,本文中我們只會講解第一個。

Google Test支援的作業系統包含下面這些:

  • Linux
  • Mac OS X
  • Windows
  • Cygwin
  • MinGW
  • Windows Mobile
  • Symbian

目前有很多的專案都使用了Google Test,例如下面這些:

編譯Google Test

關於如何編譯Google Test請參見這裡:Generic Build Instructions

為了便於讀者使用,我們在演示專案中包含了Google Test 1.8.1的原始碼壓縮包。並且在CMake檔案中,同時包含了Google Test的編譯和使用配置工作。

如果使用演示專案,讀者將不需要手動處理Google Test的編譯和安裝工作。

使用Google Test

演示專案程式碼說明

為了便於下文說明,演示專案中包含了幾個簡單的函式。

可以從這裡下載原始碼以便檢視其中的內容:paulQuei/gtest-and-coverage

演示專案中的軟體庫包含一個標頭檔案和一個實現檔案。標頭檔案內容如下:

// utility.h

#ifndef INCLUDE_UTILITY_
#define INCLUDE_UTILITY_

enum CalcType {
    ADD,
    MINUS,
    MULTIPLE,
    DIVIDE
};

class Utility {
public:
    int ArithmeticCalculation(CalcType op, int a, int b);

    double ArithmeticCalculation(CalcType op, double a, double b);

    bool IsLeapYear(int year);
};

#endif

這個標頭檔案說明如下:

  • 標頭檔案包含了三個函式,前兩個用來做intdouble型別的四則運算。最後一個判斷輸入的年份是否是閏年。
  • 當然,在實際的工程中,前兩個函式合併實現為一個泛型函式更為合適。但這裡之所以分成兩個,是為了檢視程式碼覆蓋率所用。
  • 關於閏年說明如下:
    • 能被4整除但不能被100整除的年份為普通閏年。
    • 能被100整除,也同時能被400整除的為世紀閏年。
    • 其他都不是閏年。
    • 例如:1997年不是閏年,2000年是閏年,2016年是閏年,2100不是閏年。

這三個函式的實現也不復雜:

// utility.cpp

#include "utility.h"

#include <iostream>
#include <limits>

using namespace std;

int Utility::ArithmeticCalculation(CalcType op, int a, int b) {
    if (op == ADD) {
        return a + b;
    } else if (op == MINUS) {
        return a - b;
    } else if (op == MULTIPLE) {
        return a * b;
    } else {
        if (b == 0) {
            cout << "CANNO Divided by 0" << endl;
            return std::numeric_limits<int>::max();
        }
        return a / b;
    }
}

double Utility::ArithmeticCalculation(CalcType op, double a, double b) {
    if (op == ADD) {
        return a + b;
    } else if (op == MINUS) {
        return a - b;
    } else if (op == MULTIPLE) {
        return a * b;
    } else {
        if (b == 0) {
            cout << "CANNO Divided by 0" << endl;
            return std::numeric_limits<double>::max();
        }
        return a / b;
    }
}

bool Utility::IsLeapYear(int year) {
    if (year % 100 == 0 && year % 400 == 0) {
        return true;
    }
    if (year % 100 != 0 && year % 4 == 0) {
        return true;
    }
    return false;
}

開始測試

接下來我們就要對上面這些程式碼進行測試了。

要使用Google Test進行測試,整個過程也非常的簡單。只要進行下面三部:

  1. 建立一個測試用的cpp檔案
  2. 為上面這個測試用的cpp檔案編寫Makefile(或者CMake檔案)。同時連結:
    • 待測試的軟體庫
    • gtest
    • gtest_main
    • pthread庫(Google Test使用了這個庫所以需要)
  3. 編寫測試程式碼,編譯並執行測試的可執行程式。

並且,測試程式碼寫起來也非常的簡單,像下面這樣:

#include "utility.h"

#include "gtest/gtest.h"

TEST(TestCalculationInt, ArithmeticCalculationInt) {
    Utility util;
    EXPECT_EQ(util.ArithmeticCalculation(ADD, 1, 1), 2);
    EXPECT_EQ(util.ArithmeticCalculation(MINUS, 2, 1), 1);
    EXPECT_EQ(util.ArithmeticCalculation(MULTIPLE, 3, 3), 9);
    EXPECT_EQ(util.ArithmeticCalculation(DIVIDE, 10, 2), 5);
    EXPECT_GT(util.ArithmeticCalculation(DIVIDE, 10, 0), 999999999);
}

是的,就是這麼簡單的幾行程式碼,就對整數四則運算的函式進行了測試。

TEST後面所包含的內容稱之為一條case,通常我們會為每個函式建立一個獨立的case來進行測試。一個測試檔案中可以包含很多條case。同時,一條case中會包含很多的判斷(例如EXPECT_EQ...)。

注意:在做單元測試的時候,保證每條case是獨立的,case之間沒有前後依賴關係是非常重要的。

當然,測試程式碼中包含的判斷的多少將影響測試結果的覆蓋率。所以在編寫每條case的時候,我們需要仔細思考待測試函式的可能性,有針對性的進行測試程式碼的編寫。

這段程式碼應該很好理解,它分別進行了下面這些測試:

  • 1 + 1 = 2
  • 2 – 1 = 1
  • 3 x 3 = 9
  • 10 / 2 = 5
  • 10 / 0 > 999999999

你可能會發現,這段程式碼裡面甚至沒有main函式。它也依然可以生成一個可執行檔案。這就是我們連結gtest_main所起的作用。

在實際的測試過程中,你想判斷的情況可能不止上面這麼簡單。下面我們來看看Google Test還能做哪些測試。

測試判斷

Google Test對於結果的判斷,有兩種形式:

  • ASSERT_*:這類判斷是Fatal的。一旦這個判斷出錯,則直接從測試函式中返回,不會再繼續後面的測試。
  • EXPECT_*:這類判斷是Nonfatal的。它的效果是,如果某個判斷出錯,則輸出一個錯誤資訊,但是接下來仍然會繼續執行後面的測試。

可以進行的判斷方法主要有下面這些:

布林判斷

Fatal Nonfatal 說明
ASSERT_TRUE(condition) EXPECT_TRUE(condition) 判斷 condition 為 true
ASSERT_FALSE(condition) EXPECT_FALSE(condition) 判斷 condition 為 false

二進位制判斷

Fatal Nonfatal 說明
ASSERT_EQ(expected, actual) EXPECT_EQ(expected, actual) 判斷兩個數值相等
ASSERT_NE(val1, val2) EXPECT_NE(val1, val2) val1 != val2
ASSERT_LT(val1, val2) EXPECT_LT(val1, val2) val1 < val2
ASSERT_LE(val1, val2) EXPECT_LE(val1, val2) val1 <= val2
ASSERT_GT(val1, val2) EXPECT_GT(val1, val2) val1 > val2
ASSERT_GE(val1, val2) EXPECT_GE(val1, val2) val1 >= val2

說明:

  • EQ:EQual
  • NE:Not Equal
  • LT:Less Than
  • LE:Less Equal
  • GT:Greater Than
  • GE:Greater Equal

字串判斷

Fatal Nonfatal 說明
ASSERT_STREQ(expected, actual) EXPECT_STREQ(expected, actual) 兩個C string相同
ASSERT_STRNE(str1, str2) EXPECT_STRNE(str1, str2) 兩個C string不相同
ASSERT_STRCASEEQ(exp, act) EXPECT_STRCASEEQ(exp, act) 忽略大小寫,兩個C string相同
ASSERT_STRCASENE(str1, str2) EXPECT_STRCASENE(str1, str2) 忽略大小寫,兩個C string不相同

浮點數判斷

Fatal Nonfatal 說明
ASSERT_FLOAT_EQ(exp, act) EXPECT_FLOAT_EQ(exp, act) 兩個float數值相等
ASSERT_DOUBLE_EQ(exp, act) EXPECT_DOUBLE_EQ(exp, act) 兩個double數值相等
ASSERT_NEAR(val1, val2, abs_err) EXPECT_NEAR(val1, val2, abs_err) val1和val2的差距不超過abs_err

異常判斷

Fatal Nonfatal 說明
ASSERT_THROW(stmt, exc_type) EXPECT_THROW(stmt, exc_type) stmt丟擲了exc_type型別的異常
ASSERT_ANY_THROW(stmt) EXPECT_ANY_THROW(stmt) stmt丟擲了任意型別的異常
ASSERT_NO_THROW(stmt) EXPECT_NO_THROW(stmt) stmt沒有丟擲異常

Test Fixture

在某些情況下,我們可能希望多條測試case使用相同的測試資料。例如,我們的演示專案中,每條case都會需要建立Utility物件。

有些時候,我們要測試的物件可能很大,或者建立的過程非常的慢。這時,如果每條case反覆建立這個物件就顯得浪費資源和時間了。此時,我們可以使用Test Fixture來共享測試的物件。

要使用Test Fixture我們需要建立一個類繼承自Google Test中的::testing::Test

還記得我們前面說過,我們要儘可能的保證每條測試case是互相獨立的。但是,當我們在多條case之間共享有狀態的物件時,就可能出現問題。

例如,我們要測試的是一個佇列資料結構。有的case會向佇列中新增資料,有的case會從佇列中刪除資料。case執行的順序不同,則會導致Queue中的資料不一樣,這就可能會影響case的結果。

為了保證每條case是獨立的,我們可以在每條case的執行前後分別完成準備工作和清理工作,例如,準備工作是向佇列中新增三個資料,而清理工作是將佇列置空。

這兩項重複性的工作可以由::testing::Test類中的SetupTearDown兩個函式來完成。

我們演示用的Utility類是無狀態的,所以不存在這個問題。因此,這裡我們僅僅在SetupTearDown兩個函式中列印了一句日誌。

使用Test Fixture後,我們的程式碼如下所示:

class UtilityTest : public ::testing::Test {

protected:

void SetUp() override {
    cout << "SetUp runs before each case." << endl;
}

void TearDown() override {
    cout << "TearDown runs after each case." << endl;
}

Utility util;

};

這段程式碼說明如下:

  1. SetupTearDown兩個函式標記了override以確認是重寫父類中的方法,這是C++11新增的語法。
  2. 我們的Utility類是無狀態的,因此SetupTearDown兩個函式中我們僅僅列印日誌以便確認。
  3. Utility util設定為protected以便測試程式碼中可以訪問。(從實現上來說,測試case的程式碼是從這個類繼承的子類,當然,這個關係是由Google Test工具完成的)。

要使用這裡定義的Test Fixture,測試case的程式碼需要將開頭的TEST變更為TEST_F

這裡_F就是Fixture的意思。

使用TEST_F的case的程式碼結構如下:

TEST_F(TestCaseName, TestName) {
  ... test body ...
}

這裡的TestCaseName必須是Test Fixture的類名。

所以我們的測試程式碼寫起來是這樣:

TEST_F(UtilityTest, ArithmeticCalculationDouble) {
    EXPECT_EQ(util.ArithmeticCalculation(ADD, 1.1, 1.1), 2.2);
}

TEST_F(UtilityTest, ArithmeticCalculationIsLeapYear) {
    EXPECT_FALSE(util.IsLeapYear(1997));
    EXPECT_TRUE(util.IsLeapYear(2000));
    EXPECT_TRUE(util.IsLeapYear(2016));
    EXPECT_FALSE(util.IsLeapYear(2100));
}

我們針對ArithmeticCalculation方法故意只進行了一種情況的測試。這是為了最終生成程式碼覆蓋率所用。

執行測試

編寫完單元測試之後,再執行編譯工作便可以執行測試程式以檢視測試結果了。

測試的結果像下面這樣:

如果測試中包含了失敗的case,則會以紅色的形式輸出。同時,會看到失敗的case所處的原始碼行數,這樣可以很方便的知道哪一個測試失敗了,像下面這樣:

只想有選擇性的跑部分case,可以通過--gtest_filter引數進行過濾,這個引數支援*萬用字元。

像下面這樣:

$ ./build/unit_test --gtest_filter=*ArithmeticCalculationInt
Running main() from googletest/src/gtest_main.cc
Note: Google Test filter = *ArithmeticCalculationInt
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from TestCalculationInt
[ RUN      ] TestCalculationInt.ArithmeticCalculationInt
CANNO Divided by 0
[       OK ] TestCalculationInt.ArithmeticCalculationInt (0 ms)
[----------] 1 test from TestCalculationInt (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (0 ms total)
[  PASSED  ] 1 test.

如果想要更好的理解這些內容。請讀者下載演示專案之後完成下面這些操作:

  1. utility.hutility.cpp中新增一些新的函式。
  2. 在新新增的函式中故意包含一個bug。
  3. 為新新增的函式編寫測試程式碼,並測試出函式中包含的bug。

程式碼覆蓋率

在進行單元測試之後,我們當然希望能夠直觀的看到我們的測試都覆蓋了哪些程式碼。

理論上,如果我們能做到100%的覆蓋我們的所有程式碼,則可以說我們的程式碼是沒有Bug的。

但實際上,100%的覆蓋率要比想象得困難。對於大型專案來說,能夠達到80% ~ 90%的語句覆蓋率就已經很不錯了。

覆蓋率的型別

先來看一下,當我們在說“覆蓋率”的時候我們到底是指的什麼。

實際上,程式碼覆蓋率有下面幾種型別:

  • 函式覆蓋率:描述有多少比例的函式經過了測試。
  • 語句覆蓋率:描述有多少比例的語句經過了測試。
  • 分支覆蓋率:描述有多少比例的分支(例如:if-elsecase語句)經過了測試。
  • 條件覆蓋率:描述有多少比例的可能性經過了測試。

這其中,函式覆蓋率最為簡單,就不做說明了。

語句覆蓋率是我們最常用的。因為它很直觀的對應到我們寫的每一行程式碼。

而分支覆蓋率和條件覆蓋率可能不太好理解,需要做一下說明。

以下面這個C語言函式為例:

int foo (int x, int y) {
    int z = 0;
    if ((x > 0) && (y > 0)) {
        z = x;
    }
    return z;
}

這個函式中包含了一個if語句,因此if語句成立或者不成立構成了兩個分支。所以如果只測試了if成立或者不成立的其中之一,其分支覆蓋率只有 1/2 = 50%

而條件覆蓋率需要考慮每種可能性的情況。

對於if (a && b)這樣的語句,其一共有四種可能的情況:

  1. a = true, b = true
  2. a = true, b = false
  3. a = false, b = true
  4. a = false, b = false

請讀者思考一下:對於三層if巢狀,每個if語句包含三個布林變數的程式碼,如果要做到100%的條件覆蓋率,一共要測試多少種情況。

很顯示,在編寫程式碼的時候,儘可能的減少程式碼巢狀,並且簡化邏輯運算是一項很好的習慣。

便於測試的程式碼也是便於理解和維護的,反之則反。

有了這些概念之後,我們就可以看懂測試報告中的覆蓋率了。

gcov

gcov是由GCC工具鏈提供的程式碼覆蓋率生成工具。它可以很方便的和GCC編譯器配合使用。

通常情況下,安裝好GCC工具鏈,也就同時包含了gcov命令列工具。

對於程式碼覆蓋率工具所做的工作,可以簡單的理解為:標記一次執行過程中,哪些程式碼被執行過,哪些沒有執行。

因此,即便沒有測試程式碼,直接執行編譯產物也可以得到程式碼的覆蓋率。只不過,通常情況下這樣得到的覆蓋率較低罷了。

使用

這裡我們以另外一個簡單的程式碼示例來說明gcov的使用。

這段程式碼如下:

// test.c

#include <stdio.h>

int main (void) {

  for (int i = 1; i < 10; i++) {
      if (i % 3 == 0)
        printf ("%d is divisible by 3\n", i);
      if (i % 11 == 0)
        printf ("%d is divisible by 11\n", i);
  }

  return 0;
}

這是一個僅僅包含了main函式的c語言程式碼,main函式的邏輯也很簡單。

我們將這段程式碼儲存到檔案test.c

要通過gcov生成程式碼覆蓋率。需要在編譯時,增加引數--coverage

gcc --coverage test.c

--coverage等同於編譯引數-fprofile-arcs -ftest-coverage以及在連結時增加-lgcov

此處的編譯結果除了得到可執行檔案a.out,還會得到一個test.gcno檔案。該檔案包含了程式碼與行號的資訊,在生成覆蓋率時會需要這個檔案。

很顯然,帶--coverage編譯引數得到的編譯產物會比不帶這個引數要包含更多的資訊,因此編譯產物會更大。所以這個引數只適合在需要生成程式碼覆蓋率的時候才加上。對於正式釋出的編譯產物,不應該新增這個編譯引數。

當我們執行上面編譯出來的可執行檔案a.out時,我們還會得到每個原始碼檔案對應的gcda字尾的檔案。由test.gcnotest.gcda這兩個檔案,便可以得到程式碼的覆蓋率結果了。

關於這兩個檔案的說明請參見這裡:Brief description of gcov data files

只需要通過gcov指定原始檔的名稱(不需要帶字尾):gcov test,便可以得到包含覆蓋率的結果檔案 test.c.gcov了。

回顧一下我們剛剛的操作內容:

$ gcc --coverage test.c
$ ll
total 72
-rwxr-xr-x  1 Paul  staff    26K 11 10 14:41 a.out
-rw-r--r--  1 Paul  staff   240B 11 10 14:41 test.c
-rw-r--r--  1 Paul  staff   720B 11 10 14:41 test.gcno
$ ./a.out 
3 is divisible by 3
6 is divisible by 3
9 is divisible by 3
$ ll
total 80
-rwxr-xr-x  1 Paul  staff    26K 11 10 14:41 a.out
-rw-r--r--  1 Paul  staff   240B 11 10 14:41 test.c
-rw-r--r--  1 Paul  staff   212B 11 10 14:42 test.gcda
-rw-r--r--  1 Paul  staff   720B 11 10 14:41 test.gcno
$ gcov test
File 'test.c'
Lines executed:85.71% of 7
test.c:creating 'test.c.gcov'

$ ll
total 88
-rwxr-xr-x  1 Paul  staff    26K 11 10 14:41 a.out
-rw-r--r--  1 Paul  staff   240B 11 10 14:41 test.c
-rw-r--r--  1 Paul  staff   623B 11 10 14:42 test.c.gcov
-rw-r--r--  1 Paul  staff   212B 11 10 14:42 test.gcda
-rw-r--r--  1 Paul  staff   720B 11 10 14:41 test.gcno

我們可以cat test.c.gcov一下,檢視覆蓋率的結果:

        -:    0:Source:test.c
        -:    0:Graph:test.gcno
        -:    0:Data:test.gcda
        -:    0:Runs:1
        -:    0:Programs:1
        -:    1:// test.c
        -:    2:
        -:    3:#include <stdio.h>
        -:    4:
        -:    5:int main (void) {
        -:    6:
       20:    7:  for (int i = 1; i < 10; i++) {
        9:    8:      if (i % 3 == 0)
        3:    9:        printf ("%d is divisible by 3\n", i);
        9:   10:      if (i % 11 == 0)
    #####:   11:        printf ("%d is divisible by 11\n", i);
        9:   12:  }
        -:   13:
        1:   14:  return 0;
        -:   15:}

這個結果應該還是很容易理解的,最左邊一列描述了程式碼的覆蓋情況:

  • -: 表示該行程式碼被覆蓋了
  • 整數: 表示被執行的次數
  • #####:表示該行沒有被覆蓋

lcov

gcov得到的結果是本文形式的。但很多時候,我們可能希望得到更加美觀和便於瀏覽的結果。

此時就可以使用lcov了。

lcov是gcov工具的圖形前端。它收集多個原始檔的gcov資料,並生成描述覆蓋率的HTML頁面。生成的結果中會包含概述頁面,以方便瀏覽。

lcov支援我們前面提到的所有四種覆蓋率。

這個連結是lcov生成的報告樣例:lcov – code coverage report

安裝

lcov並非包含在GCC中,因此需要單獨安裝。

Mac系統

brew install lcov

Ubuntu系統

sudo apt install lcov

使用

對於lcov的使用方法可以通過下面這條命令查詢:

lcov --help

通過輸出我們可以看到,這個命令的引數有簡短(例如-c)和完整(例如--capture)兩種形式,其作用是一樣的。

這裡主要關注的下面這幾個引數:

  • -c 或者 --capture 指定從編譯產物中收集覆蓋率資訊。
  • -d DIR 或者 --directory DIR 指定編譯產物的路徑。
  • -e FILE PATTERN 或者 --extract FILE PATTERN 從指定的檔案中根據PATTERN過濾結果。
  • -o FILENAME 或者 --output-file FILENAME 指定覆蓋率輸出的檔名稱。

另外還有需要說明的是:

  • lcov預設不會開啟分支覆蓋率,因此我們還需要增加這個引數來開啟分支覆蓋率的計算:--rc lcov_branch_coverage=1
  • lcov輸出的仍然是一箇中間產物,我們還需要通過lcov軟體包提供的另外一個命令genhtml來生成最終需要的html格式的覆蓋率報告檔案。同樣的,為了開啟分支覆蓋率的計算,我們也要為這個命令增加--rc lcov_branch_coverage=1引數

最後,make_all.sh指令碼中包含的相關內容如下:

COVERAGE_FILE=coverage.info
REPORT_FOLDER=coverage_report
lcov --rc lcov_branch_coverage=1 -c -d build -o ${COVERAGE_FILE}_tmp
lcov --rc lcov_branch_coverage=1  -e ${COVERAGE_FILE}_tmp "*src*" -o ${COVERAGE_FILE}
genhtml --rc genhtml_branch_coverage=1 ${COVERAGE_FILE} -o ${REPORT_FOLDER}

這段程式碼從我們前面編譯的結果中收集覆蓋率結果,並將結果輸出到coverage.info_tmp檔案中。但是這裡面會包含非專案原始碼的覆蓋率(例如google test),所以我們又通過另外一條命令來指定”src”資料夾進行過濾。最後,通過genhtml得到html格式的報告。

可以通過瀏覽器檢視覆蓋率報告的結果,像下面這樣:

從這個報告的首頁,我們已經可以看到程式碼的語句覆蓋率(Lines),函式覆蓋率(Functions)以及分支覆蓋率(Branches)。而對於條件覆蓋率可以從詳細頁面中看到。如下圖所示:

在上面這張圖中,我們可以看到哪些程式碼被覆蓋了,哪些沒有。而對於對於if-else之類的語句,也能很清楚的看到條件覆蓋率的覆蓋情況。例如,對於程式碼的27行,只覆蓋了if成立時的情況,沒有覆蓋if不成立時的情況。

更進一步

本文中,我們已經完整的完成了從編寫單元測試到覆蓋率生成的整個過程。

但實際上,對於這項工作我們還可以做得更多一些。例如下面這兩項工作:

使用Google Mock

Google Mock是Google Test的擴充套件,用於編寫和使用C++ Mock類。

在物件導向的程式設計中,Mock物件是模擬物件,它們以預先設定的方式模模擬實物件的行為。程式設計師通常會建立一個Mock物件來測試某個其他物件的行為,這與汽車設計師使用碰撞測試假人來模擬人類在車輛碰撞中的動態行為的方式非常相似。

關於Google Mock的更多內容請參見:Google Mock的文件

持續整合

對於演示專案的覆蓋率報告是通過手動執行指令碼檔案生成的。

而在實際的專案中,可能同時有很多人在開發同一個專案,每一天專案中都會有很多次的程式碼提交。我們不可能每次手動的執行編譯和生成覆蓋率報告結果。這時就可以藉助一些持續整合的工具,定時自動地完成專案的編譯,測試和覆蓋率報告結果的生成工作。

可以在持續整合工具中包含我們編寫的指令碼,然後將覆蓋率報告的html結果釋出到某個Web伺服器上,最後再以郵件的形式將連結地址傳送給大家。

這樣就可以很方便的讓整個團隊看到所有模組的測試結果和覆蓋率情況了。

完成了一整套這樣的工作,可以非常好的提升整個專案的質量。

參考文獻與推薦讀物

相關文章