AI Computer Institute
Expert-curated CS & AI curriculum aligned to CBSE standards. A bharath.ai initiative. About Us

Unit Testing with pytest

📚 Software Engineering⏱️ 23 min read🎓 Grade 11
✍️ AI Computer Institute Editorial Team Updated: August 2026 CBSE-aligned · Peer-reviewed · 23 min read
Content curated by subject matter experts with IIT/NIT backgrounds. All chapters are fact-checked against official CBSE/NCERT syllabi.

Imagine you are building a small program for your school's annual day report cards. You write a function that converts marks into a percentage, test it once by eyeballing the printed output, see a number that looks about right, and move on to the next feature. Two weeks later, a classmate asks you to also add a function that converts percentage into a grade letter, and while editing the file you accidentally reorder two variables in the percentage function. Nothing crashes. No error appears. The program still runs. But every report card it now generates is silently wrong — and unless someone happens to check the exact numbers by hand, nobody will notice until a parent complains that their child's 80% has become a nonsensical 125%. This is not a rare, contrived situation. It is the single most common way real software breaks: not through dramatic crashes, but through a small, silent change to code that used to work. Unit testing with a tool called pytest is how professional programmers — and you, starting today — make that kind of silent breakage loud, immediate, and impossible to ship without noticing.

Why "It Looks Right" Is Not Good Enough

When you write a function, the natural instinct is to test it by calling it once or twice and reading the printed result with your own eyes. Suppose you write this function to turn marks obtained into a percentage:

def calculate_percentage(marks_obtained, max_marks):
    return (marks_obtained / max_marks) * 100

print(calculate_percentage(400, 500))   # you read: 80.0, looks correct

You run it, see 80.0, and trust that the function works. This is called manual testing, and it has three serious problems. First, it depends entirely on you remembering to do it — and remembering to do it again every single time you touch the code, even for changes that seem unrelated. Second, it depends on you personally recalculating the correct answer in your head to compare against what got printed, which is exactly the kind of arithmetic a tired student (or programmer) gets wrong. Third, and most dangerously, once you delete that print line or move on to the next file, the check disappears completely. Nothing remains to catch the bug if someone — including future you — edits calculate_percentage six months from now.

Now suppose that instead of just printing the value, you had written a line that automatically compares the function's output against the answer you already know is correct, and shouts at you if they ever disagree:

assert calculate_percentage(400, 500) == 80.0

This single line is the seed of everything in this chapter. The keyword assert tells Python: "evaluate this expression; if it is True, do nothing and continue; if it is False, stop immediately and raise an error." Run it while the function is correct, and nothing visible happens — silence means success. But if someone later reverses the two variables inside calculate_percentage so it accidentally becomes (max_marks / marks_obtained) * 100, this same line will now compute 125.0, compare it to 80.0, get False, and Python will crash with an AssertionError the moment this line runs. The bug that was silent under manual testing becomes loud and immediate under an assertion. That is the entire philosophy of automated testing in one sentence: turn "does this look right?" into a check the computer runs for you, every time, without needing you to remember or recalculate anything.

What Exactly Is a "Unit"?

A real program is built from many small functions working together — one to calculate a percentage, one to convert a percentage into a grade, one to check whether a UPI ID is correctly formatted, one to validate a PNR number, and so on. A unit is the smallest independently testable piece of that program — almost always a single function (sometimes a single method inside a class). Unit testing is the practice of writing small, automated pieces of code whose only job is to call one unit with known inputs and check that it produces the expected output, completely independent of every other part of the program. This is different from checking whether your entire website or app works end-to-end (that is called integration testing or system testing, and you will meet those ideas in later chapters) — unit testing zooms in on one function at a time, in isolation, so that when a check fails, you know almost exactly which few lines of code are responsible. pytest is simply a tool — a Python library — that makes writing, organizing, and running large collections of these small automated checks fast and convenient.

Writing Your First pytest Test

pytest does not need to be told to run assertions inside a special "test class" the way some older testing tools require. It follows two simple naming rules that let it automatically discover your tests: put your tests in a file whose name starts with test_ (for example, test_report_card.py), and write each individual check as a function whose name also starts with test_. Inside that function, you simply write ordinary Python with one or more assert statements. Here is a complete, runnable test file for the percentage function:

# report_card.py
def calculate_percentage(marks_obtained, max_marks):
    return (marks_obtained / max_marks) * 100


# test_report_card.py
from report_card import calculate_percentage

def test_calculate_percentage_typical():
    assert calculate_percentage(400, 500) == 80.0

def test_calculate_percentage_zero_marks():
    assert calculate_percentage(0, 500) == 0.0

def test_calculate_percentage_full_marks():
    assert calculate_percentage(500, 500) == 100.0

Notice three things about this file. It imports the function it is testing from the actual program file, exactly like any other Python import — a test file is not magic, it is ordinary Python that happens to be organized around checking other code. Each test function checks exactly one behaviour and is named descriptively enough that its name alone tells you what broke if it fails — test_calculate_percentage_zero_marks is far more useful in a failure report than test1. And each test is self-contained: it does not depend on any other test having run first, which matters because pytest is free to run your tests in any order it chooses.

To run these tests, you open a terminal in the folder containing both files and type pytest (or, more explicitly, python -m pytest, which is safer if you have multiple Python versions installed). pytest automatically walks the folder, finds every file matching test_*.py, imports it, finds every function matching test_*, and runs each one. With all three tests passing, the output looks like this:

$ pytest
test_report_card.py ...                                            [100%]
3 passed in 0.01s

Each dot represents one passing test. This compact, silent-on-success style is deliberate — when you have hundreds of tests, you want your attention drawn only to the ones that failed, not scrolled past a wall of "OK" messages for the ones that already work.

Reading a Failure Report Like a Detective

Now let's deliberately reintroduce the bug from the opening story — someone edits calculate_percentage and swaps the two parameters inside the division:

# report_card.py (bugged version)
def calculate_percentage(marks_obtained, max_marks):
    return (max_marks / marks_obtained) * 100

Running pytest again now produces something very different, and this is the moment where unit testing proves its worth:

$ pytest -v
test_report_card.py::test_calculate_percentage_typical FAILED
test_report_card.py::test_calculate_percentage_zero_marks FAILED
test_report_card.py::test_calculate_percentage_full_marks PASSED

================== FAILURES ==================
______________ test_calculate_percentage_typical ______________

    def test_calculate_percentage_typical():
>       assert calculate_percentage(400, 500) == 80.0
E       assert 125.0 == 80.0
E        +  where 125.0 = calculate_percentage(400, 500)

test_report_card.py:4: AssertionError
______________ test_calculate_percentage_zero_marks ______________

    def test_calculate_percentage_zero_marks():
>       assert calculate_percentage(0, 500) == 0.0

report_card.py:2: in calculate_percentage
>       return (max_marks / marks_obtained) * 100
E       ZeroDivisionError: division by zero
============== short test summary info ===============
FAILED test_report_card.py::test_calculate_percentage_typical - assert 125.0 == 80.0
FAILED test_report_card.py::test_calculate_percentage_zero_marks - ZeroDivisionError: division by zero
2 failed, 1 passed in 0.01s

Read this the way a detective reads a clue, line by line. The -v (verbose) flag lists every test by name with PASSED or FAILED next to it, so you instantly know that two out of three tests broke, and — just as informative — which one did not: the full-marks case still passes, because when marks_obtained equals max_marks the ratio is 1 either way round you divide. Now compare the two failures, because they are failing for genuinely different reasons, and that difference is itself a clue. The first shows the pattern you would expect: pytest rewrote the assertion to display both sides of the comparison automatically, assert 125.0 == 80.0, so you can see the function returned a wrong-but-valid number. Seeing 125.0 where you expected 80.0 is usually enough on its own to spot that the numerator and denominator got swapped, since 500 ÷ 400 × 100 is exactly 125.0. The second failure never even reaches a comparison — it stops with ZeroDivisionError: division by zero, because the swapped code now computes max_marks / marks_obtained, and here marks_obtained is 0. A student who scored zero marks would have crashed the whole report-card program, not just received a wrong number — an even worse bug than the first one, and one that a single "does 400 out of 500 look like 80%?" manual check would never have revealed. Fix the function back to marks_obtained / max_marks, run pytest a third time, and all three dots return.

This is the core habit unit testing builds: instead of hunting for a bug by adding and removing print statements every time something feels wrong, you write the check once, and from then on every future change to the code is automatically verified against it in a fraction of a second.

The Arrange-Act-Assert Pattern

As your tests grow more realistic, it helps to write them in a consistent three-part shape that programmers call Arrange, Act, Assert (sometimes shortened to AAA). Arrange means setting up whatever input data or starting state the test needs. Act means calling the one function or unit you are actually testing. Assert means checking that what came back matches what you expected. Our earlier one-line test was really doing all three at once; writing them as separate lines makes long or complex tests far easier to read months later, including by you:

def test_calculate_percentage_typical():
    marks, total = 400, 500                        # Arrange
    result = calculate_percentage(marks, total)     # Act
    assert result == 80.0                           # Assert
Anatomy of a pytest Test def test_calculate_percentage_typical(): marks, total = 400, 500 result = calculate_percentage(marks, total) assert result == 80.0 ARRANGE: set up inputs ACT: call the function ASSERT: check the result How pytest Runs & Reports test_report_card.py (your test file) pytest finds every test_*() function and runs it PASSED prints a green dot . the assert was True FAILED prints a red F + a traceback of the assert

Testing the Edges, Not Just the Middle

A dangerously common beginner mistake is to write only one "happy path" test for a function and stop there. Real bugs love to hide at boundaries — the exact points where a function's behaviour is supposed to change. Consider a function that turns a percentage into a report-card grade, using bands similar to the ones many CBSE-affiliated schools print on a report card (91 and above is the top grade, 81 up to 90 is the next band, and so on down to a fail grade below 33):

def grade_from_percentage(percentage):
    if percentage >= 91:
        return "A1"
    elif percentage >= 81:
        return "A2"
    elif percentage >= 71:
        return "B1"
    elif percentage >= 61:
        return "B2"
    elif percentage >= 51:
        return "C1"
    elif percentage >= 41:
        return "C2"
    elif percentage >= 33:
        return "D"
    else:
        return "E (Fail)"

A test that only checks grade_from_percentage(75) == "B1" would happily pass even if someone typed > instead of >= on the very first line — a mistake that would only ever show up for a student who scored exactly 91%. That single mistyped character is invisible unless you specifically test the boundary. Good unit tests deliberately probe exactly these edges: the value right at the boundary, and the value one step below it.

def test_grade_at_A1_boundary():
    assert grade_from_percentage(91) == "A1"

def test_grade_just_below_A1_boundary():
    assert grade_from_percentage(90) == "A2"

def test_grade_at_pass_boundary():
    assert grade_from_percentage(33) == "D"

def test_grade_just_below_pass_boundary():
    assert grade_from_percentage(32) == "E (Fail)"

Trace through these by hand to see why they are correct, not just trust them. For 91: the first condition 91 >= 91 is True, so the function returns "A1" immediately — matches. For 90: the first condition 90 >= 91 is False, Python falls through to 90 >= 81 which is True, returning "A2" — matches. For 33: every condition down to 33 >= 33 is checked in order and that one is finally True, returning "D". For 32: every condition fails, including 32 >= 33, so execution falls all the way to the else branch and returns "E (Fail)". Four small tests, and between them they pin down every one of the eight boundaries in this function. This technique — picking test inputs at and around the boundaries of a function's behaviour rather than random "typical" values — is called boundary value analysis, and it catches a disproportionate share of real bugs for a very small number of tests.

One Test, Many Inputs: Parametrize

Suppose you also write a function to sanity-check whether a string looks like a valid UPI ID before your program tries to use it — a UPI ID must contain an @ symbol with at least one character on each side, such as rahul123@okicici:

def is_valid_upi_id(upi_id):
    if "@" not in upi_id:
        return False
    local_part, _, handle = upi_id.partition("@")
    return len(local_part) > 0 and len(handle) > 0

(str.partition("@") splits a string around its first @ and returns a tuple of three pieces: everything before it, the @ itself, and everything after it.) A function like this genuinely needs several example inputs — a valid ID, an ID with no @ at all, an ID where the @ is the very first character, and one where it is the very last — and writing a separate test_ function for each would mean repeating almost identical code five times. pytest solves this with the @pytest.mark.parametrize decorator, which runs the same test body once for every input/expected-output pair you list:

import pytest

@pytest.mark.parametrize("upi_id, expected", [
    ("rahul123@okicici",  True),
    ("priya.sharma@ybl",  True),
    ("noatsign",          False),
    ("@okhdfc",           False),
    ("rahul@",            False),
])
def test_is_valid_upi_id(upi_id, expected):
    assert is_valid_upi_id(upi_id) == expected

pytest treats this as five independent tests, reported separately, even though you wrote the checking logic only once. Trace the trickiest two by hand: for "@okhdfc", the @ is present, so partition("@") gives local_part = "" and handle = "okhdfc"; since len(local_part) > 0 is False, the whole and expression is False, matching the expected value False. For "rahul@", partitioning gives local_part = "rahul" and handle = ""; now the second half of the and, len(handle) > 0, is False, so again the result is False as expected. Parametrize does not change what testing means — it only removes the repetition of writing near-identical test bodies, which matters because repeated code is itself a common place for new bugs (and typos) to creep in.

Two Misconceptions Worth Correcting Now

The first misconception is believing that a passing test suite means the code has no bugs. It does not, and it never can. A test only checks the specific inputs you thought to give it. Our grade_from_percentage tests would all still pass even if the function had a completely different, unrelated bug for negative percentages, because none of the five tests ever passed a negative number in. The famous computer scientist Edsger Dijkstra put this precisely: testing can show the presence of bugs, never their absence. This is exactly why boundary value analysis matters so much — it is a deliberate strategy for choosing which few inputs, out of infinitely many possible ones, are most likely to expose a hidden mistake, since no one can literally test every possible number a function might ever receive.

The second misconception is treating print() debugging as equivalent to testing. Printing a value and reading it with your eyes is manual, temporary, and forgotten the moment you close the terminal — it verifies nothing for anyone who runs the code five minutes, five days, or five months later. An assert-based pytest test is permanent, automatic, and self-verifying: it keeps checking the same guarantee every single time pytest runs, for as long as the test file exists, without you needing to remember what the "right" printed value was supposed to look like.

Why This Matters More as a Program Grows

A program with three functions and three tests feels almost unnecessary to test formally — you could probably keep the whole thing in your head. But real software, including the apps millions of Indian users open every day for things like train ticket booking or digital payments, is built from hundreds or thousands of functions written and edited by many different people over years. When you change one function, you often cannot be certain which other parts of the program secretly depended on its exact old behaviour. Running the full collection of unit tests after every change — something called regression testing, because it catches the code "regressing" back to a broken state — takes pytest a few seconds even for a large project, and it checks far more thoroughly and far more reliably than any person re-reading the code by eye ever could. This is precisely why the habit of writing a test alongside every function, starting with programs as small as the ones in this chapter, is worth building now rather than only when a project becomes too large to keep in your head.

Where Unit Testing Fits

Unit testing is the smallest, fastest, most focused layer of a larger idea called the testing pyramid. Above unit tests sit integration tests, which check that several units work correctly together (for example, that the percentage function and the grade function combine to produce a correct report card), and above those sit system tests, which check the entire application behaves correctly from a user's point of view. You will typically write far more unit tests than integration or system tests, because they are cheap to write, run in milliseconds, and — as you saw with the traceback in this chapter — point almost directly at the broken line of code when something goes wrong, instead of just telling you that "something, somewhere" is wrong.

Check Your Understanding

  • You write assert calculate_percentage(250, 500) == 50.0. If calculate_percentage currently returns 50.0, what does pytest print for this test, and why does it print so little?
  • For the grade_from_percentage function in this chapter, which single boundary value would you test to make sure the C2/D boundary (41 vs below) is implemented correctly, and what should each side return?
  • A friend says, "My 12 tests all pass, so my sorting function must be completely bug-free." Explain, using the Dijkstra idea from this chapter, exactly why this conclusion does not follow — and suggest one additional kind of input worth testing.
  • Rewrite this single-line test in explicit Arrange-Act-Assert style: assert is_valid_upi_id("meera@sbi") == True.
  • Why does pytest recommend giving each test function a name like test_grade_just_below_A1_boundary instead of test_2? Think about what you would see in a failure report with hundreds of tests.

Summary

Manual testing — running a function once and eyeballing the output — cannot protect a program from silent regressions, because the check disappears the moment you stop looking. pytest fixes this by letting you write permanent, automatic checks: put functions named test_* inside files named test_*.py, use assert to compare actual results against known-correct expected results, and run the whole collection with a single pytest command. A passing run prints a compact row of dots; a failing run shows you the exact line, the exact computed value, and the exact expected value, turning debugging from guesswork into reading a precise report. Structuring tests as Arrange-Act-Assert keeps them readable as they grow more complex. Because bugs love to hide at boundaries, good unit tests deliberately probe the edges of a function's behaviour, not just typical middle-of-the-road inputs — and @pytest.mark.parametrize lets you run the same check across many such inputs without repeating code. Above all, remember what a green test suite actually proves: not that your code is bug-free, only that it correctly handles every specific case you were careful enough to think of and write down.

← The Journey of a URL: From Typing to DisplayCloud Computing: From Room-Sized Computers to AWS →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn