Here's the video walkthrough, if you'd rather watch it:
Why I Started Using This Tool
For seventeen episodes, my way of "testing" was to build, deploy to the DE10-Nano, SSH in, and read the
log. That works when the whole program is a single heartbeat. It stopped working once
UDPRx started changing the shared
frame that UDPTx sends. Every change
made me wonder whether I'd broken something I wasn't looking at, and each check meant a round trip to the
board. I wanted something that would tell me in a second, on my PC, that the basics still hold: the
header is still 0xDE10, the frame is still 16 bytes, and set and get still agree. That's
what unit tests are for, so I added GoogleTest, starting with the smallest file I have,
frame.c.
What It Does
GoogleTest is a C++ testing framework. You write small TEST blocks that call your code and
check the results with EXPECT_EQ. It reports exactly which value was wrong and on which line.
extern "C": testing C code from C++
Even though GoogleTest is C++, it tests C code with no trouble. You include the C header inside
extern "C" { } so the linker looks for the plain C function names instead of C++ mangled
ones. Here's test/test_frame.cpp, with one line commented out on purpose:
#include <gtest/gtest.h>
extern "C" {
#include "frame.h"
}
/* The frame after startup: header set, everything else zero. */
static Frame default_frame(void)
{
Frame f = {};
// f.header = FRAME_HEADER;
return f;
}
TEST(FrameTest, DefaultFrameCustomCheck) {
Frame frame = default_frame();
EXPECT_EQ(frame.header, FRAME_HEADER);
}
With f.header = FRAME_HEADER; commented out, the helper returns a frame whose header is
0, and the test fails. That's the point. I broke the header on purpose to see what a failure looks like,
and GoogleTest points straight at the line:
test_frame.cpp:18: Failure
Expected equality of these values:
frame.header
Which is: 0
FRAME_HEADER
Which is: 56848
[ FAILED ] FrameTest.DefaultFrameCustomCheck (0 ms)
56848 is just 0xDE10 in decimal. Uncomment the line, run again, and it goes
green. A test you've seen fail is a test you can trust.
A separate test folder with FetchContent
I kept everything in a separate test folder with its own CMakeLists.txt.
CMake's FetchContent downloads a pinned GoogleTest version the first time, so there's
nothing to install. The file compiles the real src/frame.c together with
test/test_frame.cpp and registers every test with CTest:
cmake_minimum_required(VERSION 3.25)
project(ytdemo_tests LANGUAGES C CXX)
set(CMAKE_C_STANDARD 99)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Where the app code lives, relative to this test folder.
set(SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../src) # .c files
set(INC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../src) # .h files
# Download GoogleTest at configure time.
include(FetchContent)
FetchContent_Declare(
googletest
URL https://github.com/google/googletest/archive/refs/tags/v1.15.2.zip
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)
FetchContent_MakeAvailable(googletest)
find_package(Threads REQUIRED)
enable_testing()
# One test program per file under test. Start with frame.c.
add_executable(test_frame
test_frame.cpp
${SRC_DIR}/frame.c
)
target_include_directories(test_frame PRIVATE ${INC_DIR})
target_compile_options(test_frame PRIVATE -Wall -Wextra)
target_link_libraries(test_frame PRIVATE GTest::gtest_main Threads::Threads)
include(GoogleTest)
gtest_discover_tests(test_frame)
My existing Makefile doesn't
change. It still cross-compiles src/*.c for the ARM board, so test code can never end up in
the board binary. Threads::Threads is there because frame.c uses a pthread
mutex.
The first four tests
The first four tests check that:
Frameis exactly 16 bytes.- The shared frame starts with the header set and everything else zero.
- A value you set is the value you get back.
Frame_setreplaces the whole frame.
Because g_frame is a static global shared by every test, a fixture with
TearDown() puts the defaults back after each test, so the order the tests run in never
matters.
One command: cmake --workflow
A test/CMakePresets.json file holds every CMake setting, and one command,
cmake --workflow --preset test, runs configure, build, and CTest:
{
"version": 6,
"configurePresets": [
{
"name": "test",
"generator": "MinGW Makefiles",
"binaryDir": "${sourceDir}/../build-test",
"environment": { "PATH": "C:/msys64/mingw64/bin;$penv{PATH}" }
}
],
"buildPresets": [ { "name": "test", "configurePreset": "test" } ],
"testPresets": [
{ "name": "test", "configurePreset": "test", "output": { "outputOnFailure": true } }
],
"workflowPresets": [
{
"name": "test",
"steps": [
{ "type": "configure", "name": "test" },
{ "type": "build", "name": "test" },
{ "type": "test", "name": "test" }
]
}
]
}
The MinGW PATH gotcha
The only real gotcha was adding C:/msys64/mingw64/bin to the preset's PATH.
The test program links against MinGW DLLs, and without that entry Windows can't find them when CTest
runs it, so the tests fail before a single EXPECT_EQ runs. $penv{PATH} keeps
the rest of your normal PATH after it.
One click in VS Code
A single VS Code task runs that command with one click. Add it to .vscode/tasks.json next
to the build task:
{
"version": "2.0.0",
"tasks": [
{
"label": "test",
"type": "shell",
"command": "C:/msys64/mingw64/bin/cmake.exe",
"args": ["--workflow", "--preset", "test"],
"options": { "cwd": "${workspaceFolder}/test" },
"group": { "kind": "test", "isDefault": true },
"problemMatcher": ["$gcc"]
}
]
}
"kind": "test" with isDefault makes it the task behind
Tasks: Run Test Task, and the $gcc problem matcher turns compile errors
into clickable entries in the Problems panel. If you'd rather click individual tests, the CMake Tools
extension can run the same presets from its Testing sidebar.
Final Verdict
Worth it, even for a four-field struct. The setup took one CMake file, one test file, one presets file, and one task, and now "did I break the frame?" takes a second to answer instead of a deploy. The best moment was breaking the header on purpose and watching the test point straight at the line. If you're writing embedded C, start with your most boring, board-free file. Get the setup working there, and then grow it one file at a time. A setup you actually run beats a clever one you never finish.