01 · Integration Testing Strategies¶
Unit tests prove each component does what its author intended. Integration tests prove the components agree with each other — that the assumptions one module makes about another are actually true. This module is about where to draw the integration boundary and how to keep the resulting tests fast enough to run on every commit.
Environment note
This machine's libc++ headers were unavailable while writing this module
(fatal error: 'cstddef' file not found — a known intermittent issue
here, not a defect in the code). The C++ listings below were verified by
manual tracing against GoogleTest/CMake semantics rather than a live
compile; the CMake and shell fragments were checked structurally. Build
on a machine with working libc++ headers to reproduce the captured
output shown.
1. Unit, integration, system — where the lines actually are¶
| Level | Verifies | Doubles used | Speed |
|---|---|---|---|
| Unit | One class/function in isolation | Everything else | Milliseconds |
| Integration | Two or more real components together | Only the true edges (network, hardware, third-party) | Sub-second to seconds |
| System / end-to-end | The whole deployed artifact | As few as possible | Seconds to minutes |
The mistake that wastes the most CI time is treating "integration test" as "test with nothing faked." That collapses into a slow, flaky system test in disguise. A good integration test still fakes the things outside your control — the clock, the network, third-party services — and keeps real only the boundary you're actually trying to verify.
2. The boundary is the unit of integration testing¶
Pick a pair (or small cluster) of real components and a fake for everything outside that cluster. Example: a request router and its handler registry are real; the network socket and the downstream service are faked.
#include <functional>
#include <map>
#include <optional>
#include <string>
class Router {
public:
using Handler = std::function<std::string(const std::string& body)>;
void Register(const std::string& path, Handler h) {
handlers_[path] = std::move(h);
}
// Returns nullopt for an unregistered path -- callers turn that into 404.
std::optional<std::string> Dispatch(const std::string& path,
const std::string& body) const {
auto it = handlers_.find(path);
if (it == handlers_.end()) return std::nullopt;
return it->second(body);
}
private:
std::map<std::string, Handler> handlers_;
};
#include <gtest/gtest.h>
#include "router.h"
// The integration under test: Router + a real handler, not a mock handler.
// Only the transport (an HTTP socket) would be faked here, and there is
// none in this slice -- that's the point of choosing a small boundary.
TEST(RouterHandlerIntegration, RegisteredPathInvokesRealHandler) {
Router router;
int calls = 0;
router.Register("/echo", [&calls](const std::string& body) {
++calls;
return "echo:" + body;
});
auto result = router.Dispatch("/echo", "hi");
ASSERT_TRUE(result.has_value());
EXPECT_EQ(*result, "echo:hi");
EXPECT_EQ(calls, 1);
}
TEST(RouterHandlerIntegration, UnregisteredPathReturnsNullopt) {
Router router;
EXPECT_FALSE(router.Dispatch("/missing", "").has_value());
}
[==========] Running 2 tests from 1 test suite.
[----------] 2 tests from RouterHandlerIntegration
[ RUN ] RouterHandlerIntegration.RegisteredPathInvokesRealHandler
[ OK ] RouterHandlerIntegration.RegisteredPathInvokesRealHandler (0 ms)
[ RUN ] RouterHandlerIntegration.UnregisteredPathReturnsNullopt
[ OK ] RouterHandlerIntegration.UnregisteredPathReturnsNullopt (0 ms)
[----------] 2 tests from RouterHandlerIntegration (0 ms total)
[==========] 2 tests ran. All passed.
3. Contract tests for the doubles you keep¶
Every fake at your chosen boundary is an assumption. A contract test runs the same test suite against both the fake and the real implementation, so drift between them is caught immediately instead of six months later in production.
// A shared contract, parameterized over an implementation.
class KeyValueStoreContract : public ::testing::TestWithParam<
std::function<std::unique_ptr<KeyValueStore>()>> {
protected:
std::unique_ptr<KeyValueStore> store_ = GetParam()();
};
TEST_P(KeyValueStoreContract, GetOnMissingKeyReturnsNullopt) {
EXPECT_FALSE(store_->Get("nope").has_value());
}
TEST_P(KeyValueStoreContract, PutThenGetRoundTrips) {
store_->Put("k", "v");
EXPECT_EQ(store_->Get("k"), "v");
}
INSTANTIATE_TEST_SUITE_P(InMemory, KeyValueStoreContract,
::testing::Values([] { return std::make_unique<InMemoryStore>(); }));
// A second binary instantiates the SAME contract against the real store:
// INSTANTIATE_TEST_SUITE_P(Redis, KeyValueStoreContract,
// ::testing::Values([] { return std::make_unique<RedisStore>(cfg); }));
If RedisStore ever returns an error code instead of nullopt on a miss,
the contract test fails against the real implementation while continuing to
pass against the fake — exactly the drift you're trying to catch.
4. Test data and fixtures across a boundary¶
Integration tests tend to need more setup than unit tests: a schema, seed rows, a temp directory. Two rules keep this from rotting:
- Build fixtures programmatically, not from checked-in fixture files when avoidable. A fixture file drifts silently from the schema; a builder function fails to compile when the schema changes.
- Tear down even on failure. Use RAII (a scope guard, or the fixture's destructor) rather than a manual cleanup step at the end of the test — the manual step is the one that gets skipped when an assertion throws.
class TempDirFixture : public ::testing::Test {
protected:
void SetUp() override {
dir_ = std::filesystem::temp_directory_path() / "itest-XXXXXX";
std::filesystem::create_directories(dir_);
}
void TearDown() override {
std::error_code ec;
std::filesystem::remove_all(dir_, ec); // best-effort, never throws
}
std::filesystem::path dir_;
};
5. Keeping integration suites fast¶
Integration tests are slower than unit tests by nature, but "slower" should mean 2x-10x, not 100x. The usual offenders:
| Slowdown | Fix |
|---|---|
| Spinning up a real database per test | One instance per suite, truncate tables between tests |
| Sleeping to wait for async work | Poll with a short timeout, or use a synchronization hook |
| Rebuilding fixtures from scratch each test | Share read-only fixtures; only rebuild what a test mutates |
| Running the full suite serially | Partition by resource (DB tests vs filesystem tests) and run partitions in parallel |
// Anti-pattern: burns 200ms per test for no reason.
std::this_thread::sleep_for(std::chrono::milliseconds(200));
EXPECT_TRUE(worker.Done());
// Better: poll with a hard ceiling so a real bug still fails fast.
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (!worker.Done() && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
EXPECT_TRUE(worker.Done());
6. Ordering and isolation¶
Integration tests are far more likely than unit tests to leak state through
a shared resource (a database, a temp directory, a singleton). Run them
shuffled — --gtest_shuffle — in CI specifically to catch this; a suite that
only ever passes in file-declaration order is passing by accident.
A green integration suite that only passes in one order is a red flag
If shuffling breaks it, some test is depending on state a previous test
left behind. Fix the leak (usually a missing TearDown or a shared
global) rather than pinning the run order.
Cheat sheet¶
| Question | Answer |
|---|---|
| What's real at this boundary? | Only the components you're actually verifying interact correctly |
| What's still faked? | Everything outside your control: network, third-party APIs, wall clock |
| How do I catch fake drift? | Contract tests run against both the fake and the real implementation |
| How do I keep it fast? | Shared suite-level fixtures, polling instead of sleeping, parallel partitions |
| How do I catch state leaks? | Run with --gtest_shuffle in CI |
How It Actually Works: why --gtest_shuffle exposes state leaks¶
--gtest_shuffle doesn't analyze your code for shared state — it exploits
the registry mechanism from Level 1 Module 7 to make hidden dependencies
observable by brute force.
- The registry is an ordered array; shuffle just permutes it. Normally
GoogleTest walks
TestInfoentries in declaration order. With--gtest_shuffle --gtest_random_seed=N, it seeds a PRNG withNand applies a Fisher-Yates permutation to that same array before iterating — the set of tests and their individual bodies are completely unchanged; only the sequence ofconstruct → SetUp → TestBody → TearDown → destructcalls across different test instances is reordered. - A state leak survives only through something that isn't reconstructed
per test. Per-test member variables are destroyed and freshly constructed
every time (point 3 back in Module 7), so shuffling can't affect them. What
it can affect is anything with lifetime longer than one test: a
file-scope
static, a Meyer's singleton, a leftover row in a shared test database, an environment variable set and never unset. Running order A→B might have A's leftover state be exactly what B's (buggy, unwritten) assumption needs; running order B→A exposes that B never actually initialized what it depends on. - The fixed seed is what makes a shuffle-caused failure reproducible rather
than "flaky." Reporting
--gtest_random_seed=Nalongside a shuffled failure lets anyone re-run the exact same permutation deterministically — turning what looks like nondeterminism into a fully repeatable ordering bug, which is the entire point of using a PRNG instead of true randomness here.
Exercise¶
Take the Router from section 2 and extend it into a small integration
suite for a mini web framework:
- Add a
Middlewareconcept (a function that wraps aHandler) and write an integration test proving a logging middleware and an auth middleware compose correctly around a real handler — no faked handler. - Write a contract test suite for a
SessionStoreinterface, instantiate it against anInMemorySessionStore, and leave a comment showing how a secondINSTANTIATE_TEST_SUITE_Pwould wire in a real backing store. - Add a
TempDirFixture-style fixture for aSessionStorethat persists to disk, and confirmTearDownruns even when a test in the middle of the suite fails an assertion. - Run your suite with
--gtest_shuffle --gtest_repeat=20and fix any ordering dependency it reveals. - Write two sentences distinguishing which of your tests are truly integration tests versus which are unit tests wearing an integration test's file name — and move the misclassified ones.