Suppose you write a function for your school's result-analysis program. It takes a student's marks and returns their grade letter — something every student in your class will see on their report card. You test it by hand: you type in 95, it says "A1". You type in 40, it says "C2". Looks right, so you move on to the next part of the program.
Three weeks later, a classmate reports that a student who scored exactly 91 marks got graded "A2" instead of "A1" on the practice sheet your program generated. You go back and stare at the function. It "worked" every time you tried it. Nothing crashed. No error message ever appeared. And yet, for this one specific number, it silently gave the wrong answer — and it had probably been doing this since the day you wrote it, for every student who ever scored exactly on a boundary.
This is the problem unit testing exists to solve. It is not about making your code "more professional" in some vague sense. It is about a very specific, very common failure mode: code that runs without crashing but computes the wrong answer for inputs you didn't happen to try by hand. Manual testing — typing a few values and eyeballing the output — only checks the inputs you thought of, and it checks them exactly once. The moment you change one line of code six weeks later, you have to remember to re-check all of those values again, by hand, correctly, forever. Nobody does this reliably. Unit testing is how you make the computer do that checking for you, automatically, every single time, for every input you care about.
What exactly is a "unit"?
A unit is the smallest independently testable piece of your program — almost always a single function or method, tested on its own, separate from the rest of the program around it. Unit testing means writing code whose only job is to call that one function with specific inputs and automatically check that the output matches what you expect.
This is different from testing your whole program by running it and clicking through it — that is called integration testing or system testing, because it checks whether many units work correctly together. Unit testing is narrower and more precise: it isolates one function and interrogates it directly, which is exactly why it's good at catching the kind of boundary bug in the story above. When you test the whole program end to end, a bug in one small function can be masked, delayed, or misattributed to a completely different part of the code. When you unit test that function directly, the bug has nowhere to hide.
Building the function that hides a bug
Let's build the exact function from the story. CBSE has long used a well-known 9-point scale to convert Class 10 marks out of 100 into a grade letter:
- 91–100 → A1
- 81–90 → A2
- 71–80 → B1
- 61–70 → B2
- 51–60 → C1
- 41–50 → C2
- 33–40 → D
- 21–32 → E1
- 0–20 → E2
Notice each band's lower boundary: A1 starts at 91, not above it. A2 starts at 81. This detail matters enormously for how we write the comparisons — it's exactly where our bug will hide. Here is a first attempt, which we'll call Version 1:
def get_grade(marks):
if marks > 91:
return "A1"
elif marks > 81:
return "A2"
elif marks > 71:
return "B1"
elif marks > 61:
return "B2"
elif marks > 51:
return "C1"
elif marks > 41:
return "C2"
elif marks > 33:
return "D"
elif marks > 21:
return "E1"
else:
return "E2"
Try a few "obvious" values by hand and it looks perfect. get_grade(95): 95 > 91 is True, so it returns "A1" — correct. get_grade(40): falls through to 40 > 33, returns "D" — correct, since 40 is in the 33–40 band. This is exactly how the bug survives manual testing: the values a programmer naturally reaches for (round numbers, clearly-inside-a-band numbers) never expose it.
Now trace get_grade(91) carefully, line by line, the way Python's interpreter actually executes it:
marks > 91→ is 91 > 91? No. Move to the next condition.marks > 81→ is 91 > 81? Yes. Return "A2".
The function returns "A2". But according to the table, 91 belongs in the A1 band (91–100). This is a classic off-by-one / boundary bug: every comparison in Version 1 uses strict > instead of >=, so any mark that lands exactly on a band's lower edge gets pushed one band too low. It isn't only 91 — trace get_grade(81) yourself: 81 > 91 is False, 81 > 81 is False (81 is not strictly greater than itself), 81 > 71 is True, so it returns "B1" instead of the correct "A2". Every single lower boundary — 91, 81, 71, 61, 51, 41, 33, 21 — is misclassified into the band directly below it. Only the very bottom of the scale (marks below 21, caught by the final else) is unaffected, because that branch has no boundary comparison to get wrong.
There's a second, quieter problem. Nothing in Version 1 stops you from calling get_grade(-5) or get_grade(150) — marks that can never legitimately occur. Trace get_grade(-5): every > comparison fails, so it falls all the way to else and returns "E2" — a normal-looking grade for a mark that should never have been accepted in the first place. Trace get_grade(150): 150 > 91 is True, so it confidently returns "A1" for an impossible score. The function never complains. It just quietly produces plausible-looking nonsense.
The diagram: seeing where Version 1 breaks
Checking by hand: the assert statement
Python gives you a direct way to state "this had better be true" — the assert keyword. If the condition after assert is true, nothing happens and execution continues. If it's false, Python immediately raises an AssertionError and the program stops right there.
assert get_grade(95) == "A1"
assert get_grade(91) == "A1" # this is where it breaks
print("All checks passed!")
Trace it: the first line calls get_grade(95), gets back "A1", compares it to "A1" — true, nothing happens, execution moves on. The second line calls get_grade(91), gets back "A2" (as we traced above), compares it to "A1" — false. Python raises AssertionError immediately. The third line — the print statement — never runs. You get a crash with a traceback pointing at line 2, and no summary of anything else.
This is useful, but it has two real limitations. First, it stops at the very first failure — if there were five other bugs further down the list, you'd never find out about them in this run; you'd fix this one, rerun, hit the next one, fix it, rerun again. Second, there is no report — no "6 checks, 4 failed, here's which ones." For a function with one bug, that's tolerable. For a real program, it's unworkable.
A test harness that checks everything and reports back
Instead of stopping at the first failed assert, we can loop over a list of (input, expected-output) pairs, catch each mismatch instead of crashing, and print a full report at the end:
test_cases = [
(95, "A1"),
(91, "A1"),
(81, "A2"),
(33, "D"),
(21, "E1"),
(0, "E2"),
]
def run_tests():
passed = 0
failed = 0
for marks, expected in test_cases:
actual = get_grade(marks)
if actual == expected:
passed += 1
else:
failed += 1
print(f"FAIL: get_grade({marks}) returned {actual!r}, expected {expected!r}")
print(f"{passed} passed, {failed} failed")
run_tests()
One small detail: inside the f-string, {actual!r} uses the !r conversion, which prints the value's repr — its "programmer-readable" form — rather than its plain string. For a string value, this wraps it in quotes, so you see 'A2' instead of just A2. That distinction matters when debugging: it makes clear you got back the text "A2" and not, say, a number or an empty string that happens to look similar when printed plainly.
Run run_tests() against Version 1. Trace each pair: (95, "A1") → actual is "A1" (95 > 91) → matches, passed. (91, "A1") → actual is "A2" as traced earlier → mismatch, printed. (81, "A2") → trace: 81 > 91 F, 81 > 81 F, 81 > 71 T → "B1" → mismatch. (33, "D") → trace down to 33 > 33 F, then 33 > 21 T → "E1" → mismatch. (21, "E1") → trace down to 21 > 21 F, falls to else → "E2" → mismatch. (0, "E2") → every condition false, else → "E2" → matches. The output is:
FAIL: get_grade(91) returned 'A2', expected 'A1'
FAIL: get_grade(81) returned 'B1', expected 'A2'
FAIL: get_grade(33) returned 'E1', expected 'D'
FAIL: get_grade(21) returned 'E2', expected 'E1'
2 passed, 4 failed
Now every boundary bug shows up in one run, with no manual re-checking. But notice this harness still can't test the missing-validation problem — it only compares returned values, and has no way to say "this call should raise an error instead of returning anything." For that we need a proper testing tool.
Python's built-in testing tool: unittest
Python ships with a module called unittest designed exactly for this. You subclass unittest.TestCase, and write one method per thing you want to check — each method name must start with test_ so the framework can find it automatically:
import unittest
class TestGetGrade(unittest.TestCase):
def test_bottom_of_A1_band(self):
self.assertEqual(get_grade(91), "A1")
def test_bottom_of_A2_band(self):
self.assertEqual(get_grade(81), "A2")
def test_bottom_of_D_band(self):
self.assertEqual(get_grade(33), "D")
def test_top_of_range(self):
self.assertEqual(get_grade(100), "A1")
def test_marks_above_100_is_invalid(self):
with self.assertRaises(ValueError):
get_grade(105)
def test_negative_marks_is_invalid(self):
with self.assertRaises(ValueError):
get_grade(-5)
if __name__ == "__main__":
unittest.main()
A few building blocks here are worth naming precisely, since they're the vocabulary you'll reuse in every unit test you ever write: assertEqual(a, b) checks two values are equal (and prints both if they aren't); assertTrue/assertFalse check a condition; assertRaises(SomeError), used with with, checks that the code inside the block raises that specific exception — if it doesn't, the test itself fails. Each individual test_... method is a test case; the whole TestGetGrade class, holding several related test cases, is a test suite.
Run this file against Version 1. By default, unittest runs test methods in alphabetical order by name, and its default output is deliberately terse: one character per test — a dot for a pass, an F for a failure, an E for an unexpected error — followed by tracebacks for anything that failed, and a final tally line. It does not, by default, print a labeled line for every test method; that level of detail requires running with the -v (verbose) flag, which instead prints each test's name followed by ok or FAIL.
Trace what happens on Version 1, in alphabetical order: test_bottom_of_A1_band expects "A1" for 91, gets "A2" — FAIL. test_bottom_of_A2_band expects "A2" for 81, gets "B1" — FAIL. test_bottom_of_D_band expects "D" for 33, gets "E1" — FAIL. test_marks_above_100_is_invalid expects a ValueError when calling get_grade(105), but Version 1 just returns "A1" quietly — no exception was raised, so assertRaises itself fails the test. test_negative_marks_is_invalid expects a ValueError for get_grade(-5), but Version 1 returns "E2" quietly — FAIL. test_top_of_range expects "A1" for 100, which Version 1 gets right (100 > 91) — this one passes. The default terse output reads:
FFFFF.
======================================================================
FAIL: test_bottom_of_A1_band (__main__.TestGetGrade)
----------------------------------------------------------------------
AssertionError: 'A2' != 'A1'
...
----------------------------------------------------------------------
Ran 6 tests in 0.001s
FAILED (failures=5)
Five failures out of six, exactly matching what we traced by hand.
Fixing it: Version 2
Two separate defects need fixing: the strict > comparisons need to become >= so a mark sitting exactly on a lower boundary lands in the correct (higher) band, and the function needs to reject out-of-range input instead of silently returning a plausible-looking wrong answer. Here is Version 2, which fixes both at once:
def get_grade(marks):
if not (0 <= marks <= 100):
raise ValueError(f"marks must be between 0 and 100, got {marks}")
if marks >= 91:
return "A1"
elif marks >= 81:
return "A2"
elif marks >= 71:
return "B1"
elif marks >= 61:
return "B2"
elif marks >= 51:
return "C1"
elif marks >= 41:
return "C2"
elif marks >= 33:
return "D"
elif marks >= 21:
return "E1"
else:
return "E2"
Trace get_grade(91) against Version 2: the validation check 0 <= 91 <= 100 is true, so no error is raised. Then 91 >= 91 is True → returns "A1". Correct. Trace get_grade(105): 0 <= 105 <= 100 is False, so not(...) is True → ValueError is raised immediately, before any grade logic runs. Rerun the exact same TestGetGrade suite against Version 2: every one of the six tests now passes — the boundary tests pass because the comparisons are inclusive, and the two assertRaises tests pass because the invalid values now genuinely raise ValueError. Output: six dots, then OK.
This rerun is itself an important habit with its own name: regression testing. Whenever you change code — to fix a bug or add a feature — you rerun the entire existing test suite, not just a test for the new change. This catches "regressions": cases where a fix for one problem accidentally reintroduces or causes a different one. It's also why the value of a test suite grows over time — six months from now, if you (or a teammate) touch this function again, these same six tests will instantly tell you whether you broke something that used to work.
A common misconception, corrected directly
"If my program runs without crashing and gives an answer, it works." This is false, and Version 1 is proof: it never crashed, never printed an error, and for most inputs it even gave the right answer. It was still wrong — silently, systematically, for eight distinct boundary values and for every out-of-range input. A program that runs to completion has only demonstrated that it doesn't crash; it has demonstrated nothing about whether its output is correct. Only testing specific inputs against known-correct expected outputs can show that.
A second, subtler misconception worth naming: "passing all my tests means my function has no bugs." This is also false, and it's a limitation of testing in general, not just of this example. Our six tests check get_grade at 91, 81, 33, 100, 105, and -5. They say nothing about, say, get_grade(70) or get_grade(51) — Version 2 happens to be correct there too, but our test suite doesn't prove it; it simply never asked. Passing tests only guarantee correctness for the specific inputs those tests actually check. This is precisely why choosing which inputs to test — especially boundary values like the lower edge of every band, and invalid values just outside the valid range — matters so much more than testing an arbitrary spread of "normal-looking" numbers.
Check yourself
- Trace
get_grade(61)by hand through Version 1. What does it return, and is that the correct CBSE band? (Trace: 61 > 91 F, > 81 F, > 71 F, > 61 F — 61 is not strictly greater than itself — > 51 T → returns "C1". But 61 belongs in B2. Wrong, for the same systematic reason as 91 and 81.) - Write one more
unittesttest method,test_bottom_of_C1_band, that checksget_grade(51)returns "C1" under Version 2. (def test_bottom_of_C1_band(self): self.assertEqual(get_grade(51), "C1")— trace confirms Version 2 returns "C1" for 51.) - Why couldn't the earlier
run_tests()harness check the missing-validation bug, even though it could catch the boundary bug? (It only compares a returned value to an expected value; it has no mechanism for saying "this call should raise an exception instead of returning anything" — that neededassertRaises.) - True or false: since all six tests pass on Version 2, the function is now completely bug-free for every possible input. Justify your answer. (False — the tests only prove correctness for the specific marks they check: 91, 81, 33, 100, 105, -5. Untested inputs, like a non-integer type such as
get_grade("91"), still aren't covered by this suite.) - Explain, in one sentence, why unit testing
get_gradein isolation found this bug faster than testing the whole result-analysis program end to end would have. (Because unit testing calls the function directly with exact boundary values you choose, instead of relying on whichever random marks happen to appear when you run and click through the full program by hand.)
Summary
A unit is the smallest testable piece of a program — typically one function — and unit testing means automatically checking that function's output against known-correct expected values, separate from testing the program as a whole. Manual spot-checking fails specifically at boundary values and invalid inputs, because those are exactly the cases a programmer doesn't naturally think to try. Hand-written assert statements catch a single failure and then stop; a loop-based harness reports every mismatch in one run but can't check for expected exceptions; Python's unittest module solves both, running organized test cases and test suites with assertEqual, assertRaises, and friends, and reporting terse dot/F output by default (or a per-test breakdown with -v). Fixing a bug means rewriting the function — here, changing > to >= and adding an input-range check — and then rerunning the entire test suite as a regression check, not just testing the one case you just fixed. Passing tests never proves the complete absence of bugs; it only proves correctness for the inputs actually tested, which is exactly why choosing boundary and invalid values deliberately, rather than "obvious" round numbers, is the real skill this chapter teaches.
Think About It
Think about this: How would you explain unit testing to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.