07 · Unit Testing with GoogleTest¶
GoogleTest (gtest) is the most widely used C++ unit testing framework. It gives you test registration, a rich assertion vocabulary, fixtures for shared setup, and a runner with filtering and reporting — replacing the hand-rolled harness from Module 6 with something you can grow to thousands of tests.
1. Getting GoogleTest into your project¶
The cleanest approach is CMake's FetchContent, which downloads and builds
GoogleTest as part of your configure step. No system install, and every
developer gets the same version.
CMakeLists.txt (top level):
cmake_minimum_required(VERSION 3.14)
project(myproject LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_compile_options(-Wall -Wextra)
add_library(calculator src/calculator.cpp)
target_include_directories(calculator PUBLIC src)
# --- GoogleTest ---------------------------------------------------------
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.15.2 # pin a version -- never track a branch
)
# Windows: keep gtest from overriding the parent project's CRT settings
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
# ------------------------------------------------------------------------
enable_testing()
add_subdirectory(tests)
tests/CMakeLists.txt:
add_executable(test_calculator test_calculator.cpp)
target_link_libraries(test_calculator PRIVATE calculator GTest::gtest_main)
# Auto-discover every TEST() in the binary and register each with CTest
include(GoogleTest)
gtest_discover_tests(test_calculator)
Two details worth understanding:
GTest::gtest_mainprovides amain()that runs all registered tests, so your test file doesn't need one.gtest_discover_tests()runs the binary at build time with--gtest_list_testsand registers each test individually with CTest — soctest -R Divideworks, and a failure names the specific test rather than "the test binary". Prefer it overadd_test(NAME ... COMMAND ...)for gtest binaries.
Pin the tag
GIT_TAG main means your test suite's behaviour can change without you
changing anything — the worst possible property for a test framework. Always
pin a release tag.
2. Your first test¶
tests/test_calculator.cpp:
#include <gtest/gtest.h>
#include "calculator.h"
// TEST(TestSuiteName, TestName)
TEST(CalculatorTest, AddsTwoPositiveNumbers) {
Calculator c;
EXPECT_EQ(c.add(2, 3), 5);
}
TEST(CalculatorTest, AddsNegativeAndPositive) {
Calculator c;
EXPECT_EQ(c.add(-1, 1), 0);
}
TEST(CalculatorTest, DivisionTruncatesTowardZero) {
Calculator c;
EXPECT_EQ(c.divide(7, 2), 3);
}
Build and run:
[==========] Running 3 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 3 tests from CalculatorTest
[ RUN ] CalculatorTest.AddsTwoPositiveNumbers
[ OK ] CalculatorTest.AddsTwoPositiveNumbers (0 ms)
[ RUN ] CalculatorTest.AddsNegativeAndPositive
[ OK ] CalculatorTest.AddsNegativeAndPositive (0 ms)
[ RUN ] CalculatorTest.DivisionTruncatesTowardZero
[ OK ] CalculatorTest.DivisionTruncatesTowardZero (0 ms)
[----------] 3 tests from CalculatorTest (0 ms total)
[==========] 3 tests from 1 test suite ran. (0 ms total)
[ PASSED ] 3 tests.
Or through CTest:
Start 1: CalculatorTest.AddsTwoPositiveNumbers
1/3 Test #1: CalculatorTest.AddsTwoPositiveNumbers ... Passed 0.00 sec
Start 2: CalculatorTest.AddsNegativeAndPositive
2/3 Test #2: CalculatorTest.AddsNegativeAndPositive ... Passed 0.00 sec
Start 3: CalculatorTest.DivisionTruncatesTowardZero
3/3 Test #3: CalculatorTest.DivisionTruncatesTowardZero Passed 0.00 sec
100% tests passed, 0 tests failed out of 3
What a failure looks like¶
Change one expectation to something wrong and rebuild:
TEST(CalculatorTest, DivisionTruncatesTowardZero) {
Calculator c;
EXPECT_EQ(c.divide(7, 2), 4); // wrong on purpose
}
[ RUN ] CalculatorTest.DivisionTruncatesTowardZero
/home/you/myproject/tests/test_calculator.cpp:16: Failure
Expected equality of these values:
c.divide(7, 2)
Which is: 3
4
[ FAILED ] CalculatorTest.DivisionTruncatesTowardZero (0 ms)
...
[ FAILED ] 1 test, listed below:
[ FAILED ] CalculatorTest.DivisionTruncatesTowardZero
1 FAILED TEST
File, line, both values, and which test. That diagnostic quality is most of
what a framework buys you over if (x != y) printf("fail").
3. Naming tests¶
The test name is documentation that runs. Since gtest_discover_tests surfaces
these names in CTest output and CI dashboards, a good name means a failure is
diagnosable from a build log alone.
| Poor | Better |
|---|---|
TEST(Calc, Test1) |
TEST(CalculatorTest, AddsTwoPositiveNumbers) |
TEST(Calc, DivideWorks) |
TEST(CalculatorTest, DivisionTruncatesTowardZero) |
TEST(Calc, Error) |
TEST(CalculatorTest, ThrowsInvalidArgumentWhenDivisorIsZero) |
Conventions that work well:
- Suite name = the class or unit under test, plus
Test. - Test name = the behaviour being asserted, in the affirmative, no
underscores (gtest reserves
_for internal use —TEST(Foo, Bar_Baz)can actually break in edge cases). - Read it as a sentence: "CalculatorTest: division truncates toward zero."
4. Assertions¶
EXPECT vs. ASSERT¶
| Macro family | On failure | Use when |
|---|---|---|
EXPECT_* |
Records the failure, continues the test | Default — you want all failures reported at once |
ASSERT_* |
Records the failure, returns from the function immediately | Continuing would crash or be meaningless |
TEST(VectorTest, SecondElementIsCorrect) {
std::vector<int> v = build_vector();
ASSERT_GE(v.size(), 2u); // if this fails, v[1] would be UB -- must abort
EXPECT_EQ(v[1], 42); // safe to continue past this one
}
The rule for C/C++ specifically: use ASSERT for any precondition whose
violation would make the following lines undefined behaviour. A null pointer
check before dereferencing, a size check before indexing. EXPECT on those
turns a clean test failure into a segfault that takes the whole test binary
down with it — losing the results of every test after it.
ASSERT_* only returns from the current function
Because ASSERT expands to return;, it cannot be used in a function
returning non-void, and if you put it in a helper function it returns from
the helper, not the test — execution continues in the test body. Either
keep asserts in the test body, or have helpers return a value the test
checks.
The assertion vocabulary¶
| Assertion | Checks |
|---|---|
EXPECT_TRUE(cond) / EXPECT_FALSE(cond) |
Boolean condition |
EXPECT_EQ(a, b) / EXPECT_NE(a, b) |
a == b / a != b |
EXPECT_LT EXPECT_LE EXPECT_GT EXPECT_GE |
< <= > >= |
EXPECT_STREQ(a, b) / EXPECT_STRNE |
C strings (char*) by content |
EXPECT_STRCASEEQ / EXPECT_STRCASENE |
C strings, case-insensitive |
EXPECT_FLOAT_EQ(a, b) / EXPECT_DOUBLE_EQ |
Floats within 4 ULPs |
EXPECT_NEAR(a, b, tol) |
Floats within an explicit tolerance |
EXPECT_THROW(stmt, ExType) |
Statement throws that exception type |
EXPECT_ANY_THROW(stmt) |
Statement throws anything |
EXPECT_NO_THROW(stmt) |
Statement throws nothing |
EXPECT_DEATH(stmt, regex) |
Statement crashes/aborts with matching stderr |
FAIL() / SUCCEED() |
Unconditional |
ADD_FAILURE() << "msg" |
Record a failure and continue |
Every ASSERT_ variant exists for every EXPECT_ listed.
EXPECT_EQ on char* compares pointers, not text
const char* a = get_name(); // returns "hello"
EXPECT_EQ(a, "hello"); // WRONG -- compares addresses; may fail
EXPECT_STREQ(a, "hello"); // right -- compares contents
std::string compares correctly with
EXPECT_EQ because it has operator==; raw char* does not.
Floating point¶
TEST(MathTest, FloatingPointComparison) {
double result = 0.1 + 0.2;
// EXPECT_EQ(result, 0.3); // FAILS -- result is 0.30000000000000004
EXPECT_DOUBLE_EQ(result, 0.3); // passes -- within 4 units in the last place
EXPECT_NEAR(result, 0.3, 1e-9); // passes -- explicit tolerance
}
Use EXPECT_NEAR with a tolerance derived from the domain (measurement
precision, accumulated error budget) whenever you can justify one; fall back to
EXPECT_DOUBLE_EQ only for values that should be bit-identical modulo rounding.
Custom failure messages¶
Every assertion accepts streamed context:
TEST(ParserTest, AllSamplesParse) {
for (size_t i = 0; i < samples.size(); ++i) {
EXPECT_EQ(parse(samples[i]), expected[i])
<< "sample index " << i << ", input was: '" << samples[i] << "'";
}
}
Without the message, a failure in a loop tells you only that "one of them was wrong". With it, you know which. Always add context inside loops.
5. Testing error paths¶
TEST(CalculatorTest, ThrowsInvalidArgumentWhenDivisorIsZero) {
Calculator c;
EXPECT_THROW(c.divide(1, 0), std::invalid_argument);
}
TEST(CalculatorTest, DoesNotThrowForValidDivisor) {
Calculator c;
EXPECT_NO_THROW(c.divide(1, 2));
}
To inspect the exception's contents, catch it manually:
TEST(CalculatorTest, DivideByZeroMessageNamesTheProblem) {
Calculator c;
try {
c.divide(1, 0);
FAIL() << "expected std::invalid_argument to be thrown";
} catch (const std::invalid_argument& e) {
EXPECT_STREQ(e.what(), "division by zero");
}
}
The FAIL() after the call is essential — without it, a version of divide()
that stops throwing would silently pass this test.
6. Test fixtures¶
When several tests need the same setup, a fixture removes the duplication and guarantees each test gets a fresh instance.
#include <gtest/gtest.h>
#include "ring_buffer.h"
class RingBufferTest : public ::testing::Test {
protected:
void SetUp() override {
buffer = new RingBuffer(64); // runs before EACH test
}
void TearDown() override {
delete buffer; // runs after EACH test
}
RingBuffer* buffer; // accessible in every TEST_F below
};
// Note TEST_F (fixture), not TEST. First argument is the fixture class name.
TEST_F(RingBufferTest, StartsEmpty) {
EXPECT_EQ(buffer->size(), 0u);
EXPECT_TRUE(buffer->empty());
}
TEST_F(RingBufferTest, WriteIncreasesSize) {
const uint8_t data[] = {1, 2, 3};
EXPECT_EQ(buffer->write(data, 3), 3);
EXPECT_EQ(buffer->size(), 3u);
}
TEST_F(RingBufferTest, WriteBeyondCapacityIsTruncated) {
std::vector<uint8_t> big(100, 0xAB);
EXPECT_EQ(buffer->write(big.data(), big.size()), 64); // capacity is 64
EXPECT_TRUE(buffer->full());
}
The critical property: gtest constructs a brand-new fixture object for every
single test. StartsEmpty cannot be affected by what WriteIncreasesSize
did, and the tests can run in any order. That independence (Module 2, section 8)
is not something you have to arrange — the framework enforces it.
Prefer the constructor/destructor when you can¶
class RingBufferTest : public ::testing::Test {
protected:
RingBufferTest() : buffer(64) {} // constructor instead of SetUp
RingBuffer buffer; // value member -- no new/delete at all
};
This version has no manual memory management, so the fixture itself cannot leak.
Use SetUp()/TearDown() when setup can fail in a way you want to assert on
(you cannot use ASSERT_* in a constructor) or when teardown must run even if
setup threw.
Per-suite setup¶
For expensive one-time setup shared by all tests in a suite:
class DatabaseTest : public ::testing::Test {
protected:
static void SetUpTestSuite() { // once, before the first test
shared_db = open_test_database();
}
static void TearDownTestSuite() { // once, after the last test
close_database(shared_db);
shared_db = nullptr;
}
static Database* shared_db;
};
Database* DatabaseTest::shared_db = nullptr;
Use sparingly — shared state between tests reintroduces exactly the coupling fixtures exist to prevent.
7. Running and filtering¶
# Everything
./build/tests/test_calculator
# List without running
./build/tests/test_calculator --gtest_list_tests
# One suite
./build/tests/test_calculator --gtest_filter='CalculatorTest.*'
# One test
./build/tests/test_calculator --gtest_filter='CalculatorTest.AddsTwoPositiveNumbers'
# Wildcards, and '-' to exclude
./build/tests/test_calculator --gtest_filter='*Divide*'
./build/tests/test_calculator --gtest_filter='-*Slow*'
# Stop at the first failure
./build/tests/test_calculator --gtest_break_on_failure
# Repeat -- the flaky test hunter
./build/tests/test_calculator --gtest_repeat=100
# Randomize order -- exposes hidden inter-test dependencies
./build/tests/test_calculator --gtest_shuffle
# Machine-readable output for CI
./build/tests/test_calculator --gtest_output=xml:results.xml
--gtest_shuffle is worth running periodically even when everything is green:
if your suite passes in order but fails shuffled, you have hidden shared state,
which will eventually produce a failure nobody can reproduce.
8. Turning manual test cases into gtest¶
This is the bridge between the first half of this level and the second. Take TC-TEMP-012 from Module 2:
| Field | Value |
|---|---|
| Title | Above-maximum temperature is rejected without modifying the output parameter |
| Test data | text = "126", out pre-set to 0x5A5A5A5A |
| Expected | rc == -2 and out == 0x5A5A5A5A |
Becomes:
TEST(ParseTemperatureTest, RejectsAboveMaximumWithoutModifyingOutput) {
int out = 0x5A5A5A5A; // sentinel from the test case
int rc = parse_temperature("126", &out);
EXPECT_EQ(rc, -2) << "126 is above the maximum of 125";
EXPECT_EQ(out, 0x5A5A5A5A) << "output must be left unmodified on error";
}
The mapping is mechanical:
| Manual test case field | GoogleTest equivalent |
|---|---|
| Test Case ID + Title | The TEST(Suite, Name) identifier |
| Preconditions | Fixture SetUp() or the first lines of the test |
| Test data | Local variables / literals |
| Steps | The code in the test body |
| Expected result | The EXPECT_* / ASSERT_* assertions |
| Requirement ID | A comment, or streamed into the failure message |
| Actual result / Status | Produced by the runner |
And the boundary set from Module 5 becomes a run of tests:
TEST(ParseTemperatureTest, AcceptsLowerBoundary) {
int out = 0;
EXPECT_EQ(parse_temperature("-40", &out), 0);
EXPECT_EQ(out, -40);
}
TEST(ParseTemperatureTest, RejectsJustBelowLowerBoundary) {
int out = 0x5A5A5A5A;
EXPECT_EQ(parse_temperature("-41", &out), -2);
EXPECT_EQ(out, 0x5A5A5A5A);
}
TEST(ParseTemperatureTest, AcceptsUpperBoundary) {
int out = 0;
EXPECT_EQ(parse_temperature("125", &out), 0);
EXPECT_EQ(out, 125);
}
TEST(ParseTemperatureTest, RejectsJustAboveUpperBoundary) {
int out = 0x5A5A5A5A;
EXPECT_EQ(parse_temperature("126", &out), -2);
EXPECT_EQ(out, 0x5A5A5A5A);
}
Repetitive — deliberately so at this stage. Level 2's parameterized tests collapse this into a single test driven by a data table, which is exactly the shape your Module 5 boundary table already has.
9. Test structure — Arrange, Act, Assert¶
Keep every test in three visually distinct phases:
TEST(RingBufferTest, ReadReturnsWhatWasWritten) {
// Arrange -- set up the world
RingBuffer buffer(64);
const uint8_t input[] = {0xDE, 0xAD, 0xBE, 0xEF};
buffer.write(input, 4);
// Act -- perform the one operation under test
uint8_t output[4] = {0};
int n = buffer.read(output, 4);
// Assert -- check the observable outcome
ASSERT_EQ(n, 4);
EXPECT_EQ(std::memcmp(input, output, 4), 0);
EXPECT_TRUE(buffer.empty());
}
If a test has two "Act" phases, it is probably two tests. The value of splitting them is that a failure names which behaviour broke.
10. Common mistakes¶
| Mistake | Why it hurts | Fix |
|---|---|---|
EXPECT_EQ on char* |
Compares pointers | EXPECT_STREQ |
EXPECT_EQ on floats |
Fails on representable-value rounding | EXPECT_NEAR / EXPECT_DOUBLE_EQ |
EXPECT where the next line dereferences |
Segfault kills the whole binary | ASSERT for preconditions |
Logic in the test (if/while deciding what to assert) |
The test can silently assert nothing | Straight-line tests; separate tests per case |
| Tests sharing state via globals or statics | Order-dependent, flaky | Fixtures; verify with --gtest_shuffle |
| Signed/unsigned comparisons in assertions | -Wsign-compare warnings, surprising results |
EXPECT_EQ(v.size(), 3u) — note the u |
| Asserting on implementation details | Test breaks on every refactor | Assert on the public contract |
| Writing the test after seeing the output | Certifies the bug (Module 2) | Derive expectations from requirements |
How It Actually Works: what TEST() actually expands into¶
TEST(SuiteName, CaseName) { ... } looks like it declares a function that
some magical runner later "finds." It doesn't — it's ordinary C++ doing
static registration, and you can trace every step.
TEST()is a macro that defines a class. It expands (simplified) to a classSuiteName_CaseName_Testderiving from::testing::Test, with your{ ... }body becoming the override of itsTestBody()virtual method. Your test code is not a free function at all — it's a method on a generated class.- A static object registers that class at static-initialization time. The
macro also emits a namespace-scope object whose constructor runs before
main(), during C++ static initialization. That constructor calls::testing::UnitTest::GetInstance()->parameterized_test_registry()(in spirit — the exact API isMakeAndRegisterTestInfo), handing the registry a factory function that can construct your test class. This is why you never call your tests by name anywhere — everyTEST()macro silently inserts itself into a global list purely as a side effect of the program loading. RUN_ALL_TESTS()iterates that registry, not your source. At runtime it walks every registeredTestInfo, and for each one: constructs a fresh instance of your generated test class (so member fixtures are per-test, never shared), callsSetUp(), callsTestBody(), callsTearDown(), then destroys the instance. A fresh instance per test is precisely why fixture member variables can't leak state between tests — construction/destruction guarantees a clean slate that manual global variables do not.EXPECT_*andASSERT_*differ by what they do to control flow, not by what they check. Both macros perform the same comparison and, on failure, record it viaAddTestPartResult(which is what makes the result show up red without touchingerrnoor throwing). The difference isASSERT_*macros additionally expand to a barereturn;on failure — that's a literalreturnstatement injected into yourTestBody(), which only works becauseTestBody()returnsvoid; it's why you can't useASSERT_*inside a helper function that returns a real value without wrapping it or usingASSERT_NO_FATAL_FAILURE.- Fixture inheritance is why
SetUp()/TearDown()exist at all. A test fixture is just a subclass of::testing::Test; GoogleTest's generated test classes derive from your fixture instead of::testing::Testdirectly, so the same construct/SetUp/body/TearDown/destruct sequence from point 3 naturally runs your setup and teardown — there's no separate hook mechanism.
Exercise¶
Extend the project from Module 6 with a real GoogleTest suite.
-
Wire up GoogleTest via
FetchContentas in section 1, and convert your three hand-rolled checks toTEST()cases withgtest_discover_tests. Confirmctest --test-dir build -Nlists them as three separate tests. -
Add a
Statisticsclass to your library:class Statistics { public: // Throws std::invalid_argument if values is empty. double mean(const std::vector<int>& values) const; // Returns the largest value. Throws std::invalid_argument if empty. int max(const std::vector<int>& values) const; // Returns count of values strictly greater than threshold. size_t count_above(const std::vector<int>& values, int threshold) const; };Implement it, then write a
StatisticsTestfixture and at least twelve tests covering: - a nominal case for each method - the empty-vector case for all three methods (two throw, one returns 0) - a single-element vector for each method -meanof values that don't divide evenly — assert withEXPECT_NEARand justify your tolerance in a comment -count_abovewhere the threshold equals an element exactly (a boundary: "strictly greater" means it must not be counted) -maxwhere the maximum is the first element, and again where it is the last -
Deliberately break one method (e.g. make
count_aboveuse>=) and record which of your tests fail. If only one test catches it, add the missing boundary case. Restore the method afterwards. -
Add an overflow test. Write a test for
mean({INT_MAX, INT_MAX}). Run it in bothbuild-debugandbuild-release. Record what happens in each. Then write three sentences explaining the result in terms of undefined behaviour (Module 1 and Module 5, section 3) and what the implementation should do to make this testable. -
Prove test independence. Run your suite with
--gtest_shuffle --gtest_repeat=20and confirm it passes every time. If it doesn't, find the shared state.