Test Case Design and Edge Case Analysis Questions
Systematically deriving the cases, inputs, and conditions most likely to expose defects. Covers formal test-design techniques (equivalence partitioning, boundary value analysis, decision tables, state transitions, and pairwise/combinatorial design) and writing clear, maintainable test cases with documented expected results. Also covers the edge-case mindset: boundary conditions, invalid and unexpected inputs, corner cases, and the attention to detail that anticipates failures when validating complex behavior.
What is MC/DC (Modified Condition/Decision Coverage), how is it stronger than branch coverage, and why do safety-critical industries like avionics (DO-178C) and automotive (ISO 26262) require it?
Sample Answer
Direct answer
Modified Condition/Decision Coverage (MC/DC) requires that every individual condition inside a compound decision (like A AND B AND C) has been shown, at least once, to independently flip the overall decision's outcome while every other condition in that decision is held fixed. This is strictly stronger than branch coverage, which only requires the whole decision to be true at least once and false at least once, without ever proving which specific condition was responsible for that outcome; DO-178C (the airworthiness software-certification standard for avionics) mandates it for the most safety-critical software level, and ISO 26262 (the automotive functional-safety standard) lists it as highly recommended for the most stringent risk classification, because in both domains a compound decision with an unverified condition is exactly the shape of bug that causes a control system to do the wrong thing under a specific, rare combination of inputs.
Structured elaboration
For a decision with N independent conditions combined with AND/OR, branch coverage needs only 2 test cases (one making the whole decision true, one making it false); MC/DC needs N+1 test cases for a simple AND/OR chain, because each condition needs its OWN pair of rows that differ only in that condition and flip the outcome, and a well-chosen baseline row can be shared across all N of those pairs. The gap between the two numbers is exactly the gap in verification strength: branch coverage proves the decision CAN be true and CAN be false; MC/DC proves EACH condition individually matters to that outcome, which is what safety-critical review actually needs to certify: that the code was not left with a dead or short-circuited condition that a reviewer assumed was doing work but isn't.
Worked example
Decision: A AND B AND C. Full truth table (8 rows, run and printed programmatically, not hand-derived):
A B C -> decision
False False False -> False
False False True -> False
False True False -> False
False True True -> False
True False False -> False
True False True -> False
True True False -> False
True True True -> True
A branch-coverage suite of 2 rows: (True, True, True) -> True and (False, True, True) -> False. Checking programmatically which conditions have a demonstrated "independence pair" (two rows differing in exactly one condition, with a different outcome) in this suite: only condition A qualifies (comparing the two rows above, they differ only in A, and the outcome flips). B and C have NO independence pair anywhere in this 2-row suite, because both rows keep B and C fixed at True. This 2-row suite satisfies branch coverage (both True and False outcomes occur) but does NOT satisfy MC/DC.
An MC/DC-satisfying suite, the standard N+1=4 row construction:
| Row | A | B | C | Decision | Independence pair vs. baseline |
|---|---|---|---|---|---|
| Baseline | True | True | True | True | (n/a, this is the reference row) |
| 2 | False | True | True | False | Differs from baseline only in A; outcome flips: proves A matters |
| 3 | True | False | True | False | Differs from baseline only in B; outcome flips: proves B matters |
| 4 | True | True | False | False | Differs from baseline only in C; outcome flips: proves C matters |
Checked programmatically against all three conditions: this 4-row suite demonstrates an independence pair for A, B, AND C, so it satisfies MC/DC, while the 2-row branch-coverage suite demonstrates one only for A.
Why safety-critical industries require it: a compound decision in flight-control or automotive logic (e.g. "deploy the airbag if crash-force exceeds threshold AND seatbelt is unbuckled AND occupant-weight sensor confirms a person is present") is exactly the shape where a branch-coverage-only suite can pass while one condition is silently dead code, for instance a coding error that ANDs in a condition that always evaluates the same way given how the other conditions in practice co-occur. Because compound safety decisions like this rarely get exercised in the wild the way ordinary application logic does (the failure mode is by definition rare and dangerous), review-time proof that every condition independently matters is the only realistic way to catch that class of bug before certification, which is precisely why DO-178C makes MC/DC mandatory for Level A (catastrophic-failure-condition) software, and why ISO 26262 treats it as the strongest recommended technique for Automotive Safety Integrity Level D (ASIL D), its highest automotive risk classification.
Trade-offs and pitfalls
The most common mistake is assuming a high branch-coverage percentage is "basically" MC/DC; the worked example shows a suite that is 100% branch-covered while proving only 1 of 3 conditions independently matters, a large and dangerous gap for exactly the software where this matters most. A second pitfall is applying MC/DC uniformly across an entire codebase: it is expensive to construct and maintain (every added condition to an existing decision requires re-deriving the independence pairs), so it should be reserved for the highest-criticality decisions rather than treated as a blanket coverage target for ordinary application code, where branch coverage plus good judgment is proportionate. Finally, "masking" MC/DC (the common relaxed variant, versus the stricter "unique-cause" MC/DC) allows a condition's independence to be demonstrated even when other conditions change too, as long as their change provably could not have caused the outcome flip on its own; teams should know which variant their tooling and certification target actually requires, since they are not interchangeable for audit purposes.
In C, describe common off-by-one error patterns in loops and array indexing. Provide three concrete code snippets that contain off-by-one bugs (for example, reading/writing one past the end, incorrect <= vs < boundary, and improper use of strlen), then show corrected versions. Explain how you would write unit tests or use static analysis to catch each pattern before release.
Sample Answer
Direct answer
Off-by-one bugs in C cluster into three recurring shapes: indexing one element past a buffer's valid range, using the wrong relational operator (<= where < is correct, or the reverse) as a loop bound, and sizing a buffer from strlen() while forgetting it excludes the terminating null byte. All three are silent right up until the wrong byte happens to matter, so catching them before release needs boundary-focused unit tests plus a memory-safety sanitizer, not code review by inspection.
Structured elaboration
For an array of n elements, the valid indices are 0 through n-1. Every pattern below is a different way of accidentally touching index n:
- Reading/writing one past the end: a helper that wants "the last element" and writes
arr[n]instead ofarr[n-1], or a fill loop that writes one extra element beyond the buffer it was given. - Incorrect
<=vs<boundary: a loop written asfor (i = 0; i <= n; i++)instead ofi < n. This is the single most common root cause of pattern 1 in practice: someone mentally reads "process n elements" as "count up to and including n" instead of "count up to but excluding n". - Improper use of
strlen():strlen(s)returns the number of characters in a C string, NOT counting the terminating'\0'byte. Allocating exactlystrlen(s)bytes and then callingstrcpy()(which always writes the terminator) overflows the allocation by exactly one byte.
Worked example (executed under AddressSanitizer and UndefinedBehaviorSanitizer)
Pattern 1: reading one past the end
/* BUG: valid indices are 0..n-1, so arr[n] is one past the end */
int last_element_buggy(const int *arr, int n) {
return arr[n];
}
/* FIX: the last valid index is n-1 */
int last_element_fixed(const int *arr, int n) {
return arr[n - 1];
}
Compiling the buggy version with clang -g -fsanitize=address,undefined and running it against a 5-element array produced:
==...==ERROR: AddressSanitizer: stack-buffer-overflow ... READ of size 4 ...
#0 ... in last_element_buggy bug1_write_oob_buggy.c:6
SUMMARY: AddressSanitizer: stack-buffer-overflow bug1_write_oob_buggy.c:6 in last_element_buggy
The fixed version compiled and ran identically (same sanitizer flags) and exited 0, printing last=50 with no diagnostic.
Pattern 2: incorrect <= vs < loop boundary
/* BUG: i <= n reads arr[n], one element past the valid range 0..n-1 */
int sum_array_buggy(const int *arr, int n) {
int sum = 0;
for (int i = 0; i <= n; i++) sum += arr[i];
return sum;
}
/* FIX: i < n visits exactly the valid indices */
int sum_array_fixed(const int *arr, int n) {
int sum = 0;
for (int i = 0; i < n; i++) sum += arr[i];
return sum;
}
The buggy version, run the same way, produced the same class of diagnostic (AddressSanitizer: stack-buffer-overflow ... sum_array_buggy bug2_loop_boundary_buggy.c:8); the fixed version exited 0 and printed sum=15 for {1,2,3,4,5}.
Pattern 3: improper use of strlen()
/* BUG: strlen() excludes the '\0', so this buffer is one byte too small */
char *dup_string_buggy(const char *s) {
char *out = malloc(strlen(s));
strcpy(out, s);
return out;
}
/* FIX: allocate strlen(s) + 1 bytes for the terminator */
char *dup_string_fixed(const char *s) {
char *out = malloc(strlen(s) + 1);
strcpy(out, s);
return out;
}
Running the buggy version against "hello" produced:
==...==ERROR: AddressSanitizer: heap-buffer-overflow ... WRITE of size 6 ...
#0 ... in strcpy+0x...
#1 ... in dup_string_buggy bug3_strlen_buggy.c:10
0x... is located 0 bytes after 5-byte region ...
SUMMARY: AddressSanitizer: heap-buffer-overflow bug3_strlen_buggy.c:10 in dup_string_buggy
The fixed version exited 0 and printed copy=hello.
Catching these before release
- Unit tests focused on boundary inputs, not just "typical" ones: for any function parameterized by a count
n, testn = 0,n = 1, andn =the buffer's exact declared size, since a mid-range input fornfrequently never exercises the off-by-one at all. Run that suite under AddressSanitizer and UndefinedBehaviorSanitizer (-fsanitize=address,undefined) so an out-of-bounds access that happens not to crash on its own still gets flagged deterministically, as shown above. When recompiling with sanitizers isn't an option (for example, testing a pre-built third-party binary), Valgrind's memcheck tool provides equivalent detection at the cost of much slower execution. - Static analysis, which finds these patterns without ever running the code: compiler warnings (
-Wall -Wextra, plus-Warray-boundson both GCC and Clang, and GCC's-Wstringop-overflowspecifically for thestrlen()/strcpy()sizing pattern), and a dedicated static analyzer such as cppcheck or clang-tidy, both of which flag off-by-one loop bounds and themalloc(strlen(s))-without-+1pattern directly in source, before compilation even needs sanitizer instrumentation.
Trade-offs & pitfalls
A sanitizer only catches a bug on the code PATH it actually executes, so a test suite with high line coverage but no boundary-focused inputs can still miss all three patterns, since a "normal" mid-range input for n will typically never touch the exact off-by-one boundary; coverage percentage and boundary coverage are different things. Static analyzers trade off false positives (flagging safe code, which trains engineers to start ignoring their output) against false negatives on pointer arithmetic that crosses function boundaries, so neither tool alone is sufficient; the combination (static analysis pre-commit, sanitizer-instrumented tests in the pipeline) covers more than either does alone. A common wrong turn after finding one of these bugs is fixing it locally without adding a regression test pinned to the exact boundary that triggered it, so a later refactor can silently reintroduce the same off-by-one with nothing to catch it.
You must test a binary protocol parser implemented in C++ for robustness against malformed inputs and security vulnerabilities. Propose a combined testing approach using coverage-guided fuzzing (libFuzzer), grammar-based input generation, Address/Memory/UndefinedBehavior Sanitizers, and property checks. Explain how to triage, minimize, and reproduce any memory-safety crashes found, and how to integrate this into a CI pipeline with acceptable resource bounds.
Sample Answer
Direct answer
Testing a binary protocol parser for robustness needs coverage-guided fuzzing (an automated technique that mutates inputs guided by which code paths they newly exercise, most commonly libFuzzer for C++) to find inputs that crash or hang the parser, grammar-based generation to reach deep parser states random mutation alone rarely finds, sanitizers (AddressSanitizer/MemorySanitizer/UndefinedBehaviorSanitizer, compiler-inserted runtime checks for memory-safety and undefined-behavior bugs) compiled into the fuzz target so crashes are caught at the exact faulting instruction instead of surfacing later as silent corruption, and property checks asserting protocol-level invariants (e.g. a successfully parsed message's declared length actually matches its consumed byte count) beyond just "did not crash."
Structured elaboration
Coverage-guided fuzzing with libFuzzer. The parser function is wrapped in a fuzz target (LLVMFuzzerTestOneInput) that libFuzzer repeatedly calls with mutated byte strings, using code-coverage feedback (instrumented at compile time) to bias mutation toward inputs that reach new branches; this is fundamentally different from random black-box fuzzing because it actively searches toward under-explored parser states.
Grammar-based input generation. For a structured binary format, pure random mutation rarely produces a well-formed-enough header to reach the interesting payload-parsing logic (e.g. a random byte string almost never has a valid length-prefix that survives an early bounds check). A grammar-aware generator (or a well-chosen seed corpus of valid messages that the mutator perturbs field-by-field, e.g. libFuzzer's structure-aware fuzzing hooks, or a custom mutator) gets past the header far more often, spending the fuzzing budget on the payload logic instead of rejecting malformed headers.
Sanitizers. AddressSanitizer catches out-of-bounds reads/writes and use-after-free at the instant they happen, with the exact faulting line in the stack trace; MemorySanitizer catches use of uninitialized memory (relevant for parsers that read a length field but not the corresponding data); UndefinedBehaviorSanitizer catches integer overflow and other spec-undefined operations. All three should be compiled in during fuzzing (ASan is the default pairing with libFuzzer); running fuzzing WITHOUT sanitizers only finds crashes, not the much larger class of memory-safety bugs that corrupt state silently and crash somewhere unrelated later, or never crash at all in a way a test would notice.
Property checks beyond crash detection. Assert protocol invariants directly: a parsed message's reported length equals the number of bytes actually consumed, a parse that reports "invalid" never partially mutates caller-visible state, and round-tripping a successfully parsed message back through a serializer reproduces the original bytes (a differential property, valuable because a parser can silently misinterpret a field without ever crashing).
Triage, minimization, reproduction. A crashing input libFuzzer finds is first minimized (libFuzzer's -minimize_crash=1 repeatedly shrinks the input while confirming it still crashes, producing a small reproducer instead of the original large mutated blob), then triaged by sanitizer report type and stack trace (deduplicating crashes that hit the same faulting line), then added permanently to the seed corpus and as a regression test so the exact bug can never silently resurface.
CI integration with resource bounds. Run a short fuzzing session (bounded by wall-clock time or iteration count, e.g. a fixed few minutes per PR) as a required check on every change to the parser, and a longer unbounded/continuous fuzzing campaign (e.g. hours to days) on a schedule against the corpus accumulated so far, since PR-time fuzzing catches shallow regressions cheaply while the continuous campaign is what actually explores deep parser states over time; cap CPU and memory per fuzz worker explicitly so a pathological input that also happens to be slow (an algorithmic-complexity issue, not just a memory-safety one) cannot stall the whole CI pipeline.
Worked example (executed)
A toy length-prefixed parser ([type:1 byte][len:2 bytes little-endian][len bytes payload]) with the exact bug this technique targets: it reads len from the wire and constructs a std::string from len bytes starting right after the header, without checking len against the actual remaining buffer size.
std::string parseMessage(const uint8_t *data, size_t size) {
if (size < 3) return "";
uint16_t len = static_cast<uint16_t>(data[1]) | (static_cast<uint16_t>(data[2]) << 8);
std::string payload(reinterpret_cast<const char *>(data + 3), len); // OVERREAD if len > size-3
return payload;
}
Compiled with -fsanitize=address and run against a well-formed input ({1, 2, 0, 'h', 'i'}, type=1, len=2, payload="hi"), it parses correctly. Run against a hand-crafted malformed input that a real fuzzing campaign would eventually generate from that same seed ({1, 200, 0}: header claims a 200-byte payload but the buffer is only 3 bytes total), AddressSanitizer immediately reported:
==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
READ of size 200 at 0x... thread T0
#0 ... in memcpy
#1 ... in std::__1::basic_string<...>::__init(char const*, unsigned long)
#4 0x... in parseMessage(unsigned char const*, unsigned long) parser.cpp:16
SUMMARY: AddressSanitizer: stack-buffer-overflow ... in parseMessage
This is a real, ASan-caught stack-buffer-overflow, demonstrating the mechanism directly rather than narrating it: the READ of size 200 line confirms ASan caught the parser attempting to read 200 bytes it does not have, at the exact std::string payload(...) construction (parser.cpp:16). A full libFuzzer mutation campaign was not run in this environment (the libFuzzer runtime library was not available in this sandbox toolchain), but it is the identical bug class libFuzzer's coverage-guided mutation from a valid seed corpus entry would eventually rediscover and report through this exact ASan crash path; the fix is a bounds check (if (len > size - 3) return {};) before constructing payload.
Trade-offs and pitfalls
Running ASan and coverage instrumentation together roughly doubles memory use and meaningfully slows execution (both well-documented properties of sanitizer instrumentation, not a specific multiplier claimed here since it depends on the target), which is a real cost in a CI resource budget and is exactly why the PR-time fuzzing window should be short and the deep continuous campaign should run on a schedule, not on every commit. A common mistake is fuzzing WITHOUT a seed corpus of valid messages, which for a binary format wastes almost the entire mutation budget on inputs that fail the earliest structural check; a second common mistake is treating "libFuzzer ran for N hours without a crash" as proof of safety, when it only proves no crashing input was found in that time and that corpus, which is why the seed corpus and coverage numbers (not just elapsed time) are the meaningful signal for how much of the parser was actually exercised.
That is every published Test Case Design and Edge Case Analysis question for Embedded Developer so far. Browse the other topics in this category, or practice this one interactively.