【C++】GoogleTest進階之gMock

2022-10-19 06:02:12

gMock是什麼

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

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

參考檔案:
gMock官方檔案

安裝gMock

gMock現在與gTest是組合使用的關係,因此在安裝gTest時預設就會安裝gMock,具體的安裝方式見github上的官方說明
https://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進階指南