SECTION 1
Historical Context & Motivation
Software testing as a formal discipline traces its roots to the earliest days of computing, when engineers working on room-sized machines discovered that verifying correctness was often harder than writing the programs themselves. Grace Hopper famously documented one of the first literal "bugs" in 1947—a moth trapped in a relay of the Harvard Mark II—but the conceptual challenge of ensuring that code behaves as intended predates even that anecdote. As software systems grew in complexity through the 1960s and 1970s, ad-hoc debugging gave way to more structured approaches: developers began writing small, isolated programs whose sole purpose was to exercise a function and compare its actual output against a known expected result. This practice evolved into what we now call unit testing, and it forms the backbone of modern software quality assurance.
1950s • Early Debugging Era
1979 • Myers' The Art of Software Testing
1987 • SUnit for Smalltalk
1998 • JUnit and xUnit Proliferation
2003–Present • Google Test & Modern C++ Testing
The central question that test-case writing addresses is deceptively simple: how do we know our function does what we think it does? Without a systematic mechanism for comparing actual outputs against expected outputs, developers rely on intuition and manual inspection—approaches that are fragile, non-repeatable, and scale poorly. Writing test cases transforms verification from a subjective judgment into an automated, objective process that can be executed thousands of times without human intervention.
SECTION 2
Core Principles of Writing Test Cases
Effective test cases are not random spot-checks; they are carefully designed experiments that probe the behavior of a function under specific, documented conditions. A well-structured test case follows a pattern often called Arrange–Act–Assert (AAA): you set up the preconditions, invoke the function under test, and then compare the actual result with the expected result. Understanding and internalizing the following principles will equip you to write test cases that are both rigorous and maintainable.
Isolation
Determinism
Completeness of Coverage
Readability
Independence
KEY TAKEAWAY
SECTION 3
Visual Explanation — The Arrange–Act–Assert Pattern
The Arrange–Act–Assert pattern provides a uniform structure that makes test cases self-documenting. When a colleague reads your test, the three phases are immediately recognizable, and the intent of the test is apparent from the arrangement of inputs and the chosen assertion. In C++, the assert macro from <cassert> provides the simplest form of the Assert phase: if the condition evaluates to false, the program terminates with a diagnostic message. More sophisticated frameworks like Google Test replace assert with macros such as EXPECT_EQ and ASSERT_TRUE that report failures without aborting the entire test suite, allowing multiple tests to run and report results collectively.
SECTION 4
How Test Cases Work in C++
At its most fundamental level, a test case in C++ is simply a function that calls another function and compares the return value (or observable side effects) against a predetermined expected result. The simplest mechanism uses the assert macro from the C standard library, which is available in C++ via the <cassert> header. When you write assert(expression), the compiler inserts a runtime check: if expression evaluates to zero (false), the program writes a diagnostic to stderr and calls abort(). If the expression evaluates to nonzero (true), execution continues silently.
The assert Macro
ASSERT SEMANTICS
If actual == expected is true, execution proceeds. If false, the program aborts with a message identifying the file, line number, and the failed expression.
Anatomy of a Minimal Test Harness
A test harness is the driver code that orchestrates test execution. In the simplest case, a main() function calls a sequence of test functions, each containing its own Arrange–Act–Assert logic. If all assertions pass, the program exits normally (return code 0). If any assertion fails, abort() is called and the remaining tests do not run—which is a limitation that frameworks like Google Test address by catching assertion failures and continuing with subsequent tests.
Compilation Note
assert macro is disabled when the preprocessor symbol NDEBUG is defined (e.g., in release builds with -DNDEBUG). Always compile your test executables without NDEBUG to ensure assertions remain active.Test Structure with Google Test
GOOGLE TEST ASSERTION
Unlike assert, EXPECT_EQ records the failure and continues executing the remaining test cases. ASSERT_EQ is available when early termination of the current test is desired.
In Google Test, each test case is declared with the TEST(TestSuiteName, TestCaseName) macro, which registers the test function automatically. The framework provides a main() function that discovers all registered tests, runs them in isolation, and prints a summary report. This eliminates the need to manually maintain a test harness and ensures that one test's failure does not prevent subsequent tests from executing.
SECTION 5
Categories of Test Cases
Writing effective test cases requires thinking beyond the "happy path"—the scenario where everything goes as planned. A robust test suite systematically covers multiple categories of input, each designed to probe a different aspect of the function's behavior. The following classification, rooted in the equivalence partitioning and boundary value analysis techniques from software testing theory, provides a practical framework for deciding which test cases to write.
divide function into six categories: normal cases, boundary cases, error cases, negative inputs, identity/special values, and truncation behavior. A well-designed test suite includes at least one test from each category.Notice that even a function with only two integer parameters generates at least six distinct categories of test input. Real-world functions that accept strings, containers, or user-defined types can easily require dozens of carefully chosen test cases. The discipline of boundary value analysis tells us that bugs cluster at the boundaries of input domains—zero, empty strings, maximum integer values, first and last elements of arrays—so these cases deserve disproportionate testing attention.
SECTION 6
Worked Example — Testing a factorial Function
Consider a function int factorial(int n) that returns n! for non-negative integers and returns −1 for negative inputs as an error indicator. We will write a complete test suite using <cassert>, then show the equivalent using Google Test, walking through each step of the AAA pattern.
Building a Test Suite for factorial()
- 1
Step 1 — Identify the function contract
Before writing any test, define the expected behavior precisely. The functionfactorial(n)should return: 1 when n = 0 (by convention, 0! = 1), n × (n−1) × ... × 1 when n > 0, and −1 when n < 0. This contract gives us three equivalence classes.Three equivalence classes: n = 0, n > 0, n < 0 - 2
Step 2 — Write the base-case test
Arrange: set n = 0, expected = 1. Act: callfactorial(0). Assert:assert(factorial(0) == 1). This tests the boundary between the recursive case and the base case, a common source of off-by-one errors.assert(factorial(0) == 1); // base case - 3
Step 3 — Write normal-case tests
Select representative values from the positive domain. We choose small values where the expected result is easy to compute mentally:factorial(1) == 1,factorial(5) == 120, andfactorial(10) == 3628800. Including n = 1 tests the boundary between base case and recursion.assert(factorial(1) == 1); assert(factorial(5) == 120); assert(factorial(10) == 3628800); - 4
Step 4 — Write error-case tests
Per the contract, negative inputs should return −1. Test a single negative value and an extreme negative:assert(factorial(-1) == -1)andassert(factorial(-100) == -1). This ensures the error-handling code path is exercised.assert(factorial(-1) == -1); assert(factorial(-100) == -1); - 5
Step 5 — Assemble the complete test file
Combine all assertions into a single test driver. The complete file includes#include <cassert>, the function definition (or its header), and amain()that calls each assertion. If all pass,mainreturns 0 and we print "All tests passed." Compile withg++ -std=c++17 -o test_factorial test_factorial.cpp(without-DNDEBUG) and run the executable.Complete test driver: 6 assertions covering base case, normal path, and error path → "All tests passed."
Google Test Equivalent
TEST(FactorialTest, BaseCase) { EXPECT_EQ(factorial(0), 1); } and TEST(FactorialTest, NormalCases) { EXPECT_EQ(factorial(5), 120); EXPECT_EQ(factorial(10), 3628800); }. The framework automatically discovers and runs these tests, producing a formatted pass/fail report.SECTION 7
Comparing Testing Approaches in C++
C++ offers several mechanisms for writing test cases, ranging from the bare-bones assert macro to full-featured testing frameworks. Each approach occupies a different position on the spectrum of complexity, expressiveness, and automation. Understanding these trade-offs helps you choose the right tool for your project's maturity and scale.
| Approach | Strengths | Limitations |
|---|---|---|
| Manual print & inspect | No setup required; quick for one-off debugging | Non-repeatable; requires human judgment; does not scale |
assert macro | Zero dependencies; part of the C++ standard library; clear pass/fail semantics | Aborts on first failure; no test discovery or reporting; disabled by NDEBUG |
| Custom test harness | Full control over execution flow; can continue after failures using try/catch | Maintenance overhead; must reinvent discovery, reporting, and fixture management |
| Google Test / Catch2 | Auto-discovery; rich assertion macros; structured reporting; fixture and parameterized test support | External dependency; build-system integration required; slight learning curve |
Comparison of C++ testing approaches by complexity and capability
KEY TAKEAWAY
assert for learning and small projects—it gets you writing test cases immediately with zero configuration. As your codebase grows, migrate to a framework like Google Test or Catch2 for their superior reporting and test management capabilities. The underlying Arrange–Act–Assert logic remains identical regardless of the tool; only the assertion syntax changes.SECTION 8
Connection to Advanced Testing Techniques
The simple, hand-written test cases covered in this lesson are the foundation upon which more advanced techniques are built. As your software engineering skills mature, you will encounter test-driven development (TDD), property-based testing, fuzz testing, and mutation testing—all of which presuppose a solid understanding of basic test-case construction.
| This Lesson | Advanced Technique | Key Difference |
|---|---|---|
| Write test after implementation | TDD: Write test before implementation | Tests drive design; the Red→Green→Refactor cycle |
| Hand-chosen specific inputs | Property-based testing: Auto-generated inputs | You specify invariants (e.g., "sort(x) is always sorted"); framework generates thousands of inputs |
| Verify expected output | Fuzz testing: Detect crashes & undefined behavior | No expected output needed; the goal is to find inputs that cause the program to crash or exhibit UB |
| Assess function correctness | Mutation testing: Assess test suite quality | Deliberately injects bugs into code; a strong test suite should detect ("kill") every mutant |
How basic test-case writing connects to advanced testing paradigms
Code coverage tools such as gcov and llvm-cov can quantify what percentage of your source code is exercised by your test suite, providing a measurable metric for coverage completeness. While 100% line coverage does not guarantee the absence of bugs—it says nothing about the correctness of the assertions themselves—it does reveal untested code paths that may harbor latent defects. Integrating coverage analysis into your workflow is a natural next step after mastering manual test-case writing.
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
std::cout statements to verify program output is insufficient compared to using assert-based test cases. What fundamental property of a good test suite does manual inspection violate?Show answer
cout violates the principle of deterministic, automated repeatability. A human must read each output, mentally compare it to the expected value, and judge correctness—a process that is slow, error-prone, and non-scalable. With assert, the comparison is performed by the machine, producing an unambiguous pass/fail result. Furthermore, manual inspection cannot be integrated into continuous integration pipelines, meaning regressions introduced by later code changes may go undetected until a human happens to re-inspect the output.Question 2: BASIC CALCULATION
int abs_val(int x) that returns the absolute value of x, write three assert statements that test: (a) a positive input, (b) a negative input, and (c) zero.Show answer
assert(abs_val(7) == 7); for the positive case, assert(abs_val(-3) == 3); for the negative case, and assert(abs_val(0) == 0); for the zero boundary. These three inputs represent the three equivalence classes of the absolute value function. Note that a thorough suite might additionally test INT_MIN, which causes undefined behavior for the naive implementation return x < 0 ? -x : x due to two's complement overflow.Question 3: INTERMEDIATE
bool isPalindrome(const std::string& s) returns true if s reads the same forward and backward (case-sensitive, including spaces). Design a test suite of at least five assert statements that covers normal, boundary, and false-positive cases. Justify each test's inclusion.Show answer
assert(isPalindrome("racecar") == true); — odd-length palindrome, the canonical normal case. (2) assert(isPalindrome("abba") == true); — even-length palindrome, tests the midpoint logic. (3) assert(isPalindrome("") == true); — empty string boundary; by convention, it is a palindrome. (4) assert(isPalindrome("a") == true); — single-character boundary. (5) assert(isPalindrome("hello") == false); — a non-palindrome that ensures the function doesn't always return true. Together, these cover both equivalence classes (palindrome and non-palindrome), boundary lengths (0, 1), and both even and odd lengths.Question 4: APPLIED
char letterGrade(double score) that maps a numeric score (0.0–100.0) to a letter grade: A (≥ 90), B (≥ 80), C (≥ 70), D (≥ 60), F (< 60). Write a comprehensive test suite using the AAA pattern. Pay special attention to boundary values.Show answer
assert(letterGrade(90.0) == 'A'); and assert(letterGrade(89.9) == 'B'); — the A/B boundary. Similarly, assert(letterGrade(80.0) == 'B'); and assert(letterGrade(79.9) == 'C'); test the B/C boundary. Continue this pattern for C/D and D/F boundaries. Additionally, test extremes: assert(letterGrade(100.0) == 'A');, assert(letterGrade(0.0) == 'F');, and consider how the function handles invalid inputs like −1.0 or 101.0. Because floating-point comparisons can introduce subtle rounding issues, boundary tests at ≥ thresholds are especially critical here.Question 5: CRITICAL THINKING
std::vector<int> removeDuplicates(std::vector<int> v) is correct because it passes the test assert(removeDuplicates({1,2,2,3}) == std::vector<int>{1,2,3}). Construct at least three additional test cases that would distinguish a correct implementation from a buggy one. Explain what class of bug each test is designed to catch.Show answer
assert(removeDuplicates({}) == std::vector<int>{}); — catches implementations that crash or access out-of-bounds on empty input. (2) assert(removeDuplicates({5,5,5,5}) == std::vector<int>{5}); — catches implementations that only remove adjacent duplicates or fail when all elements are identical, leaving an empty result. (3) assert(removeDuplicates({3,1,2,1,3}) == std::vector<int>{3,1,2}); — catches implementations that assume sorted input (a common hidden assumption) and tests whether the original ordering of first occurrences is preserved. These three cases exercise empty input, uniform input, and non-sorted input with non-adjacent duplicates, covering the most common failure modes.SUMMARY
Summary
Summary
Writing test cases is the practice of systematically verifying that a function produces the correct output for a given input. Every test case follows the Arrange–Act–Assert pattern: set up inputs and expected values, invoke the function under test, and compare the actual result against the expected result using an assertion. In C++, the simplest mechanism is the assert macro from <cassert>, while production-grade projects benefit from frameworks like Google Test or Catch2 that provide richer assertion macros and automated test discovery.
Effective test suites are guided by the principles of isolation, determinism, coverage completeness, readability, and independence. Test inputs should be drawn from multiple categories—normal cases, boundary values, and error conditions—using techniques like equivalence partitioning and boundary value analysis to maximize defect-detection capability. Mastering these fundamentals prepares you for advanced techniques including test-driven development, property-based testing, and mutation testing.