Testing C++ with a framework means writing small functions that exercise one piece of behaviour each and report whether they pass. Catch2 and GoogleTest are the two most widely used, and they differ mostly in syntax. The idea is the same for both, and the ideas transfer to any framework — including the tiny one you can write yourself in twenty lines.
What a unit test is
A unit test is a function with a clear name, one behaviour to check, and a way of saying whether the behaviour held. The name is the documentation: when it fails, the name says what broke.
#include
#include
#include
#include
struct Item {
std::string sku;
int quantity;
};
int total_quantity(const std::vector& items) {
int total = 0;
for (const Item& item : items) {
total += item.quantity;
}
return total;
}
void test_total_of_an_empty_inventory() {
std::vector items;
assert(total_quantity(items) == 0);
}
void test_total_sums_every_line() {
std::vector items = {{"A-100", 5}, {"B-200", 3}};
assert(total_quantity(items) == 8);
}
int main() {
test_total_of_an_empty_inventory();
test_total_sums_every_line();
std::cout << "all tests passed\n";
return 0;
}The output confirms both tests passing:
all tests passedThe three parts of every test are arrange — set up the data; act — call the thing under test; assert — check the result. Keeping them in that order and keeping one behaviour per test is what makes a failure easy to read.
A test harness without a framework
Before reaching for a framework, it is worth knowing how small the machinery really is. The harness below registers test functions in a vector and runs them.
#include
#include
#include
#include
#include
struct TestCase {
std::string name;
std::function body;
};
std::vector& registry() {
static std::vector cases;
return cases;
}
void add_test(const std::string& name, std::function body) {
registry().push_back(TestCase{name, body});
}
#define TEST_CASE(name) \
static void name(); \
struct Registrar_##name { Registrar_##name() { add_test(#name, name); } }; \
static Registrar_##name registrar_instance_##name; \
static void name()
#define CHECK(condition) \
if (!(condition)) { \
throw std::runtime_error(std::string("check failed: ") + #condition); \
}
int main() {
int passed = 0;
int failed = 0;
for (const TestCase& test : registry()) {
try {
test.body();
++passed;
std::cout << "PASS " << test.name << '\n';
} catch (const std::exception& error) {
++failed;
std::cout << "FAIL " << test.name << ": " << error.what() << '\n';
}
}
std::cout << passed << " passed, " << failed << " failed\n";
return failed == 0 ? 0 : 1;
}The macro trick is what real frameworks do: a static object registers the test before main runs, and the runner then executes everything in the registry. That is the whole idea.
Using the harness
With the harness above in a header, the tests themselves look like this.
#include "test_harness.hpp"
#include "inventory.hpp"
TEST_CASE(total_of_an_empty_inventory_is_zero) {
std::vector items;
CHECK(total_quantity(items) == 0);
}
TEST_CASE(total_sums_every_line) {
std::vector items = {{"A-100", 5}, {"B-200", 3}};
CHECK(total_quantity(items) == 8);
}
TEST_CASE(negative_quantities_are_rejected) {
std::vector items = {{"A-100", -1}};
CHECK(!is_valid(items));
}The shape is now the familiar one, and the same file compiles with Catch2 or GoogleTest after changing the macros.
Catch2
Catch2 is header-based for the single-file version, which makes it very easy to adopt.
#define CATCH_CONFIG_MAIN
#include
TEST_CASE("the total of an empty inventory is zero") {
std::vector items;
REQUIRE(total_quantity(items) == 0);
}
TEST_CASE("the total sums every line") {
std::vector items = {{"A-100", 5}, {"B-200", 3}};
REQUIRE(total_quantity(items) == 8);
}
TEST_CASE("a negative quantity is rejected") {
std::vector items = {{"A-100", -1}};
REQUIRE_FALSE(is_valid(items));
}REQUIRE stops the test at the first failure, while CHECK records the failure and carries on. Use REQUIRE when continuing would crash.
GoogleTest
GoogleTest uses a class-based fixture style, which is convenient when several tests need the same setup.
#include
class InventoryTest : public ::testing::Test {
protected:
void SetUp() override {
items = {{"A-100", 5}, {"B-200", 3}};
}
std::vector items;
};
TEST_F(InventoryTest, TotalsEveryLine) {
EXPECT_EQ(total_quantity(items), 8);
}
TEST_F(InventoryTest, RejectsNegativeQuantities) {
items.push_back({"C-300", -1});
EXPECT_FALSE(is_valid(items));
}The SetUp method runs before each test in the fixture, so every test starts from a known state and one test cannot affect another.
Testing the parts that are hard to test
Pure functions are easy to test. Everything else needs a seam — somewhere to substitute a fake for the outside world.
#include
#include
#include
#include
class Inventory {
public:
using SaveFunction = std::function;
explicit Inventory(SaveFunction save) : save_(std::move(save)) {}
bool add(const std::string& sku, int quantity) {
items_.push_back(Item{sku, quantity});
return save_("inventory.csv");
}
std::size_t size() const { return items_.size(); }
private:
struct Item {
std::string sku;
int quantity;
};
std::vector items_;
SaveFunction save_;
};
int main() {
int saved = 0;
Inventory inventory([&saved](const std::string&) {
++saved;
return true;
});
inventory.add("A-100", 5);
inventory.add("B-200", 3);
std::cout << "items: " << inventory.size() << '\n';
std::cout << "saves: " << saved << '\n';
return 0;
}The output shows the injected save function being called once per addition:
items: 2
saves: 2The dependency injection is what makes the class testable: the test supplies a lambda that records the calls, and no file is ever touched.
Quick reference
- One behaviour per test, named so the name explains what broke.
- Arrange, act, assert — in that order, every time.
- A test harness is a registry of functions plus a runner; that is all a framework is.
REQUIREstops the test;CHECKcontinues after a failure.- Inject the outside world as a dependency so the code under test is deterministic.
- Tests that touch the filesystem or the network are slow and flaky; push that work behind a seam.
Try it yourself: write three tests for a function that adds an item to an inventory: one for a valid item, one for a duplicate SKU and one for a negative quantity. Run them with the hand-written harness, then install Catch2 or GoogleTest through your package manager and move the same tests over, changing only the macros. Finally, add a test that the inventory is saved to disk and check how much work the injection seam saves you.