Automated Testing with pytest
Write tests that prove your code works and keep it working — the professional habit every Python team expects.
What you will learn
- Understand why automated tests matter
- Write and run test functions with assert and pytest
- Test both correct results and error cases
Why write tests?
So far you have checked your code by running it and looking at the output. That works for tiny scripts, but it does not scale: as a program grows, re-checking everything by hand after each change becomes slow and easy to forget. An automated test is just code that checks your other code — you write it once, and it re-checks for you in a fraction of a second, every time.
The payoff is confidence. When you change one function, your tests instantly tell you whether you accidentally broke something else. This safety net is why every professional team writes tests, and why “can you write tests?” is a standard interview question.
assert: the building block
A test is built on one simple keyword: assert. It means “I claim this is true.” If the claim is true, nothing happens and the program carries on. If it is false, Python raises an AssertionError — that failure is how a test reports a problem.
def add(a, b):
return a + b
assert add(2, 3) == 5 # claim: this should equal 5
assert add(-1, 1) == 0
print("All checks passed")Each assert states an expectation about add. The first says “add(2, 3) must equal 5” — it does, so Python moves on silently. The second checks a negative case, which also passes. Because nothing failed, the final print runs. If we had written assert add(2, 3) == 6, Python would stop there and raise an AssertionError, telling us the function (or our expectation) is wrong.
Note: Output: All checks passed
pytest: the standard test runner
pytest is the most popular tool for running tests in Python. It is a separate package, so you install it into your virtual environment with pip install pytest. Its rules are wonderfully simple: put your tests in a file whose name starts with test_, write each test as a function whose name also starts with test_, and use plain assert inside. pytest finds and runs them all for you.
Imagine our add function lives in a file called calc.py. We put its tests in a file named test_calc.py right beside it:
# test_calc.py
from calc import add
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_add_zero():
assert add(0, 0) == 0Here is how this file works:
- The first line imports the function we want to test from its own file,
calc.py— tests live separately from the code they check. - Each
test_...function checks one specific situation: a positive case, a negative case, and a zero case. Splitting them up means a failure tells you exactly which case broke. - Inside each, a single
assertstates the expected result. Noprintis needed — pytest reports pass or fail for you. - You never call these functions yourself; pytest discovers every
test_function automatically and runs them.
Running the tests
To run every test, you type one word in the terminal (with your venv active), inside the project folder: pytest. It hunts down all the test_ files and functions, runs them, and prints a summary — a green dot for each pass.
pytest
# ===== test session starts =====
# collected 3 items
#
# test_calc.py ... [100%]
#
# ===== 3 passed in 0.01s =====The three dots after test_calc.py are the three passing tests — one dot each. The bottom line, 3 passed, confirms everything is healthy. If a test had failed, pytest would show an F instead of a dot and print exactly which assertion failed, with the expected and actual values side by side — so you can fix it fast.
Note: Output: 3 passed in 0.01s (A failing test would show an F and a detailed report of what went wrong.)
Testing that errors happen
Good code does not only return correct answers — it also refuses bad input by raising errors (you learned raise earlier). A complete test suite checks those error cases too. pytest gives you pytest.raises for exactly this: it asserts that a block of code does raise the error you expect.
# in calc.py
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# in test_calc.py
import pytest
from calc import divide
def test_divide_works():
assert divide(10, 2) == 5
def test_divide_by_zero_raises():
with pytest.raises(ValueError):
divide(10, 0)The first test checks the normal path — divide(10, 2) should give 5. The second test checks the error path: with pytest.raises(ValueError): says “the code inside this block must raise a ValueError.” Since divide(10, 0) does raise one, the test passes. If divide had forgotten to guard against zero, no error would be raised, pytest.raises would not see the expected exception, and the test would fail — catching the bug for you.
Note: Output (via pytest): 2 passed in 0.01s
The testing mindset
A reliable way to think about what to test: for any function, cover three kinds of case.
- The normal case — typical, expected input gives the right answer (
divide(10, 2) == 5). - The edge case — boundary or unusual input, like zero, empty lists, or very large values.
- The error case — bad input that should be rejected with a raised exception.
Tip: A great habit is to write a test the moment you fix a bug: reproduce the bug as a failing test, then fix the code until the test passes. That bug can never silently come back, because the test will catch it forever. This is the heart of professional, maintainable code.
Q. For pytest to discover and run your tests automatically, your test functions should:
✍️ Practice
- Write a function is_even(n) and three pytest tests covering an even number, an odd number, and zero.
- Add a test that uses
pytest.raisesto confirm a function rejects a negative input with a ValueError.
🏠 Homework
- Take the safedivide function from the exceptions lesson, put it in its own file, and write a test file with at least three pytest tests including one pytest.raises check; run pytest and confirm they pass.