Python • Debugging and Testing

Writing Unit Tests — Write simple unit tests using assert statements

Learn to catch bugs before they bite by writing automated checks for your Python functions.

Code braces with terminal and markup symbols representing Python programming.
Inside this lesson 10 sections 5 questions 9 figures

SECTION 1

Historical Context & Motivation

Imagine spending hours building a complex program, only to discover a tiny mistake buried deep in your code right before a deadline. This scenario has plagued programmers since the earliest days of computing. In the 1940s, when computers filled entire rooms, engineers had to trace errors by hand through thousands of instructions. As software grew more complex, developers realized they needed a systematic way to verify that each small piece of code worked correctly before assembling the whole program.

The idea of unit testing — checking individual functions or components in isolation — emerged as one of the most powerful strategies for preventing bugs. Rather than waiting until an entire application is built and then searching for errors, unit tests let you verify each building block as you write it. Python's built-in assert statement provides the simplest tool for writing these checks, making it an ideal starting point for learning how to test your code.

1947 • The First 'Bug'

Grace Hopper's team found a moth trapped in a relay of the Harvard Mark II computer, coining the term 'debugging.' Engineers began formalizing methods to find and fix errors.

1979 • Assertions in C

The C programming language introduced the assert macro, allowing programmers to embed logical checks directly in their code. This idea influenced nearly every language that followed.

1994 • First Python Release with Assert

Python 1.0 included the assert statement as a built-in keyword, giving developers a simple, readable way to write inline checks and verify assumptions.

1999 • Extreme Programming & TDD

Kent Beck popularized Test-Driven Development (TDD), a practice where developers write tests before writing the actual code. Unit testing became a cornerstone of professional software development.

2004 • pytest Emerges

The pytest framework made Python testing even more accessible by using simple assert statements instead of complex test class hierarchies. It remains the most popular Python testing tool today.

So how do you actually write a unit test, and what makes assert so useful? The core question is straightforward: how can you automatically verify that a function produces the correct output for a given input? Let's find out.

SECTION 2

Core Principles & Definitions

Before writing your first test, you need to understand a few foundational ideas. Unit testing is built on the principle that you can break any program into small, testable pieces, and then verify each piece independently. Think of it like proofreading each paragraph of an essay one at a time instead of reading the whole thing and hoping you catch every typo.

1

Unit Test

A short piece of code that calls a specific function with known inputs and checks whether the output matches the expected result. Each test targets one small 'unit' of your program.
2

Assert Statement

A Python keyword that evaluates a condition. If the condition is True, nothing happens and the program continues. If it's False, Python raises an AssertionError, signaling a test failure.
3

Expected vs. Actual

Every test compares the expected output (what you know the answer should be) with the actual output (what the function returns). A passing test means these two values match.
4

Test Isolation

Each unit test should be independent — it should not rely on the result of another test. This ensures that when a test fails, you know exactly which function has the bug.
5

Edge Cases

Special or unusual inputs — like zero, negative numbers, empty strings, or very large values — that are most likely to reveal hidden bugs. Good tests always include edge cases.
✦

KEY TAKEAWAY

Think of an assert statement like a teacher's answer key. When you grade a test, you compare the student's answer to the correct one. If they match, the student passes. If they don't, you immediately know something went wrong. The assert statement is your automatic grading machine — it checks answers for you so you don't have to read every output by hand.

SECTION 3

Visual Explanation — How Assert Works

The following diagram shows the flow of execution when Python encounters an assert statement. Understanding this flow is critical — it reveals exactly what happens behind the scenes when you run a unit test.

How an Assert Statement Executesassert add(2, 3) == 5Step 1: Call add(2, 3) → returns 5Step 2: Is5 == 5 ?FalseTrue❌ AssertionError!Test FAILS — bug detected✅ No error raisedTest PASSES — move onContinue to nextassert statement...Program halts.Fix the bug and re-run.The assert statement acts as a gate: only correct results pass through.
The flowchart shows three stages: (1) the function is called and returns a value, (2) the returned value is compared against the expected value, and (3) execution branches based on whether the condition is True or False. A passing test produces no visible output, while a failing test raises an AssertionError that stops execution immediately.

Notice how a passing test is completely silent — no output, no messages. This is by design. When all your tests pass, you simply see nothing, which means everything is working. The moment something breaks, the AssertionError immediately draws your attention to the exact line where the failure occurred. This "no news is good news" pattern is a hallmark of testing in Python.

SECTION 4

How Assert Statements Work

The assert statement follows a simple syntax. You write the keyword assert followed by a condition (any expression that evaluates to True or False). You can optionally add a comma and a message string that appears when the assertion fails, helping you diagnose the problem.

BASIC ASSERT SYNTAX

assert<condition>,<optionalmessage>assert <condition>, <optional message>

If <condition> evaluates to True, execution continues. If False, Python raises AssertionError and prints the optional message.

Assert with Equality Checks

The most common pattern in unit testing is comparing the actual return value of a function against an expected value using ==. Here are the key forms you will use:

EQUALITY ASSERTION

assertfunction(input)==expectedoutputassert function(input) == expected_output

Calls function(input), captures the return value, then checks if it equals expected_output.

ASSERTION WITH MESSAGE

assertfunction(input)==expected,"descriptiveerrormessage"assert function(input) == expected, "descriptive error message"

Adding a message string after the comma makes debugging much easier. When this assertion fails, Python displays your custom message alongside the AssertionError.

BOOLEAN ASSERTION

assertconditionistrueassert condition_is_true

You can also assert that a condition is True directly, such as assert len(result) > 0 to check that a list is not empty.

⚠

Important Note

The assert statement can be disabled when Python is run with the -O (optimize) flag. This means you should never use assert for input validation in production code. It is designed specifically for testing and debugging.

SECTION 5

Common Testing Patterns & Structure

Professional developers organize their tests using established patterns. Even when using simple assert statements, following a clear structure makes your tests easier to read, maintain, and debug. The most important pattern is called Arrange-Act-Assert (AAA), which divides every test into three distinct phases.

The Arrange-Act-Assert PatternThree phases of every well-structured unit test1. ARRANGESet up the inputsand expected valuesx = 5y = 3expected = 82. ACTCall the functionbeing testedresult = add(x, y)3. ASSERTCheck the resultmatches expectedassert result ==expectedFull Example: Testing an add() Functiondef test_add_positive_numbers(): # Arrange x, y, expected = 5, 3, 8 # Act result = add(x, y) # Assert assert result == expected, f"Expected {expected}, got {result}"
The Arrange-Act-Assert pattern: first set up your test data (Arrange), then call the function (Act), and finally verify the result (Assert). The code example at the bottom shows all three phases in a single test function.

What to Test: Categories of Test Cases

CategoryDescriptionExample for add(a, b)
Normal caseTypical, expected inputsassert add(2, 3) == 5
Edge case: zeroOne or more inputs are zeroassert add(0, 7) == 7
Edge case: negativeNegative numbers as inputsassert add(-1, -4) == -5
BoundaryVery large or very small valuesassert add(10**9, 1) == 10**9 + 1
IdentityInput that should return unchangedassert add(0, 0) == 0

Five categories of test cases every unit test suite should cover

SECTION 6

Worked Example — Testing a Grade Calculator

Let's walk through a complete example. Suppose you have written a function called letter_grade(score) that takes a numerical score from 0 to 100 and returns the corresponding letter grade as a string. Here is the function and a full set of unit tests.

Writing Unit Tests for letter_grade()

  1. 1

    Step 1 — Define the Function to Test

    First, here is the function we want to test. It uses conditional logic to map scores to letter grades: def letter_grade(score): if score >= 90: return "A" elif score >= 80: return "B" elif score >= 70: return "C" elif score >= 60: return "D" else: return "F"
    Function defined with 5 return paths based on score ranges.
  2. 2

    Step 2 — Test the Normal Cases

    Start by testing a typical value from each grade range. Pick a value clearly in the middle of each range so there is no ambiguity: assert letter_grade(95) == "A", "95 should be an A" assert letter_grade(85) == "B", "85 should be a B" assert letter_grade(75) == "C", "75 should be a C" assert letter_grade(65) == "D", "65 should be a D" assert letter_grade(50) == "F", "50 should be an F"
    All five normal-case assertions pass. ✅
  3. 3

    Step 3 — Test the Boundary Values

    Boundary values sit right at the edge between two grades. These are the most likely places for off-by-one errors. Test the exact cutoff scores: assert letter_grade(90) == "A", "90 is the boundary for A" assert letter_grade(89) == "B", "89 should be a B, not A" assert letter_grade(80) == "B", "80 is the boundary for B" assert letter_grade(70) == "C", "70 is the boundary for C" assert letter_grade(60) == "D", "60 is the boundary for D" assert letter_grade(59) == "F", "59 should be an F"
    Boundary assertions pass — no off-by-one errors. ✅
  4. 4

    Step 4 — Test Edge Cases

    Check extreme values — the lowest and highest possible scores: assert letter_grade(100) == "A", "Perfect score" assert letter_grade(0) == "F", "Zero score"
    Edge cases pass. ✅
  5. 5

    Step 5 — Run All Tests Together

    Wrap everything in a test function and call it. If all assertions pass, the function completes silently: def test_letter_grade(): # Normal cases assert letter_grade(95) == "A" assert letter_grade(85) == "B" assert letter_grade(75) == "C" assert letter_grade(65) == "D" assert letter_grade(50) == "F" # Boundaries assert letter_grade(90) == "A" assert letter_grade(89) == "B" assert letter_grade(80) == "B" assert letter_grade(60) == "D" assert letter_grade(59) == "F" # Edge cases assert letter_grade(100) == "A" assert letter_grade(0) == "F" print("All tests passed!") test_letter_grade()
    Output: All tests passed! — all 12 assertions verified successfully. 🎉

SECTION 7

Assert Statements vs. Print Debugging

You might be wondering: why not just use print() statements to check your results? After all, you can print the output and visually compare it to what you expect. While print() debugging is quick for one-off checks, it falls apart as your program grows. Here is how the two approaches compare.

Featureprint() Debuggingassert Statements
Automatic?No — you must read each output manuallyYes — fails automatically if wrong
Scales to 100+ tests?Impractical — too much output to readEasily — silent when passing
Catches regressions?Only if you remember to re-checkYes — re-run tests any time
Error message?Whatever you write in the print()Shows line number + custom message
Cleanup needed?Must remove prints before shippingTests live separately from main code

Comparison of print() debugging vs. assert-based unit testing

✦

KEY TAKEAWAY

Using print() to debug is like checking your side mirrors every few seconds while driving — it works but requires constant attention. Using assert is like having a dashboard warning light: it stays off when everything is fine and only alerts you the moment something goes wrong. As your programs get bigger, you need the warning light approach.

SECTION 8

Connection to Professional Testing Frameworks

The assert statement is the foundation, but professional developers use testing frameworks that build on this same concept. These frameworks provide features like automatic test discovery, detailed failure reports, setup/teardown routines, and the ability to run hundreds of tests with a single command. The two most popular frameworks in Python are unittest (built into Python) and pytest (a third-party library). The good news? The assert syntax you are learning now is exactly what pytest uses.

FeaturePlain assertunittest (built-in)pytest (advanced)
Syntaxassert x == yself.assertEqual(x, y)assert x == y
Learning curveMinimal — one keywordModerate — requires class structureLow — plain functions with assert
Auto-discoveryNo — must call manuallyYesYes
Failure detailLine number + messageExpected vs. actual valuesRich diff with variable introspection
Best forQuick checks, learningLarger projects, Java-style teamsMost Python projects

Progression from simple assert to professional testing frameworks

As you continue learning, you will likely move from plain assert statements to pytest, which uses the exact same syntax but adds powerful features on top. Think of learning assert as learning to drive in a parking lot — you are mastering the fundamental controls that you will use on every road and highway later.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

What happens when Python executes assert 2 + 2 == 5? What happens when it executes assert 2 + 2 == 4? Explain the difference in behavior.
Show answer
When Python executes assert 2 + 2 == 5, it evaluates 2 + 2 == 5 which is False. Because the condition is False, Python raises an AssertionError and the program stops. When Python executes assert 2 + 2 == 4, the condition evaluates to True, so nothing happens — no output is printed, no error is raised, and execution continues to the next line. This 'no news is good news' behavior is the fundamental principle of assert-based testing.

Question 2: BASIC CALCULATION

Given the function below, write three assert statements to test it — one normal case, one with zero, and one with a negative number. def double(n): return n * 2
Show answer
Here are three appropriate assert statements: assert double(5) == 10, "Normal case: 5 doubled is 10" assert double(0) == 0, "Zero case: 0 doubled is 0" assert double(-3) == -6, "Negative case: -3 doubled is -6" The first test checks a typical positive integer. The second tests the edge case of zero, which should return zero. The third tests a negative number to make sure the function handles signs correctly. Each includes a descriptive error message after the comma to help with debugging if the test fails.

Question 3: INTERMEDIATE

The function below is supposed to return the maximum value from a list, but it contains a bug. Write assert statements that would catch the bug, and identify the problem. def find_max(numbers): max_val = 0 for num in numbers: if num > max_val: max_val = num return max_val
Show answer
The bug is that max_val is initialized to 0 instead of the first element of the list. This means the function fails when all numbers are negative. The following assert catches it: assert find_max([3, 7, 2]) == 7 # Passes — normal case assert find_max([-5, -1, -8]) == -1 # FAILS! Returns 0 instead of -1 assert find_max([0, 0, 0]) == 0 # Passes — but accidentally The second assertion would raise an AssertionError because the function returns 0 (the initial value of max_val) instead of −1. The fix is to initialize max_val = numbers[0] so it starts with an actual value from the list. This demonstrates why testing with negative numbers is essential.

Question 4: APPLIED

You are building a simple password validator. Write the function is_valid_password(password) that returns True if the password is at least 8 characters long and contains at least one digit, and False otherwise. Then write at least four assert statements testing different scenarios including edge cases.
Show answer
Here is one possible implementation and test suite: def is_valid_password(password): if len(password) < 8: return False for char in password: if char.isdigit(): return True return False # Tests assert is_valid_password("hello123") == True, "Valid: 8+ chars with digit" assert is_valid_password("hi2") == False, "Too short despite having digit" assert is_valid_password("abcdefgh") == False, "Long enough but no digit" assert is_valid_password("") == False, "Empty string should be invalid" assert is_valid_password("abcdefg1") == True, "Exactly 8 chars with digit at end" The tests cover: a clearly valid password, a short password with a digit, a long password without digits, the empty string edge case, and a boundary case of exactly 8 characters. Each test targets a different failure mode of the function.

Question 5: CRITICAL THINKING

A classmate argues: 'I don't need to write tests. I just run my program and look at the output to see if it's right.' Write a response explaining at least three specific scenarios where this approach fails and assert-based unit testing would have caught the problem. Use concrete examples from a project with multiple functions.
Show answer
Here are three scenarios where manual checking fails: 1. Regression bugs: Suppose you have a working calculate_average() function and later you modify a helper function it depends on. Without tests, you would not know calculate_average() broke until you happened to manually check it. With assert statements, you simply re-run all tests and the failure is caught instantly. 2. Edge cases you forget to check: You might test your divide(a, b) function with 10 ÷ 2 and see 5 — looks correct. But did you check 10 ÷ 3 for proper decimal handling? Did you check division by zero? Assert statements force you to think about these cases upfront and document them permanently. 3. Scale and teamwork: In a project with 20 functions, manually checking every function's output each time you make a change would take an unreasonable amount of time. Written tests can verify all 20 functions in under a second. Furthermore, if a teammate modifies your code, they can run your tests to confirm they have not broken anything — even if they do not fully understand your implementation.

SUMMARY

Summary

Summary

Unit tests are small, focused checks that verify individual functions produce correct outputs for known inputs. Python's built-in assert statement is the simplest tool for writing these tests: it evaluates a condition and raises an AssertionError if the condition is False, while passing silently when the condition is True. The basic syntax is assert function(input) == expected_output, "error message".

Every well-structured test follows the Arrange-Act-Assert pattern: set up inputs, call the function, and check the result. Thorough test suites include normal cases, boundary values, and edge cases like zero, negative numbers, and empty inputs. Mastering assert prepares you for professional frameworks like pytest, which uses the same syntax to power industry-grade testing workflows.

Varsity Tutors • Python • Writing Unit Tests — Write simple unit tests using assert statements