在 Windows 上使用 Google Test 來 Unittest 既有的 Legacy C 語言程式碼

單元測試(Unittest)一直是軟體開發的好幫手,但場景切換到 C 語言原始碼的 Legacy Code,似乎處處碰壁。本系列文章將帶你入門如何使用 Google Test 的 C++ 框架來實現對舊有的 C Code 做 Unittest,搭配 VS Code 的 Debugger,幫助你持續穩定程式品質。

前言

TDD 測試驅動開發(Test-driven development),是一種「先寫測試再開發程式」的開發技巧⋯⋯理想上是。實務上,直接把程式碼刻出來,稍微測試 Debug之後發現能動,十之八九就這樣用下去了。未來持續疊床架屋,main branch 上的 Commit 先搶先贏,也就離 TDD 越來越遠了。

就是在如此困境之下,還是想要拆東牆補西壁結構補強,於是就有了本篇文章:為既有 C 語言程式原始碼,加入單元測試,期許未來更穩定的開發。

原始碼目錄結構

本系列教學,假定現有的專案為以下資料夾結構,其中 very_important_func.h 會 include 使用 list8.h 以及 queue.h。

./
├── .git/
├── workspace/
│   ├── main/
│   │   ├── inc/
│   │   │   └── very_important_func.h
│   │   └── src/
│   │       └── very_important_func.c
│   └── shrlib/
│       ├── inc/
│       │   ├── list8.h
│       │   └── queue.h
│       └── src/
│           ├── list8.c
│           └── queue.c
└── README.md

What’s inside list8.h/list8.c

這是本篇文章會用到的測試對象。

#ifndef LIST8_H
#define LIST8_H

#define MAX_LIST8_NODE     (0xFFU)

typedef struct list8_head
{
    unsigned char pos;
    unsigned char size;
} LIST8_HEAD, *LIST8_HEAD_PTR;

typedef struct list8_node
{
    unsigned char prev;
    unsigned char next;
} LIST8_NODE, *LIST8_NODE_PTR;

typedef struct list8_struct
{
    LIST8_HEAD         head;
    LIST8_NODE_PTR     node;
} LIST8_STRUCT, *LIST8_STRUCT_PTR;

void list8_init(LIST8_STRUCT_PTR list, LIST8_NODE_PTR node);

#endif
#include "list8.h"

void list8_init(LIST8_STRUCT_PTR list, LIST8_NODE_PTR node)
{
    list->head.pos  = MAX_LIST8_NODE;
    list->head.size = 0;
    list->node      = node;
}

使用環境

本系列預計使用:

  • 作業系統:Windows 10(含)以上
  • 測試框架:Google Test (C++ Framework)
  • 編譯器:CMake + MSYS2

我知道,在Windows上安裝環境,永遠是開發前期最困難的部分。為了避免你踩坑,底下依序說明。

下載 Google Test 原始碼

在專案的根目錄打開終端機輸入:

$ git clone https://github.com/google/googletest

現在你的專案目錄應該會長成這樣:

./
├── .git/
├── googletest/
│   └── ...
├── workspace/
│   └── ...
└── README.md  

下載 CMake + MSYS2

前往 CMake 官網,下載 cmake-3.26.3-windows-x86_64.msi 並安裝。

前往 MSYS2 官網,下載 msys2-x86_64-20230318.exe 並安裝。請留意 MSYS2 並不包含編譯器,他只是讓你能夠集中管理下載各系統的 Native Build。所以安裝完成後請執行 MSYS,我們要使用其内建的 pacman 安裝 gcc 編譯器,這邊我選擇安裝的是 MingGW 32bit 的版本(你可以自行從詳細列表中選擇)。

$ pacman -S mingw-w64-i686-toolchain
:: There are 19 members in group mingw-w64-i686-toolchain:
:: Repository mingw32
   1) mingw-w64-i686-binutils  2) mingw-w64-i686-crt-git  3) mingw-w64-i686-gcc
   4) mingw-w64-i686-gcc-ada  5) mingw-w64-i686-gcc-fortran  6) mingw-w64-i686-gcc-libgfortran
   7) mingw-w64-i686-gcc-libs  8) mingw-w64-i686-gcc-objc  9) mingw-w64-i686-gdb
   10) mingw-w64-i686-gdb-multiarch  11) mingw-w64-i686-headers-git  12) mingw-w64-i686-libgccjit
   13) mingw-w64-i686-libmangle-git  14) mingw-w64-i686-libwinpthread-git  15) mingw-w64-i686-make
   16) mingw-w64-i686-pkgconf  17) mingw-w64-i686-tools-git  18) mingw-w64-i686-winpthreads-git
   19) mingw-w64-i686-winstorecompat-git

Enter a selection (default=all):

請鍵入 Enter 以安裝全部。然後:

  • 將 C:/msys64/mingw32/bin 資料夾裡的 xxx-make.exe 重新命名為 make.exe。
  • 將 C:/msys64/mingw32/bin 加入系統環境變數 PATH

打開終端機檢查一下編譯環境:

$ gcc --version
gcc.exe (Rev10, Built by MSYS2 project) 12.2.0
...
$ make --version
GNU Make 4.4
Built for Windows32
...

確認 CMake 編譯環境

在開始撰寫測試之前(別急),讓我們先確認環境沒問題。在根目錄加入 CMakeLists.txt 檔案,貼上以下文字內容。

cmake_minimum_required(VERSION 3.5)

set(GOOGLETEST_VERSION 1.13.0)

project(my_project_name)

# GoogleTest requires at least C++14
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_C_STANDARD 11)

set(CMAKE_VERBOSE_MAKEFILE ON)

enable_testing()

include(GoogleTest)
add_subdirectory(googletest)

在根目錄開啟終端機,輸入以下指令透過 CMake 於 build 資料夾下創建 Makefile 目錄:

$ cmake -S . -B build -G "MSYS Makefiles"
-- The C compiler identification is GNU 12.2.0
-- The CXX compiler identification is GNU 12.2.0
...
-- Found Python: C:/Users/zhung/AppData/Local/Programs/Python/Python310/python.exe (found version "3.10.11") found components: Interpreter 
-- Configuring done (80.2s)
-- Generating done (0.7s)
-- Build files have been written to: D:/your/path/to/build

留意,我們特別指定使用 MSYS Makefiles,以避免 CMake 優先使用 Visual Studio 20XX with Windows SDK 環境。

輸入以下指令開始編譯,現在我們的 CMakeLists.txt 設定只有帶入 gtest 主程式。

$ cmake --build build
...
[ 12%] Building CXX object googletest/googletest/CMakeFiles/gtest.dir/src/gtest-all.cc.obj 
...
[ 37%] Building CXX object googletest/googlemock/CMakeFiles/gmock.dir/src/gmock-all.cc.obj
...
[ 62%] Building CXX object googletest/googlemock/CMakeFiles/gmock_main.dir/src/gmock_main.cc.obj
...
[ 87%] Building CXX object googletest/googletest/CMakeFiles/gtest_main.dir/src/gtest_main.cc.obj
...
[100%] Linking CXX static library ../../lib/libgtest_main.a
...
[100%] Built target gtest_main 
可以加入 –verbose 參數以查看詳細編譯指令。

沒問題的話,就表示你的系統可以正常編譯 Google Test 主程式啦!

撰寫第一則測試

現在,在根目錄下加入一個 test 資料夾,我們要把 Test 檔案集中在這邊,例如加入一支 test_list8.cc。

./
├── .git/
├── fff/
│   └── ...
├── googletest/
│   └── ...
├── test/
│   └── test_list8.cc
├── workspace/
│   └── ...
├── CMakeLists.txt
└── README.md

在 test_list8.cc 加入以下內容:

#include <gtest/gtest.h>

extern "C" {
    #include "list8.h"
}

LIST8_STRUCT list;
LIST8_NODE node[MAX_LIST8_NODE];

TEST(TestList8, CaseInit) {
    list8_init(&list, node);
    ASSERT_EQ(list.head.pos, MAX_LIST8_NODE);
};

在 CMakeLists.txt 中加入以下設定:

# ...

include_directories(
    "workspace/shrlib/inc"
)

file(GLOB_RECURSE SHR_SRCS FOLLOW_SYMLINKS ${CMAKE_SOURCE_DIR}/workspace/shrlib/src/*.c)
add_library(
    lib_shrlib
    ${SHR_SRCS}
)

add_executable( test_shrlib test/test_list8.cc )
target_link_libraries(
    test_shrlib
    lib_shrlib
    GTest::gtest_main
)
gtest_discover_tests( test_shrlib )

重新編譯程式:

$ cmake --build build
...
[100%] Built target gmock_main
...
加入參數 -j 4 可以使用4個執行緒以加速。

沒問題的話,輸入以下指令執行測試:

$ ctest --test-dir build
Internal ctest changing into directory: D:/Work/Desktop/googletest/build
Test project D:/Work/Desktop/googletest/build
    Start 1: TestList8.CaseInit
1/1 Test #1: TestList8.CaseInit ...............   Passed    0.35 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) =   0.38 sec

撒花!恭喜完成我們的第一支測試程式。你可以在 build/Testing/Temporary 資料夾中看到測試 Log 檔案 LastTest.log 如下:

Start testing: Apr 24 11:55
...
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from TestList8
[ RUN      ] TestList8.CaseInit
[       OK ] TestList8.CaseInit (0 ms)
[----------] 1 test from TestList8 (0 ms total)

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

Test time =   0.35 sec
----------------------------------------------------------
Test Passed.
...

在 VS Code 上使用 Debugger

如果你會使用 gdb 直接 Debug,可以跳過這段。若還是想用 VS Code 的界面,請安裝套件 CMake Tools,重新用 VS Code 打開你的專案目錄,就可以發現狀態欄多出許多按鈕啦!加入 Debug 用的啟動設定於 .vscode/launch.json。

{
    "version": "0.2.0",
    "configurations": [
        {
            // source: https://github.com/microsoft/vscode-cmake-tools/blob/main/docs/debug-launch.md
            "name": "(gdb) Launch",
            "type": "cppdbg",
            "request": "launch",
            // Resolved by CMake Tools:
            "program": "${command:cmake.launchTargetPath}",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [
                {
                    // add the directory where our target was built to the PATHs
                    // it gets resolved by CMake Tools:
                    "name": "PATH",
                    "value": "${env:PATH}:${command:cmake.getLaunchTargetDirectory}"
                },
                {
                    "name": "OTHER_VALUE",
                    "value": "Something something"
                }
            ],
            "console": "externalTerminal",
            "MIMode": "gdb",
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                }
            ]
        },
    ]
}

然後在 Debugger Panel 中點選綠色箭頭 ,就可以 Debug 啦!嗯?好像哪裏怪怪的。你可能會發現只有 google test (C++) 的程式部分可以跳轉,C 程式都進不去,原因是 gcc 預設 Release Build,不會有 Debug 用的檔案資訊。回到我們 CMakeLists.txt,加入以下設定:

set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} -g -O0")

這樣就可以在 gcc 的編譯步驟加入 -g Flag 以啟用 Debug 編譯。重新編譯過後再啟用 Debugger,現在應該就可以 Step into C function 了!

建議可以關閉 VS Code 中 CMake Tools 的這些設定:cmake.configureOnEdit、cmake.configureOnOpen、cmake.automaticReconfigure,以避免每次改動都會觸發 CMake Re-Config。

在 Debugger 中使用 Memory View

另外推薦 MemoryView 套件,這樣就可以在 VS Code中觀看 Memory Buffer,此外掛支援的 gdb 有限,所以假如你是用 MSVC Toolkit 將無法調用 Memory View。使用截圖:


現在我們完成了第一支單元測試程式,你可以接著往下了解:

References