boxuegu

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

對代碼進行單元測試是幾乎每個軟件工程師都要完成的工作。本文以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服務器上,最后再以郵件的形式將鏈接地址發送給大家。

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

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

參考文獻與推薦讀物

發表我的評論

取消評論
表情 插代碼

Hi,您需要填寫昵稱和郵箱!

  • 必填項
  • 必填項
22选5今晚开奖公告