C++ • Debugging and Testing

Writing Test Cases — Write simple test cases and verify expected outputs

Master the discipline of verifying program correctness through systematic, repeatable test cases in C++.

Programming braces with a command terminal and binary digits.
Inside this lesson 10 sections 5 questions 6 figures

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

Programmers manually trace machine code and insert print statements to verify intermediate values. Testing is informal and entirely manual.

1979 • Myers' The Art of Software Testing

Glenford Myers publishes a seminal text defining testing as the process of executing a program with the intent of finding faults, shifting the mindset from 'proving correctness' to 'revealing defects.'

1987 • SUnit for Smalltalk

Kent Beck creates SUnit, the first modern unit-testing framework, introducing the pattern of automated assert-based verification that would influence all subsequent frameworks.

1998 • JUnit and xUnit Proliferation

Beck and Erich Gamma port SUnit to Java as JUnit. The xUnit family of frameworks expands across languages, including CppUnit for C++.

2003–Present • Google Test & Modern C++ Testing

Google releases Google Test (gtest), which becomes the de facto standard for C++ unit testing. Catch2 and doctest offer header-only alternatives. Test-driven development (TDD) gains mainstream adoption.

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.

1

Isolation

Each test case should exercise exactly one behavior of one function. Isolating tests prevents cascading failures and makes it immediately clear which piece of logic is broken when a test fails.
2

Determinism

A test must produce the same result every time it runs, regardless of the environment. Avoid dependencies on system time, random numbers, or external files unless those are explicitly controlled within the test.
3

Completeness of Coverage

Test the normal path, boundary conditions, and error conditions. A function that accepts an integer should be tested with positive, negative, zero, and extreme values (e.g., INT_MAX).
4

Readability

A failing test is a diagnostic tool. Its name, structure, and assertions should make the failure's cause obvious without requiring a debugger. Descriptive names like 'addReturnsZeroForEmptyList' are preferred.
5

Independence

Tests should not depend on the order in which they are executed. Each test must set up its own preconditions and clean up after itself, ensuring that modifying one test never breaks another.
✦

KEY TAKEAWAY

Think of writing test cases like designing scientific experiments in a laboratory. Each experiment (test case) changes exactly one variable (input), keeps all other conditions controlled (isolation), and measures the outcome against a predicted result (assertion). Just as a chemist would never trust a single measurement without replication, a software engineer should never trust code without repeatable, automated verification.

SECTION 3

Visual Explanation — The Arrange–Act–Assert Pattern

Arrange–Act–Assert Test Case FlowARRANGEint a = 5;int b = 3;int expected = 8;Set up inputs & expected valuesACTint actual = add(a, b);Invoke function under testASSERTassert( actual == expected);Compare actual vs. expected✓PASSactual == expected → test passes, function behaves correctly for this input✗FAILactual != expected → test fails, indicating a defect in the function logicEach test case follows this three-phase structure to produce a binary pass/fail verdict.
The diagram illustrates the three-phase structure of every test case. In the Arrange phase, you declare inputs and the expected output. The Act phase invokes the function under test with those inputs. Finally, the Assert phase compares the actual result against the expected value, producing a binary pass or fail verdict.

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

assert(actual==expected)assert(actual == expected)

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

The 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

EXPECTEQ(actual,expected)EXPECT_EQ(actual, expected)

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.

Test Case Categories for a Functionint divide(int numerator, int denominator)Normal Casesdivide(10, 2) → 5divide(9, 3) → 3divide(100, 4) → 25Typical, valid inputsBoundary Casesdivide(0, 5) → 0divide(1, 1) → 1divide(INT_MAX, 1)Edge of valid domainError Casesdivide(5, 0) → ???divide(INT_MIN, -1)→ overflow!Invalid or pathologicalNegative Inputsdivide(-10, 2) → -5divide(10, -2) → -5divide(-10, -2) → 5Sign handlingIdentity / Specialdivide(x, 1) → xdivide(x, x) → 1divide(x, -1) → -xMathematical propertiesTruncationdivide(7, 2) → 3divide(-7, 2) → -3(truncates toward zero)Integer division behaviorCoverage StrategySelect at least one test case from each category to achieve comprehensive behavioral coverage.Total: ≥ 6 test cases for a simple two-parameter integer functionThe number of categories grows with the complexity of the function's interface.
This diagram classifies test cases for a simple 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. 1

    Step 1 — Identify the function contract

    Before writing any test, define the expected behavior precisely. The function factorial(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. 2

    Step 2 — Write the base-case test

    Arrange: set n = 0, expected = 1. Act: call factorial(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. 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, and factorial(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. 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) and assert(factorial(-100) == -1). This ensures the error-handling code path is exercised.
    assert(factorial(-1) == -1); assert(factorial(-100) == -1);
  5. 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 a main() that calls each assertion. If all pass, main returns 0 and we print "All tests passed." Compile with g++ -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

In Google Test, the same logic becomes: 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.

ApproachStrengthsLimitations
Manual print & inspectNo setup required; quick for one-off debuggingNon-repeatable; requires human judgment; does not scale
assert macroZero dependencies; part of the C++ standard library; clear pass/fail semanticsAborts on first failure; no test discovery or reporting; disabled by NDEBUG
Custom test harnessFull control over execution flow; can continue after failures using try/catchMaintenance overhead; must reinvent discovery, reporting, and fixture management
Google Test / Catch2Auto-discovery; rich assertion macros; structured reporting; fixture and parameterized test supportExternal dependency; build-system integration required; slight learning curve

Comparison of C++ testing approaches by complexity and capability

✦

KEY TAKEAWAY

Start with 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 LessonAdvanced TechniqueKey Difference
Write test after implementationTDD: Write test before implementationTests drive design; the Red→Green→Refactor cycle
Hand-chosen specific inputsProperty-based testing: Auto-generated inputsYou specify invariants (e.g., "sort(x) is always sorted"); framework generates thousands of inputs
Verify expected outputFuzz testing: Detect crashes & undefined behaviorNo expected output needed; the goal is to find inputs that cause the program to crash or exhibit UB
Assess function correctnessMutation testing: Assess test suite qualityDeliberately 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

Explain why relying solely on 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
Manual inspection via 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

Given a function 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
The three assertions would be: 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

A function 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
A well-designed suite includes: (1) 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

You are developing a grade-calculation module with a function 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
Boundary value analysis dictates testing at the exact threshold values where behavior changes. Critical tests include: 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

A colleague claims their implementation of 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
The colleague's single test is far too narrow. (1) 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.

Varsity Tutors • C++ • Writing Test Cases — Write simple test cases and verify expected outputs