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:

  1. Frame is exactly 16 bytes.
  2. The shared frame starts with the header set and everything else zero.
  3. A value you set is the value you get back.
  4. Frame_set replaces 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.