Unit Testing with GoogleTest & Catch2
A unit test is a small piece of code that calls one function or class with known input and checks the result automatically. Instead of running your program and looking at the screen, you run hundreds of checks in a second, every time you change something. In C++ companies, a change usually cannot be merged until all tests pass on the build server.
In this lesson you will test a grading function with GoogleTest, the most widely used C++ test framework, connect the tests to CMake and CTest, read a real failure, and then see the same tests in Catch2, another popular framework.
The code under test
A school in Lucknow grades marks out of 100: 90 and above is A, 75 and above is B, 60 and above is C, 40 and above is D, and anything lower is F. Marks outside 0 to 100 are an error. Keep the logic in a library so that both the real program and the tests can use it.
// include/school/grade.hpp
#pragma once
namespace school {
char grade_for(int marks); // throws std::out_of_range for marks outside 0..100
}// src/grade.cpp
#include "school/grade.hpp"
#include <stdexcept>
namespace school {
char grade_for(int marks) {
if (marks < 0 || marks > 100) {
throw std::out_of_range("marks must be between 0 and 100");
}
if (marks >= 90) return 'A';
if (marks >= 75) return 'B';
if (marks >= 60) return 'C';
if (marks > 40) return 'D'; // a bug is hiding here
return 'F';
}
}Did you spot the bug? A student with exactly 40 marks should get D, but marks > 40 gives F. Bugs at boundaries, the exact values where behaviour changes, are among the most common in real code. Good tests target them.
Writing tests with GoogleTest
A GoogleTest test is written with the TEST macro, which takes a test suite name and a test name. Inside, you use assertions:
| Assertion | Checks |
|---|---|
EXPECT_EQ(a, b) | a == b |
EXPECT_NE, EXPECT_LT, EXPECT_GE ... | Not equal, less than, greater or equal |
EXPECT_TRUE(cond) | cond is true |
EXPECT_DOUBLE_EQ(a, b) | Two double values are almost equal |
EXPECT_THROW(code, Type) | code throws an exception of Type |
Every EXPECT_ macro has an ASSERT_ twin. EXPECT_ records a failure and carries on with the test; ASSERT_ stops the current test immediately. Use ASSERT_ when the rest of the test makes no sense after a failure, for example when a pointer you are about to use is null.
// tests/grade_test.cpp
#include <gtest/gtest.h>
#include <stdexcept>
#include "school/grade.hpp"
using school::grade_for;
TEST(Grade, TopMarksGetA) {
EXPECT_EQ(grade_for(100), 'A');
EXPECT_EQ(grade_for(90), 'A');
}
TEST(Grade, MiddleBands) {
EXPECT_EQ(grade_for(89), 'B');
EXPECT_EQ(grade_for(75), 'B');
EXPECT_EQ(grade_for(60), 'C');
}
TEST(Grade, BoundaryForD) {
EXPECT_EQ(grade_for(40), 'D');
EXPECT_EQ(grade_for(39), 'F');
}
TEST(Grade, ZeroIsF) {
EXPECT_EQ(grade_for(0), 'F');
}
TEST(Grade, RejectsInvalidMarks) {
EXPECT_THROW(grade_for(-1), std::out_of_range);
EXPECT_THROW(grade_for(101), std::out_of_range);
}Notice that the tests check both sides of each boundary (90 and 89, 40 and 39) plus the extremes (0 and 100) and invalid values. Test names describe behaviour, so a failing name already tells you what broke.
Connecting tests to CMake
GoogleTest needs C++17 or newer. The simplest way to get it is FetchContent, which you met in Build Projects with CMake. enable_testing() switches on CMake's test runner, CTest, and gtest_discover_tests registers every TEST in the program with it.
cmake_minimum_required(VERSION 3.28)
project(school LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(school src/grade.cpp)
target_include_directories(school PUBLIC include)
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.17.0
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) # needed with MSVC on Windows
FetchContent_MakeAvailable(googletest)
enable_testing()
add_executable(school_tests tests/grade_test.cpp)
target_link_libraries(school_tests PRIVATE school GTest::gtest_main)
include(GoogleTest)
gtest_discover_tests(school_tests)GTest::gtest_main provides a ready-made main() that runs all tests, so your test file needs none. Build and run the tests through CTest:
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failureReading a failure
With the bug still in grade_for, CTest reports one failing test. Run the test program directly to see GoogleTest's own report:
./build/school_testsRunning main() from .../googletest/src/gtest_main.cc
[==========] Running 5 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 5 tests from Grade
[ RUN ] Grade.TopMarksGetA
[ OK ] Grade.TopMarksGetA (0 ms)
[ RUN ] Grade.MiddleBands
[ OK ] Grade.MiddleBands (0 ms)
[ RUN ] Grade.BoundaryForD
.../tests/grade_test.cpp:20: Failure
Expected equality of these values:
grade_for(40)
Which is: 'F' (70, 0x46)
'D'
Which is: 'D' (68, 0x44)
[ FAILED ] Grade.BoundaryForD (0 ms)
[ RUN ] Grade.ZeroIsF
[ OK ] Grade.ZeroIsF (0 ms)
[ RUN ] Grade.RejectsInvalidMarks
[ OK ] Grade.RejectsInvalidMarks (0 ms)
[----------] 5 tests from Grade (0 ms total)
[----------] Global test environment tear-down
[==========] 5 tests from 1 test suite ran. (0 ms total)
[ PASSED ] 4 tests.
[ FAILED ] 1 test, listed below:
[ FAILED ] Grade.BoundaryForD
1 FAILED TESTPaths and timings differ on your machine. The report gives the file and line of the failing check, both expressions, and their values (a char is shown with its number). Change marks > 40 to marks >= 40, rebuild and run again: all five tests pass. The test now protects that boundary forever; if someone breaks it again next year, the build server catches it.
Fixtures: shared setup
When several tests need the same starting objects, put them in a fixture: a class derived from testing::Test. Each TEST_F gets a fresh copy, so tests cannot affect each other.
#include <gtest/gtest.h>
#include <map>
#include <string>
class ClassRegister : public testing::Test {
protected:
std::map<std::string, int> marks{{"Aarav", 91}, {"Priya", 78}, {"Kabir", 40}};
};
TEST_F(ClassRegister, HasThreeStudents) {
EXPECT_EQ(marks.size(), 3u);
}
TEST_F(ClassRegister, RemovingAStudentDoesNotLeak) {
marks.erase("Kabir");
EXPECT_FALSE(marks.contains("Kabir"));
}Even though the second test erases Kabir, the first test still sees three students, because every test gets its own fixture object. You can also override SetUp() and TearDown() for setup code that does not fit in member initialisers.
The same tests in Catch2
Catch2 (version 3) is another popular framework. Tests are named with plain strings, and one REQUIRE macro handles every comparison by reading the expression itself. SECTION blocks share setup code: the test case runs once for each section.
#include <catch2/catch_test_macros.hpp>
#include <stdexcept>
#include "school/grade.hpp"
TEST_CASE("grade_for maps marks to grades", "[grade]") {
using school::grade_for;
SECTION("boundaries") {
REQUIRE(grade_for(90) == 'A');
REQUIRE(grade_for(40) == 'D');
REQUIRE(grade_for(39) == 'F');
}
SECTION("invalid marks") {
REQUIRE_THROWS_AS(grade_for(101), std::out_of_range);
}
}In CMake, link to Catch2::Catch2WithMain instead of GTest::gtest_main, and register the tests with include(Catch) and catch_discover_tests(school_tests) (when you get Catch2 through FetchContent, first add its extras folder to CMAKE_MODULE_PATH so that include(Catch) can find the module). CHECK is Catch2's "continue after failure" version of REQUIRE, like EXPECT_ in GoogleTest. Both frameworks are good; use whichever your team already uses.
Common mistakes
- Testing only the happy path. Most bugs live at boundaries, empty inputs and invalid values. Test those first.
- Tests that depend on each other or on order. Each test must set up its own data; use fixtures.
- Comparing
doublevalues withEXPECT_EQ.0.1 + 0.2is not exactly0.3. UseEXPECT_DOUBLE_EQorEXPECT_NEAR. - Putting logic in
main.cpp. Code inside the program'smaincannot be tested. Move it into a library target that both the program and the tests link to. - Ignoring a flaky test. A test that sometimes fails often reveals a real race condition or uninitialised variable.
Key points
- Unit tests call your code with known input and check results automatically, every time the code changes.
- GoogleTest uses
TEST,TEST_F,EXPECT_*(continue) andASSERT_*(stop); it needs C++17 or newer. - In CMake:
FetchContent,enable_testing(), linkGTest::gtest_main, thengtest_discover_tests; run withctest. - Test both sides of every boundary, the extremes, and invalid input.
- Catch2 offers the same ideas with
TEST_CASE,SECTIONandREQUIRE.
Exercise
Write a function int ticket_fare(int age) for a Pune museum: children under 5 enter free, ages 5 to 17 pay ₹50, ages 60 and above pay ₹60, and everyone else pays ₹120. Negative ages throw std::invalid_argument. Write GoogleTest tests that cover every boundary (4, 5, 17, 18, 59, 60) and the error case.
Show solution
// fare.cpp
#include <stdexcept>
int ticket_fare(int age) {
if (age < 0) throw std::invalid_argument("age cannot be negative");
if (age < 5) return 0;
if (age <= 17) return 50;
if (age >= 60) return 60;
return 120;
}// fare_test.cpp
#include <gtest/gtest.h>
#include <stdexcept>
int ticket_fare(int age);
TEST(Fare, ChildBoundaries) {
EXPECT_EQ(ticket_fare(4), 0);
EXPECT_EQ(ticket_fare(5), 50);
EXPECT_EQ(ticket_fare(17), 50);
}
TEST(Fare, AdultAndSeniorBoundaries) {
EXPECT_EQ(ticket_fare(18), 120);
EXPECT_EQ(ticket_fare(59), 120);
EXPECT_EQ(ticket_fare(60), 60);
}
TEST(Fare, NegativeAgeThrows) {
EXPECT_THROW(ticket_fare(-1), std::invalid_argument);
}Each pair such as 4 and 5 checks the last value of one band and the first of the next, so a wrong < or <= fails a test immediately.
Quick Quiz
1. What is the difference between EXPECT_EQ and ASSERT_EQ in GoogleTest?
Both record a failure, but only ASSERT_ returns from the test function immediately.
2. Which values best test the rule "40 and above is D, below is F"?
Boundary tests check the last value on each side of the change.
3. What does gtest_discover_tests(school_tests) do?
It lets ctest list and run each test individually.
4. Why does each TEST_F start with fresh data?
A new fixture per test keeps tests independent of each other.
Learn C++ with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
