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++.

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.

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.

Visual Explanation — The Arrange–Act–Assert Pattern

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.

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)
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
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.

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.

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.

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 contractBefore 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
Step 2 — Write the base-case testArrange: 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
Step 3 — Write normal-case testsSelect 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
Step 4 — Write error-case testsPer 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
Step 5 — Assemble the complete test fileCombine 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.

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.

Comparison of C++ testing approaches by complexity and capability
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
✦ 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.

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.

How basic test-case writing connects to advanced testing paradigms
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

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.

Practice Problems

PROBLEM 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?
PROBLEM 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.
PROBLEM 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.
PROBLEM 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.
PROBLEM 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.

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