【C++】GoogleTest進階之gMock

gMock是什麼

當我們去寫測試時,有些測試對象很單純簡單,例如一個函數完全不依賴於其他的對象,那麼就只需要驗證其輸入輸出是否符合預期即可。

但是如果測試對象很複雜或者依賴於其他的對象呢?例如一個函數中需要訪問資料庫或者消息隊列,那麼要想按照之前的思路去測試就必須創建好資料庫和消息隊列的客戶端實例,然後放在該函數內使用。很多時候這種操作是很麻煩的,此時Mock Object就能幫助我們解決這個問題。一個Mock Object實現與真實對象相同的介面,它可以替代真實對象去使用,而我們要做的就是制定好該Mock Object的行為(調用多少次、參數、返回值等等)

參考文檔:
gMock官方文檔

安裝gMock

gMock現在與gTest是組合使用的關係,因此在安裝gTest時默認就會安裝gMock,具體的安裝方式見github上的官方說明
//github.com/google/googletest/tree/main/googletest

使用gMock的基本思路

  • 首先,使用一些簡單的gMock宏來描述想要模擬的介面,它們會實現你的mock類
  • 然後,創建一些mock object然後使用gMock提供的語法指定好它們的行為
  • 最後,運行需要使用這些mock object的程式碼,gMock會在mock object的行為不符合預期的時候發現並指出

gMock快速入門

假設我們在做一個用戶的賬戶系統,一個用戶會有一個賬戶,用戶提供介面salary,賬戶提供介面add和getAccount,在用戶的salary內會調用賬戶的add和getAccount介面
特別注意:此處的賬戶就是我們要mock的對象,它是用戶的一個依賴。要想模擬它,它內部必須有虛析構函數,各個介面也建議是虛函數乃至純虛函數。這裡我的理解是,實際上mock object是對真實對象的代理/替換,在代理模式中比較常見的一種做法就是代理類和被代理類繼承自同一個父類/介面

基本樣例

User

#ifndef USER_H
#define USER_H
#include <iostream>
#include "account.h"

class User{
public:
    /// @brief User類的對象依賴於Account的對象
    /// @param account Account實例,被User所依賴 
    User(Account *account){
        account_ = account;
    }
    /// @brief 模擬發工資的場景
    /// @param money 發的錢數
    /// @return 賬戶餘額
    int salary(int money){
        account_->add(money);
        return account_->getAccount();
    }

private:
    Account *account_;
};

#endif //USER_H

Account

#ifndef ACCOUNT_H
#define ACCOUNT_H

class Account
{
public:
    virtual ~Account() {}
    virtual void add(int money) = 0;
    virtual int getAccount() = 0;
};

#endif //ACCOUNT_H

mock類編寫

我們要mock的是Account的一個對象,所以書寫mock類實現Account介面

#ifndef MOCK_ACCOUNT_H
#define MOCK_ACCOUNT_H
#include "account.h"
#include <gmock/gmock.h>

class MockAccount : public Account
{
public:
    MOCK_METHOD(void, add, (int money), (override));
    MOCK_METHOD(int, getAccount, (), (override));
};

#endif // MOCK_ACCOUNT_H

其中的關鍵部分在於MOCK_METHOD,很多老的教程中會使用MOCK_METHOD0、MOCK_METHOD1…這些宏,它們分別代表0參數、1參數、2參數的介面。在新的官方教程中沒有這種寫法,統一都是MOCK_METHOD,內部有四個參數

  • 介面返回值類型
  • 介面名
  • 介面形參列表
  • 為生成的mock object的方法添加關鍵字(如果是override這個參數其實可以不寫,但是如果介面是const的,就必須寫const關鍵字了)

mock類放在哪

按照google的建議,除非整個介面就是你自己持有的,否則mock類不要放在xx_test下,因為一旦Account介面被它的所有者改變,MockAccount也必須改變才能繼續使用
一般來說,我們不應該mock不是自己持有的介面。如果真的需要mock不是自己持有的,mock對象的目錄或者testing的子目錄下創建一個.h文件和一個 cc_library with testonly=true,這樣一來,每個人都可以使用同一個地方定義的mock類

mock的使用

創建好mock類之後,要使用它一般分以下幾步

  • 創建Mock Object
  • 規定Mock Object的預期行為
  • 使用Mock Object測試業務程式碼,業務程式碼部分可以使用gTest的各種斷言
  • 一旦Mock Object的方法被調用的情況與前面規定的預期行為不符,測試就會不通過(在Mock Object被析構時也會再次檢查)
    其中比較核心程式碼有兩部分:規定Mock Object的預期行為和業務程式碼測試,前者將會在下面詳細展開,後者可以參考Google Test那篇文章
    google test入門指南

樣例

user_test.cc文件

#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "user.h"
#include "mock_account.h"

using ::testing::AtLeast;
using ::testing::Return;

TEST(UserTest, SalaryIsOK)
{
    MockAccount mAccount;//創建Mock Object
    EXPECT_CALL(mAccount, add(100)).Times(AtLeast(1));
    EXPECT_CALL(mAccount, getAccount()).Times(AtLeast(1));//規範Mock Object的行為,此處是說該mock對象的getAccount()方法至少被調用1次
    User user(&mAccount);//將Mock Obejct注入到user中使用(依賴注入)
    int res = user.salary(100);//測試User業務邏輯
    ASSERT_GE(res, 0);//gTest的斷言,res大於等於0則通過
}

編譯運行

這裡我使用CMake來做構建,注意gTest和gMock需要C++14及以上,在鏈接時直接鏈接gtest_main,這樣就不需要自己寫main方法了

CMakeLists.txt

cmake_minimum_required(VERSION 3.14)

project(user LANGUAGES C CXX)

set(CMAKE_CXX_STANDARD 14)

enable_testing()

find_package(GTest REQUIRED)

add_executable(test_user "${PROJECT_SOURCE_DIR}/user_test.cc")
target_link_libraries(test_user GTest::gtest_main gmock)

include(GoogleTest)
gtest_discover_tests(test_user)

運行結果

[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from UserTest
[ RUN      ] UserTest.SalaryIsOK
[       OK ] UserTest.SalaryIsOK (0 ms)
[----------] 1 test from UserTest (0 ms total)

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

測試通過了

設置預期行為

使用Mock最核心的點就在於給一個Mock Object規定好預期行為。這部分也是我們需要斟酌的地方。預期行為是設置的嚴格一點還是松一點全看需求。

一般語法

在gMock中使用EXPECT_CALL()這個斷言宏去設置一個Mock Object的預期行為
EXPECT_CALL(mock_object, mock_method(params))...
其中有兩個核心參數,第一個是mock_object,第二個是mock_object中的方法,如果有參數同時要把參數傳進去,注意,不同參數的mock_method可以認為是不同的預期行為
…部分可以填寫很多鏈式調用的邏輯來指定該對象該方法的調用運行情況

using ::testing::Return;
...
EXPECT_CALL(mock_object, mock_method(params)).Times(5).WillOnce(Return(100)).WillOnce(Return(150)).WillRepeatedly(Return(200));

在以上的栗子中,為該對象的該方法指定了四個預期行為:
首先它會被調用5次,第一次返回100,第二次返回150,之後的每次都返回200

關於方法的參數params

不確定參數值

很多時候我們不想讓參數值變得固定,這個時候可以使用::testing::_來表示任意參數值

using ::testing::_;
...
EXPECT_CALL(mock_object, mock_method(_))...

如果參數有多個,而且全部都是不確定參數值,我們可以這樣寫:
EXPECT_CALL(mock_object, mock_method)...

參數值需要滿足某種條件

對於傳入確切參數的情況,相當於是使用Eq(100),以下的前兩個寫法是等價的

EXPECT_CALL(mock_object, mock_method(100))...
EXPECT_CALL(mock_object, mock_method(Eq(100)))...
EXPECT_CALL(mock_object, mock_method(Ge(50)))...//參數大於等於50的所有情況

那麼除了Eq之外,gMock還提供了其他的一些,可以自行探索

預期調用的次數

在預期行為部分,我們可以手動寫上Times(3)來指定它需要被調用3次,多或少都會導致測試不通過。
AtLeast()是在次數預期里比較常用的一個方法,如果是Times(3),那方法必須調用且只能調用3次,但是如果是Times(AtLeast(3)),那麼就是至少調用3次的意思了。
我們也可以省略Times(),此時gMock會默認根據我們寫的鏈式調用情況添加Times(),具體規則見下面的部分。

關於次數的預期,核心的方法有兩個,分別是WillOnce()和WillRepeatedly(),前者表示調用一次,後者表示重複調用,它們可以組合使用,使用的具體規則如下:

  • 如果沒有WillOnce和WillRepeatedly(),則默認添加Times(1)
  • 如果有n個WillOnce,沒有WillRepeatedly(),則默認添加Times(n)
  • 如果有n個WillOnce,有一個WillRepeatedly(),則默認添加Times(AtLeast(n)),這意味著WillRepeatedly可以匹配調用0次的情況

預期發生的行為

一個mock object的所有方法中都沒有具體的實現體,那麼它的返回值情況是怎麼樣設定預期的呢?
默認情況下我們如果不設定返回值預期,也會有默認的返回值(只是我們不使用而已),bool會返回false,int等等的會返回0.
如果需要它有指定的預期返回值,我們可以在次數預期中加入返回值預期

using ::testing::Return;
...
EXPECT_CALL(mock_object, mock_method(params))
.Times(5)
.WillOnce(Return(100))
.WillOnce(Return(150))
.WillRepeatedly(Return(200));

在以上的栗子中,為該對象的該方法指定了四個預期行為:
首先它會被調用5次,第一次返回100,第二次返回150,之後的每次都返回200
如果去掉Times(5),那就是第一次返回100,第二次返回150,之後每次都返回200,調用次數不少於2次(WillRepeatedly可以調用0次)

預期發生順序

默認情況下,我們設定好一個mock對象的多個預期行為時,是不關心它們的發生順序的。例如以下程式碼中,先調用PenDown()或者先調用了Forward(100)都是無所謂的,都能通過測試:

EXPECT_CALL(turtle, PenDown());
EXPECT_CALL(turtle, Forward(100));

那麼如果我們想指定預期發生順序,我們需要創建InSequence對象,該對象創建處的程式碼塊(scope)內的所有預期行為都必須按照聲明順序發生。

using ::testing::InSequence;
...
TEST(FooTest, DrawsLineSegment) {
  ...
  {
    InSequence seq;

    EXPECT_CALL(turtle, PenDown());
    EXPECT_CALL(turtle, Forward(100));
    EXPECT_CALL(turtle, PenUp());
  }
  Foo();
}

一些需要注意的點

預期行為的一次性寫入

EXPECT_CALL()的鏈式調用中所有預期都會一次性寫入,這意味著不要在鏈式調用中寫運算,可能不會滿足預期需求。舉個栗子,以下並不能匹配返回100,101,102…而是只匹配返回100的情況,因為++是在預期行為被設定好之後才發生

using ::testing::Return;
...
int n = 100;
EXPECT_CALL(turtle, GetX())
    .Times(4)
    .WillRepeatedly(Return(n++));

mock對象方法的預期行為多重定義

在前面,我們看到的都是單對象單方法僅有1種預期行為定義的情況,如果定義了多個呢?例如:

using ::testing::_;
...
EXPECT_CALL(turtle, Forward(_));  // #1
EXPECT_CALL(turtle, Forward(10))  // #2
    .Times(2);

假如我們在後面調用了三次Forwar(10),那麼測試會報錯不通過。如果調用了兩次Forward(10),一次Forward(20),那麼測試會通過。

預期行為粘連問題

gMock中的預期行為默認是粘連的,它們會一直保持存活狀態(哪怕它所規定的預期行為已經完全被匹配過了)
例如以下的情況可能會出錯,這種寫法下可能最初想的是返回50、40、30、20、10的調用各一次,但是發生調用時就報錯了(例如第一次調用返回10,而第二次調用返回20時,預期返回10的那個也還存活著會報錯(不滿足Once了))

using ::testing::Return;
...
for (int i = 5; i > 0; i--) {
  EXPECT_CALL(turtle, GetX())
      .WillOnce(Return(10*i));
}

我的理解:所謂預期行為(Expectations),它所針對的是一個Mock對象的一個方法在某一種參數情況下的行為,如果不顯式的聲明讓它在被滿足後退休,它會一直存活,一直幹活…
要想解決上面的問題,可以顯式的聲明飽和退休

using ::testing::Return;
...
for (int i = n; i > 0; i--) {
  EXPECT_CALL(turtle, GetX())
      .WillOnce(Return(10*i))
      .RetiresOnSaturation();
}

在以上這種寫法下,每個.WillOnce()一旦被滿足就會退休,後面發生了什麼它不會去管了,也就不會報錯了
當然這也可以結合前面的預期發生順序來寫,以下的寫法意味著第一次調用返回10,第二次返回20…..

using ::testing::InSequence;
using ::testing::Return;
...
{
  InSequence s;

  for (int i = 1; i <= n; i++) {
    EXPECT_CALL(turtle, GetX())
        .WillOnce(Return(10*i))
        .RetiresOnSaturation();
  }
}

後續可填坑

gMock進階指南