軟件工程師必備的技能 | 單元測試
關(guān)注、星標(biāo)公眾號,直達(dá)精彩內(nèi)容
來源:保羅的酒吧
前言
測試是軟件開發(fā)過程中一個必須的環(huán)節(jié),測試確保軟件的質(zhì)量符合預(yù)期。
對于工程師自己來說,單元測試也是提升自信心的一種方式。
直接交付沒有經(jīng)過測試的代碼是不太好的,因為這很可能會浪費整個團(tuán)隊的時間,在一些原本早期就可以發(fā)現(xiàn)的問題上。而單元測試,就是發(fā)現(xiàn)問題一個很重要的環(huán)節(jié)。
本文以C++語言為基礎(chǔ),講解如何進(jìn)行單元測試并生成測試報告。
在工具上,我們會使用下面這些:
GCC CMake Google Test gcov lcov
演示項目
為了方便本文的講解,我專門編寫了一個演示項目作為代碼示例。
演示項目的源碼可以在我的Github上獲取:paulQuei/gtest-and-coverage[1]。
你可以通過下面幾條命令下載和運行這個項目:
git?clone?https://github.com/paulQuei/gtest-and-coverage.git
cd?gtest-and-coverage
./make_all.sh
要運行這個項目,你的機器上必須先安裝好前面提到的工具。如果沒有,請閱讀下文以了解如何安裝它們。
如果你使用的是Mac系統(tǒng),下文假設(shè)你的系統(tǒng)上已經(jīng)安裝了brew[2]包管理器。如果沒有,請通過下面這條命令安裝它:
/usr/bin/ruby?-e?"$(curl?-fsSL?https://raw.githubusercontent.com/Homebrew/install/master/install)"
項目結(jié)構(gòu)
演示項目的目錄結(jié)構(gòu)如下:
.
├──?CMakeLists.txt
├──?googletest-release-1.8.1.zip
├──?include
│???└──?utility.h
├──?make_all.sh
├──?src
│???└──?utility.cpp
└──?test
????└──?unit_test.cpp
這里演示的內(nèi)容是:以測試一個我們要提供的軟件庫為例,講解如何對其進(jìn)行單元測試并生成測試報告。
為了簡單起見,這個軟件庫只有一個頭文件和一個實現(xiàn)文件。
當(dāng)然,在實際上的項目中,一個軟件庫會通常包含更多的文件,不過這并不影響我們要說明的問題。
演示項目中的文件說明如下:
| 文件名稱 | 說明 |
|---|---|
| make_all.sh | 入口文件,會執(zhí)行:編譯,測試和生成報告等所有工作 |
| CMakeLists.txt | 項目的編譯文件 |
| googletest-release-1.8.1.zip | google test源碼壓縮包 |
| utility.h | 待測試的軟件庫的頭文件 |
| utility.cpp | 待測試的軟件庫的實現(xiàn)文件 |
| unit_test.cpp | 對軟件庫進(jìn)行單元測試的代碼 |
測試環(huán)境
演示項目在如下的環(huán)境中測試過。
MacBook Pro 操作系統(tǒng):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 操作系統(tǒng):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
關(guān)于CMake
為了簡化編譯的過程,這里使用CMake作為編譯工具。關(guān)于CMake的更多內(nèi)容請參見請官網(wǎng):https://cmake.org[3]。
關(guān)于如何安裝CMake請參見這里:Installing CMake[4]。
另外,你也可以通過一條簡單的命令來安裝CMake:
Mac系統(tǒng):
brew?install?cmake
Ubuntu系統(tǒng)
sudo?apt?install?cmake
由于篇幅所限,這里不打算對CMake做過多講解,讀者可以訪問其官網(wǎng)或者在網(wǎng)絡(luò)上搜尋其使用方法。
這里僅僅對演示項目中用到的內(nèi)容做一下說明。演示項目中的CMakeLists.txt內(nèi)容如下:
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)?⑧
以編號為序,這段代碼說明如下:
設(shè)置使用的CMake最低版本號為2.8.11。 指定項目的名稱為”utility”,項目名稱可以通過 ${CMAKE_PROJECT_NAME}進(jìn)行引用。指定使用C++11。 這里的三行是編譯google test,并將其頭文件路徑和編譯結(jié)果的庫文件路徑添加到環(huán)境中。因為后面在編譯單元測試代碼的時候需要用到。 添加 --coverage到編譯器flag中,這個參數(shù)是很重要的,因為這是生成代碼覆蓋率所必須的。關(guān)于該編譯參數(shù)的說明見這里:Program Instrumentation Options[5]。編譯我們的軟件庫,這里將生成 libutility_lib.a庫文件。編譯單元測試的可執(zhí)行文件。 單元測試的可執(zhí)行文件需要鏈接我們開發(fā)的軟件庫以及google test的庫。另外,google test依賴了pthread,所以這個庫也需要。
關(guān)于測試
軟件測試有很多種分類方式。從測試的級別來說,可以大致分為:
單元測試 集成測試 系統(tǒng)測試
這其中,單元測試是最局部和具體的。它通常需要對代碼中的每一個類和函數(shù)進(jìn)行測試。
單元測試通常由開發(fā)者完成,需要針對代碼邏輯進(jìn)行測試。所以它是一種白盒測試[6]。
關(guān)于xUnit
xUnit是幾種單元測試框架的總稱。最早源于Smalltalk的單元測試框架SUnit,它是由Kent Beck[7]開發(fā)的。
除此之外,還有針對Java語言的JUnit,針對R語言的RUnit。
在本文中,我們使用Google開發(fā)的xUnit框架:Google Test。
Google Test介紹
Google Test的項目主頁在Github上:Github: Google Test[8]。
實際上,這個項目中同時包含了GoogleTest和GoogleMock兩個工具,本文中我們只會講解第一個。
Google Test支持的操作系統(tǒng)包含下面這些:
Linux Mac OS X Windows Cygwin MinGW Windows Mobile Symbian
目前有很多的項目都使用了Google Test,例如下面這些:
Chromium projects[9] LLVM[10] Protocol Buffers[11] OpenCV[12] tiny-dnn[13]
編譯Google Test
關(guān)于如何編譯Google Test請參見這里:Generic Build Instructions[14]。
為了便于讀者使用,我們在演示項目中包含了Google Test 1.8.1的源碼壓縮包。并且在CMake文件中,同時包含了Google Test的編譯和使用配置工作。
如果使用演示項目,讀者將不需要手動處理Google Test的編譯和安裝工作。
使用Google Test
演示項目代碼說明
為了便于下文說明,演示項目中包含了幾個簡單的函數(shù)。
可以從這里下載源碼以便查看其中的內(nèi)容:paulQuei/gtest-and-coverage[15]。
演示項目中的軟件庫包含一個頭文件和一個實現(xiàn)文件。頭文件內(nèi)容如下:
//?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
這個頭文件說明如下:
頭文件包含了三個函數(shù),前兩個用來做
int和double類型的四則運算。最后一個判斷輸入的年份是否是閏年。當(dāng)然,在實際的工程中,前兩個函數(shù)合并實現(xiàn)為一個泛型函數(shù)更為合適。但這里之所以分成兩個,是為了查看代碼覆蓋率所用。
關(guān)于
閏年
說明如下:
能被4整除但不能被100整除的年份為普通閏年。 能被100整除,也同時能被400整除的為世紀(jì)閏年。 其他都不是閏年。 例如:1997年不是閏年,2000年是閏年,2016年是閏年,2100不是閏年。
這三個函數(shù)的實現(xiàn)也不復(fù)雜:
//?utility.cpp
#include?"utility.h"
#include?
#include?
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"?<????????????return?std::numeric_limits::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"?<????????????return?std::numeric_limits::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;
}
開始測試
接下來我們就要對上面這些代碼進(jìn)行測試了。
要使用Google Test進(jìn)行測試,整個過程也非常的簡單。只要進(jìn)行下面三部:
創(chuàng)建一個測試用的cpp文件 為上面這個測試用的cpp文件編寫Makefile(或者CMake文件)。同時鏈接: 待測試的軟件庫 gtest庫gtest_main庫pthread庫(Google Test使用了這個庫所以需要)編寫測試代碼,編譯并運行測試的可執(zhí)行程序。
并且,測試代碼寫起來也非常的簡單,像下面這樣:
#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);
}
是的,就是這么簡單的幾行代碼,就對整數(shù)四則運算的函數(shù)進(jìn)行了測試。
TEST后面所包含的內(nèi)容稱之為一條case,通常我們會為每個函數(shù)創(chuàng)建一個獨立的case來進(jìn)行測試。一個測試文件中可以包含很多條case。同時,一條case中會包含很多的判斷(例如EXPECT_EQ...)。
注意:在做單元測試的時候,保證每條case是獨立的,case之間沒有前后依賴關(guān)系是非常重要的。
當(dāng)然,測試代碼中包含的判斷的多少將影響測試結(jié)果的覆蓋率。所以在編寫每條case的時候,我們需要仔細(xì)思考待測試函數(shù)的可能性,有針對性的進(jìn)行測試代碼的編寫。
這段代碼應(yīng)該很好理解,它分別進(jìn)行了下面這些測試:
1 + 1 = 2 2 - 1 = 1 3 x 3 = 9 10 / 2 = 5 10 / 0 > 999999999
你可能會發(fā)現(xiàn),這段代碼里面甚至沒有main函數(shù)。它也依然可以生成一個可執(zhí)行文件。這就是我們鏈接gtest_main所起的作用。
在實際的測試過程中,你想判斷的情況可能不止上面這么簡單。下面我們來看看Google Test還能做哪些測試。
測試判斷
Google Test對于結(jié)果的判斷,有兩種形式:
ASSERT_*:這類判斷是Fatal的。一旦這個判斷出錯,則直接從測試函數(shù)中返回,不會再繼續(xù)后面的測試。EXPECT_*:這類判斷是Nonfatal的。它的效果是,如果某個判斷出錯,則輸出一個錯誤信息,但是接下來仍然會繼續(xù)執(zhí)行后面的測試。
可以進(jìn)行的判斷方法主要有下面這些:
布爾判斷
| Fatal | Nonfatal | 說明 |
|---|---|---|
ASSERT_TRUE(condition) | EXPECT_TRUE(condition) | 判斷 condition 為 true |
ASSERT_FALSE(condition) | EXPECT_FALSE(condition) | 判斷 condition 為 false |
二進(jìn)制判斷
| Fatal | Nonfatal | 說明 |
|---|---|---|
ASSERT_EQ(expected, actual) | EXPECT_EQ(expected, actual) | 判斷兩個數(shù)值相等 |
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不相同 |
浮點數(shù)判斷
| Fatal | Nonfatal | 說明 |
|---|---|---|
ASSERT_FLOAT_EQ(exp, act) | EXPECT_FLOAT_EQ(exp, act) | 兩個float數(shù)值相等 |
ASSERT_DOUBLE_EQ(exp, act) | EXPECT_DOUBLE_EQ(exp, act) | 兩個double數(shù)值相等 |
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使用相同的測試數(shù)據(jù)。例如,我們的演示項目中,每條case都會需要創(chuàng)建Utility對象。
有些時候,我們要測試的對象可能很大,或者創(chuàng)建的過程非常的慢。這時,如果每條case反復(fù)創(chuàng)建這個對象就顯得浪費資源和時間了。此時,我們可以使用Test Fixture來共享測試的對象。
要使用Test Fixture我們需要創(chuàng)建一個類繼承自Google Test中的::testing::Test。
還記得我們前面說過,我們要盡可能的保證每條測試case是互相獨立的。但是,當(dāng)我們在多條case之間共享有狀態(tài)的對象時,就可能出現(xiàn)問題。
例如,我們要測試的是一個隊列數(shù)據(jù)結(jié)構(gòu)。有的case會向隊列中添加數(shù)據(jù),有的case會從隊列中刪除數(shù)據(jù)。case執(zhí)行的順序不同,則會導(dǎo)致Queue中的數(shù)據(jù)不一樣,這就可能會影響case的結(jié)果。
為了保證每條case是獨立的,我們可以在每條case的執(zhí)行前后分別完成準(zhǔn)備工作和清理工作,例如,準(zhǔn)備工作是向隊列中添加三個數(shù)據(jù),而清理工作是將隊列置空。
這兩項重復(fù)性的工作可以由::testing::Test類中的Setup和TearDown兩個函數(shù)來完成。
我們演示用的Utility類是無狀態(tài)的,所以不存在這個問題。因此,這里我們僅僅在
Setup和TearDown兩個函數(shù)中打印了一句日志。
使用Test Fixture后,我們的代碼如下所示:
class?UtilityTest?:?public?::testing::Test?{
protected:
void?SetUp()?override?{
????cout?<"SetUp?runs?before?each?case."?<}
void?TearDown()?override?{
????cout?<"TearDown?runs?after?each?case."?<}
Utility?util;
};
這段代碼說明如下:
Setup和TearDown兩個函數(shù)標(biāo)記了override以確認(rèn)是重寫父類中的方法,這是C++11新增的語法。我們的Utility類是無狀態(tài)的,因此 Setup和TearDown兩個函數(shù)中我們僅僅打印日志以便確認(rèn)。將 Utility util設(shè)置為protected以便測試代碼中可以訪問。(從實現(xiàn)上來說,測試case的代碼是從這個類繼承的子類,當(dāng)然,這個關(guān)系是由Google Test工具完成的)。
要使用這里定義的Test Fixture,測試case的代碼需要將開頭的TEST變更為TEST_F。
這里
_F就是Fixture的意思。
使用TEST_F的case的代碼結(jié)構(gòu)如下:
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方法故意只進(jìn)行了一種情況的測試。這是為了最終生成代碼覆蓋率所用。
運行測試
編寫完單元測試之后,再執(zhí)行編譯工作便可以運行測試程序以查看測試結(jié)果了。
測試的結(jié)果像下面這樣:

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

如果只想有選擇性的跑部分case,可以通過--gtest_filter參數(shù)進(jìn)行過濾,這個參數(shù)支持*通配符。
像下面這樣:
$?./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.
如果想要更好的理解這些內(nèi)容。請讀者下載演示項目之后完成下面這些操作:
在 utility.h和utility.cpp中添加一些新的函數(shù)。在新添加的函數(shù)中故意包含一個bug。 為新添加的函數(shù)編寫測試代碼,并測試出函數(shù)中包含的bug。
代碼覆蓋率
在進(jìn)行單元測試之后,我們當(dāng)然希望能夠直觀的看到我們的測試都覆蓋了哪些代碼。
理論上,如果我們能做到100%的覆蓋我們的所有代碼,則可以說我們的代碼是沒有Bug的。
但實際上,100%的覆蓋率要比想象得困難。對于大型項目來說,能夠達(dá)到80% ~ 90%的語句覆蓋率就已經(jīng)很不錯了。
覆蓋率的類型
先來看一下,當(dāng)我們在說“覆蓋率”的時候我們到底是指的什么。
實際上,代碼覆蓋率有下面幾種類型:
函數(shù)覆蓋率:描述有多少比例的函數(shù)經(jīng)過了測試。 語句覆蓋率:描述有多少比例的語句經(jīng)過了測試。 分支覆蓋率:描述有多少比例的分支(例如: if-else,case語句)經(jīng)過了測試。條件覆蓋率:描述有多少比例的可能性經(jīng)過了測試。
這其中,函數(shù)覆蓋率最為簡單,就不做說明了。
語句覆蓋率是我們最常用的。因為它很直觀的對應(yīng)到我們寫的每一行代碼。
而分支覆蓋率和條件覆蓋率可能不太好理解,需要做一下說明。
以下面這個C語言函數(shù)為例:
int?foo?(int?x,?int?y)?{
????int?z?=?0;
????if?((x?>?0)?&&?(y?>?0))?{
????????z?=?x;
????}
????return?z;
}
這個函數(shù)中包含了一個if語句,因此if語句成立或者不成立構(gòu)成了兩個分支。所以如果只測試了if成立或者不成立的其中之一,其分支覆蓋率只有 1/2 = 50%。
而條件覆蓋率需要考慮每種可能性的情況。
對于if (a && b)這樣的語句,其一共有四種可能的情況:
a = true, b = true a = true, b = false a = false, b = true a = false, b = false
請讀者思考一下:對于三層
if嵌套,每個if語句包含三個布爾變量的代碼,如果要做到100%的條件覆蓋率,一共要測試多少種情況。很顯示,在編寫代碼的時候,盡可能的減少代碼嵌套,并且簡化邏輯運算是一項很好的習(xí)慣。
便于測試的代碼也是便于理解和維護(hù)的,反之則反。
有了這些概念之后,我們就可以看懂測試報告中的覆蓋率了。
gcov
gcov[16]是由GCC工具鏈提供的代碼覆蓋率生成工具。它可以很方便的和GCC編譯器配合使用。
通常情況下,安裝好GCC工具鏈,也就同時包含了gcov命令行工具。
對于代碼覆蓋率工具所做的工作,可以簡單的理解為:標(biāo)記一次運行過程中,哪些代碼被執(zhí)行過,哪些沒有執(zhí)行。
因此,即便沒有測試代碼,直接運行編譯產(chǎn)物也可以得到代碼的覆蓋率。只不過,通常情況下這樣得到的覆蓋率較低罷了。
使用
這里我們以另外一個簡單的代碼示例來說明gcov的使用。
這段代碼如下:
//?test.c
#include?
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函數(shù)的c語言代碼,main函數(shù)的邏輯也很簡單。
我們將這段代碼保存到文件test.c。
要通過gcov生成代碼覆蓋率。需要在編譯時,增加參數(shù)--coverage:
gcc?--coverage?test.c
--coverage等同于編譯參數(shù)-fprofile-arcs -ftest-coverage以及在鏈接時增加-lgcov。
此處的編譯結(jié)果除了得到可執(zhí)行文件a.out,還會得到一個test.gcno文件。該文件包含了代碼與行號的信息,在生成覆蓋率時會需要這個文件。
很顯然,帶
--coverage編譯參數(shù)得到的編譯產(chǎn)物會比不帶這個參數(shù)要包含更多的信息,因此編譯產(chǎn)物會更大。所以這個參數(shù)只適合在需要生成代碼覆蓋率的時候才加上。對于正式發(fā)布的編譯產(chǎn)物,不應(yīng)該添加這個編譯參數(shù)。
當(dāng)我們執(zhí)行上面編譯出來的可執(zhí)行文件a.out時,我們還會得到每個源碼文件對應(yīng)的gcda后綴的文件。由test.gcno和test.gcda這兩個文件,便可以得到代碼的覆蓋率結(jié)果了。
關(guān)于這兩個文件的說明請參見這里:Brief description of gcov data files[17]
只需要通過gcov指定源文件的名稱(不需要帶后綴):gcov test,便可以得到包含覆蓋率的結(jié)果文件 test.c.gcov了。
回顧一下我們剛剛的操作內(nèi)容:
$?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一下,查看覆蓋率的結(jié)果:
????????-:????0:Source:test.c
????????-:????0:Graph:test.gcno
????????-:????0:Data:test.gcda
????????-:????0:Runs:1
????????-:????0:Programs:1
????????-:????1://?test.c
????????-:????2:
????????-:????3:#include?
????????-:????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:}
這個結(jié)果應(yīng)該還是很容易理解的,最左邊一列描述了代碼的覆蓋情況:
-:表示該行代碼被覆蓋了整數(shù):表示被執(zhí)行的次數(shù) #####:表示該行沒有被覆蓋
lcov
gcov[18]得到的結(jié)果是本文形式的。但很多時候,我們可能希望得到更加美觀和便于瀏覽的結(jié)果。
此時就可以使用lcov[19]了。
lcov是gcov工具的圖形前端。它收集多個源文件的gcov數(shù)據(jù),并生成描述覆蓋率的HTML頁面。生成的結(jié)果中會包含概述頁面,以方便瀏覽。
lcov支持我們前面提到的所有四種覆蓋率。
這個鏈接是lcov生成的報告樣例:lcov - code coverage report[20]。
安裝
lcov并非包含在GCC中,因此需要單獨安裝。
Mac系統(tǒng)
brew?install?lcov
Ubuntu系統(tǒng)
sudo?apt?install?lcov
使用
對于lcov的使用方法可以通過下面這條命令查詢:
lcov?--help
通過輸出我們可以看到,這個命令的參數(shù)有簡短(例如-c)和完整(例如--capture)兩種形式,其作用是一樣的。
這里主要關(guān)注的下面這幾個參數(shù):
-c或者--capture指定從編譯產(chǎn)物中收集覆蓋率信息。-d DIR或者--directory DIR指定編譯產(chǎn)物的路徑。-e FILE PATTERN或者--extract FILE PATTERN從指定的文件中根據(jù)PATTERN過濾結(jié)果。-o FILENAME或者--output-file FILENAME指定覆蓋率輸出的文件名稱。
另外還有需要說明的是:
lcov默認(rèn)不會打開分支覆蓋率,因此我們還需要增加這個參數(shù)來打開分支覆蓋率的計算: --rc lcov_branch_coverage=1lcov輸出的仍然是一個中間產(chǎn)物,我們還需要通過lcov軟件包提供的另外一個命令 genhtml來生成最終需要的html格式的覆蓋率報告文件。同樣的,為了打開分支覆蓋率的計算,我們也要為這個命令增加--rc lcov_branch_coverage=1參數(shù)
最后,make_all.sh腳本中包含的相關(guān)內(nèi)容如下:
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}
這段代碼從我們前面編譯的結(jié)果中收集覆蓋率結(jié)果,并將結(jié)果輸出到coverage.info_tmp文件中。但是這里面會包含非項目源碼的覆蓋率(例如google test),所以我們又通過另外一條命令來指定”src”文件夾進(jìn)行過濾。最后,通過genhtml得到html格式的報告。
可以通過瀏覽器查看覆蓋率報告的結(jié)果,像下面這樣:

從這個報告的首頁,我們已經(jīng)可以看到代碼的語句覆蓋率(Lines),函數(shù)覆蓋率(Functions)以及分支覆蓋率(Branches)。而對于條件覆蓋率可以從詳細(xì)頁面中看到。如下圖所示:

在上面這張圖中,我們可以看到哪些代碼被覆蓋了,哪些沒有。而對于對于if-else之類的語句,也能很清楚的看到條件覆蓋率的覆蓋情況。例如,對于代碼的27行,只覆蓋了if成立時的情況,沒有覆蓋if不成立時的情況。
更進(jìn)一步
本文中,我們已經(jīng)完整的完成了從編寫單元測試到覆蓋率生成的整個過程。
但實際上,對于這項工作我們還可以做得更多一些。例如下面這兩項工作:
使用Google Mock
Google Mock是Google Test的擴展,用于編寫和使用C++ Mock類。
在面向?qū)ο蟮木幊讨校?span style="color: #1e6bb8;font-weight: bold;">Mock對象[21]是模擬對象,它們以預(yù)先設(shè)定的方式模仿真實對象的行為。程序員通常會創(chuàng)建一個Mock對象來測試某個其他對象的行為,這與汽車設(shè)計師使用碰撞測試假人來模擬人類在車輛碰撞中的動態(tài)行為的方式非常相似。
關(guān)于Google Mock的更多內(nèi)容請參見:Google Mock的文檔[22]。
持續(xù)集成
對于演示項目的覆蓋率報告是通過手動執(zhí)行腳本文件生成的。
而在實際的項目中,可能同時有很多人在開發(fā)同一個項目,每一天項目中都會有很多次的代碼提交。我們不可能每次手動的執(zhí)行編譯和生成覆蓋率報告結(jié)果。這時就可以借助一些持續(xù)集成[23]的工具,定時自動地完成項目的編譯,測試和覆蓋率報告結(jié)果的生成工作。
可以在持續(xù)集成工具中包含我們編寫的腳本,然后將覆蓋率報告的html結(jié)果發(fā)布到某個Web服務(wù)器上,最后再以郵件的形式將鏈接地址發(fā)送給大家。
這樣就可以很方便的讓整個團(tuán)隊看到所有模塊的測試結(jié)果和覆蓋率情況了。
完成了一整套這樣的工作,可以非常好的提升整個項目的質(zhì)量。
參考文獻(xiàn)與推薦讀物
CMake Cookbook Google Test googletest Generic Build Instructions Googletest Primer A quick introduction to the Google C++ Testing Framework Google Test Quick Reference Coverage testing with gcov Generating Code Coverage with Qt 5 and GCOV on Mac OS Wikipedia: xUnit Wikipedia: Code Coverage lcov - the LTP GCOV extension gcov—a Test Coverage Program
參考資料
paulQuei/gtest-and-coverage: https://github.com/paulQuei/gtest-and-coverage
[2]brew: https://brew.sh/
[3]https://cmake.org: https://cmake.org/
[4]Installing CMake: https://cmake.org/install/
[5]Program Instrumentation Options: https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html#Instrumentation-Options
[6]白盒測試: https://en.wikipedia.org/wiki/White-box_testing
[7]Kent Beck: https://en.wikipedia.org/wiki/Kent_Beck
[8]Github: Google Test: https://github.com/google/googletest
[9]Chromium projects: http://www.chromium.org/
[10]LLVM: http://llvm.org/
[11]Protocol Buffers: https://github.com/google/protobuf
[12]OpenCV: http://opencv.org/
[13]tiny-dnn: https://github.com/tiny-dnn/tiny-dnn
[14]Generic Build Instructions: https://github.com/google/googletest/tree/master/googletest
[15]paulQuei/gtest-and-coverage: https://github.com/paulQuei/gtest-and-coverage
[16]gcov: https://gcc.gnu.org/onlinedocs/gcc/gcov.html
[17]Brief description of gcov data files: https://gcc.gnu.org/onlinedocs/gcc-4.1.2/gcc/gcov-Data-Files.html
[18]gcov: https://gcc.gnu.org/onlinedocs/gcc/gcov.html
[19]lcov: http://ltp.sourceforge.net/coverage/lcov.php
[20]lcov - code coverage report: http://ltp.sourceforge.net/coverage/lcov/output/index.html
[21]Mock對象: https://en.wikipedia.org/wiki/Mock_object
[22]Google Mock的文檔: https://github.com/google/googletest/blob/master/googlemock/README.md
[23]持續(xù)集成: https://en.wikipedia.org/wiki/Continuous_integration
[24]《C++語言的單元測試與代碼覆蓋率》: https://paul.pub/gtest-and-coverage/
作者:保羅的酒吧
https://paul.pub/gtest-and-coverage/
???????????????? ?END ????????????????? 關(guān)注我的微信公眾號,回復(fù)“加群”按規(guī)則加入技術(shù)交流群。
歡迎關(guān)注我的視頻號:
點擊“閱讀原文”查看更多分享,歡迎點分享、收藏、點贊、在看。
