Debugging & Testing
A program without tests is a hypothesis. A program with bugs and no debugging strategy is a mystery. This skill catalogs systematic approaches to both: finding defects (debugging) and preventing them (testing). The emphasis is on method over intuition -- debugging and testing are engineering disciplines with established techniques, not arts that depend on talent.
Agent affinity: hopper (coined "debugging" when she found a moth in the Mark II), dijkstra (program correctness as a mathematical property)
Concept IDs: code-debugging-strategies, code-iterative-development, code-peer-review
Part 1 -- The Debugging Mindset
Debugging as Scientific Method
A bug is a hypothesis falsifier: your mental model of the program says X should happen, but Y happens instead. Debugging is the process of updating your mental model until it matches reality.
- Observe the symptom. What actually happened? What did you expect?
- Hypothesize. What could cause the discrepancy? List at least three candidates.
- Predict. If hypothesis H is true, what would happen if I do experiment E?
- Test. Perform the experiment. Does the result match the prediction?
- Iterate. If the prediction was wrong, the hypothesis is eliminated. Try the next one.
This is the scientific method applied to code. The most common debugging mistake is skipping step 2 -- changing things at random hoping the bug disappears. Random changes do not build understanding. They may mask the bug or introduce new ones.
Grace Hopper's Debugging Legacy
In 1947, operators of the Harvard Mark II found a moth trapped in a relay, causing a malfunction. Grace Hopper taped the moth into the logbook with the note "First actual case of bug being found." The term "debugging" predates this incident, but Hopper's story crystallized the concept: finding and removing defects is a first-class engineering activity, not a sign of failure.
Hopper's broader contribution to debugging was the invention of the compiler. Before compilers, programs were written in machine code, and every error was a numerical one. Compilers introduced symbolic names, structured control flow, and -- critically -- error messages. The compiler was the first automated debugging tool.
Part 2 -- Debugging Techniques
2.1 -- Printf / Logging Debugging
Technique: Insert print statements or log calls at strategic points to observe the program's state as it executes.
When to use. First response for any bug. Fast to deploy, works in any language, requires no special tools. Especially valuable in environments where interactive debuggers are impractical (distributed systems, embedded systems, CI pipelines).
Best practices.
- Log the function name, key variable values, and the decision path taken.
- Use structured logging (JSON) for production systems so logs are searchable.
- Remove or disable debug prints before committing. Use log levels (DEBUG, INFO, WARN, ERROR) to control verbosity without removing code.
Limitation. Heisenbug risk: adding print statements can change timing, which may mask concurrency bugs.
2.2 -- Interactive Debuggers
Technique: Pause execution at breakpoints, inspect variables, step through code line by line.
Tools. GDB (C/C++), LLDB (C/C++/Rust), pdb (Python), Chrome DevTools (JavaScript), VS Code debugger (multi-language).
Key operations.
- Breakpoint: Pause when execution reaches a specific line.
- Conditional breakpoint: Pause only when a condition is true (e.g.,
i == 999). - Watch expression: Monitor a variable and pause when its value changes.
- Step over: Execute the current line, skip into function calls.
- Step into: Enter the function being called.
- Step out: Execute until the current function returns.
When to use. Complex control flow where printf would require dozens of statements. Inspecting data structures that are hard to print (circular references, large trees). Understanding unfamiliar code.
2.3 -- Binary Search Debugging
Technique: Narrow the problem to the smallest possible scope by halving the search space at each step.
Application to time. If a bug appeared recently, use git bisect to binary-search through commits: mark a known-good commit and a known-bad commit, test the midpoint, repeat. In O(log n) steps, find the exact commit that introduced the bug.
Application to code. Comment out half the code. Does the bug persist? If yes, the bug is in the remaining half. If no, the bug is in the commented half. Repeat.
Application to data. If the bug occurs with a large input, halve the input. Does the bug persist? Binary search to find the minimal reproducing input.
2.4 -- Rubber Duck Debugging
Technique: Explain the code, line by line, to an inanimate object (traditionally a rubber duck). The act of articulating your assumptions often reveals the one that is wrong.
Why it works. Reading code silently allows your brain to skip over details. Speaking forces you to process each line consciously. The mismatch between what you say and what the code does surfaces the bug.
Formal variant. Code review. Explaining your code to a colleague achieves the same effect with the added benefit of a second perspective.
2.5 -- Time-Travel Debugging
Technique: Record program execution and replay it, stepping backward and forward through time.
Tools. rr (Linux, C/C++/Rust), Replay.io (JavaScript), IntelliJ's step-back feature.
When to use. Concurrency bugs, non-deterministic failures, bugs that are hard to reproduce. Time-travel debugging eliminates the need to reproduce the bug -- you debug the exact execution that failed.
2.6 -- Git Bisect
Technique: Automated binary search through git history to find the commit that introduced a bug.
Usage.
git bisect start
git bisect bad # Current commit has the bug
git bisect good abc1234 # This commit was known-good
# Git checks out the midpoint. Test it.
git bisect good # or: git bisect bad
# Repeat until the first bad commit is found.
git bisect reset # Return to original state
Automation. git bisect run ./test.sh -- provide a script that returns 0 for good and non-zero for bad. Git runs the binary search automatically.
Prerequisite. Each commit must be independently testable. This is one reason to keep commits small and atomic.
Part 3 -- Testing Levels
3.1 -- Unit Tests
Scope: Test a single function, method, or class in isolation.
Characteristics. Fast (milliseconds per test), deterministic (no I/O, no network, no database), focused (one assertion per logical concept).
Isolation techniques. Mocks (simulate dependencies), stubs (provide canned answers), fakes (simplified implementations, e.g., in-memory database).
When to use. Every pure function. Every method with business logic. The foundation of the test pyramid.
3.2 -- Integration Tests
Scope: Test the interaction between two or more components (e.g., service + database, API + authentication).
Characteristics. Slower than unit tests, may require real infrastructure (database, message queue), tests contracts between components.
When to use. Validating that the database layer correctly persists and retrieves data. Ensuring that API endpoints handle real HTTP requests. Testing message serialization/deserialization across service boundaries.
3.3 -- End-to-End Tests
Scope: Test the entire system from the user's perspective. UI clicks through backend to database and back.
Characteristics. Slowest, most brittle (sensitive to UI changes, timing, environment), most realistic.
Tools. Playwright, Cypress, Selenium (web), Appium (mobile).
When to use. Sparingly. Cover the critical user journeys (signup, purchase, core workflow). Too many E2E tests create a slow, flaky test suite that developers learn to igno