Testing: asserts, pytest, unittest and mocks
Part of the free LogicWiz Python course, Module 14: Exceptions, context managers and testing. The examples and practice run in the app.
Meher "improved" the GST function on Friday. On Monday, every bill was ₹5 short, and nobody noticed for three days. A test suite would have shouted within a second of her change.
You'll learn to
- Write tests with plain `assert` and a tiny test runner of your own
- Read pytest tests: discovery, fixtures, parametrize and `pytest.raises`
- Write `unittest` test cases that run in the browser
- Replace slow or unpredictable parts with `unittest.mock`
Learn
What a test is
A test is a small function that runs your code with a known input and checks the output with assert. If the check fails, assert raises AssertionError, and the test fails.
Good tests follow Arrange → Act → Assert: set up the data, call the code, check the result. Test the normal case and the edge cases students forget: 0, an empty list, a negative amount, exactly on the boundary.
A test runner just finds all the test functions, runs each one, and counts passes and failures. Let's write one.
Plain asserts and a mini test runner
def gst(amount):
if amount < 0:
raise ValueError("amount cannot be negative")
return round(amount * 1.05, 2)
def test_basic():
assert gst(200) == 210.0
def test_zero():
assert gst(0) == 0
def test_negative_rejected():
try:
gst(-1)
except ValueError:
return
raise AssertionError("gst(-1) should raise ValueError")
def run_tests(namespace):
tests = [f for name, f in sorted(namespace.items()) if name.startswith("test_") and callable(f)]
passed = 0
for t in tests:
try:
t()
passed += 1
print("PASS", t.__name__)
except AssertionError as e:
print("FAIL", t.__name__, e)
print(f"{passed}/{len(tests)} passed")
run_tests(dict(globals()))
This is the core of pytest: find functions named test_..., run them, report. Change 1.05 to 1.04 and run again to watch tests catch the bug.
The same tests with pytest
# test_billing.py (run on a laptop with: pytest -v)
def gst(amount):
return round(amount * 1.05, 2)
def test_basic():
assert gst(200) == 210.0
def test_zero():
assert gst(0) == 0
def test_small_amount():
assert gst(10) == 10.5
pytest is installed with pip install pytest and is not available in the browser. It finds files named test_*.py and functions named test_*, and uses plain assert. On a failure it shows both sides of the comparison.
pytest power tools
import pytest
@pytest.fixture
def stock(): # fresh data for each test that asks for it
return {"chai": 10}
def test_sell(stock): # pytest passes the fixture in by name
stock["chai"] -= 2
assert stock["chai"] == 8
@pytest.mark.parametrize("amount, expected", [(0, 0), (100, 105.0), (10, 10.5)])
def test_gst(amount, expected): # runs 3 times
assert gst(amount) == expected
def test_negative():
with pytest.raises(ValueError): # passes only if ValueError is raised
gst(-1)
`unittest`: built in, runs right here
import unittest, sys
def gst(amount):
if amount < 0:
raise ValueError("amount cannot be negative")
return round(amount * 1.05, 2)
class TestGst(unittest.TestCase):
def setUp(self):
self.amounts = {0: 0, 200: 210.0, 10: 10.5}
def test_values(self):
for amount, expected in self.amounts.items():
with self.subTest(amount=amount):
self.assertEqual(gst(amount), expected)
def test_negative(self):
with self.assertRaises(ValueError):
gst(-1)
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestGst)
result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
print("all passed:", result.wasSuccessful())
unittest comes with Python. Tests are methods named test_... in a TestCase class; setUp runs before each one (like a fixture); subTest works like parametrize; assertRaises like pytest.raises. pytest can run unittest tests too.
Mocks: fake the parts you cannot control
Some code talks to things a test should not touch: a real UPI gateway, the clock, a random coupon, a web API. A mock stands in for them. It accepts any call, returns what you tell it, and remembers how it was called.
Mock()makes one;m.return_value = ...sets the answer;m.side_effect = ...raises an error or returns a list of answers in turn.m.assert_called_once_with(...),m.call_count,m.call_argscheck how your code used it.patch("random.randint", return_value=7)swaps a real function for a mock inside awithblock and restores it afterwards.
Mocking a payment gateway and a random coupon
from unittest.mock import Mock, patch
import random
def checkout(gateway, upi, amount):
status = gateway.charge(upi, amount)
return status == "SUCCESS"
gateway = Mock()
gateway.charge.return_value = "SUCCESS"
print(checkout(gateway, "meher@okaxis", 85))
gateway.charge.assert_called_once_with("meher@okaxis", 85)
print("charge called", gateway.charge.call_count, "time")
def lucky_coupon():
return random.randint(1, 10) == 7
with patch("random.randint", return_value=7):
print("lucky?", lucky_coupon())
patch replaces the name where your code looks it up. lucky_coupon calls random.randint, so we patch "random.randint". If it had done from random import randint, you would patch the name in your own module instead.
What makes a good test? It is small, fast, checks one behaviour, and gives the same result every run (no real network, clock or randomness: mock them). Name it after the behaviour: test_gst_rejects_negative_amount. Writing the test first and then the code is called TDD (test-driven development).
In one line
A test calls your code and asserts the result; pytest finds test_* functions (fixtures, parametrize, pytest.raises); unittest.mock fakes what you cannot control.
Interview corner
What is the difference between a unit test and an integration test?
A unit test checks one small piece (a function or class) in isolation, with its dependencies replaced by mocks, so it is fast and pinpoints failures. An integration test checks that several real parts work together (code plus database, or two services). You want many unit tests and fewer integration tests.
What are pytest fixtures and parametrize?
A fixture (@pytest.fixture) is a function that prepares data or resources; a test receives it by naming it as a parameter, and it can clean up after a yield. @pytest.mark.parametrize runs one test function with several sets of inputs, each reported as a separate test.
What is mocking and when do you use it?
Mocking replaces a real dependency with a fake object that returns controlled values and records how it was called. Use it for things that are slow, costly or unpredictable: network calls, payment gateways, the current time, randomness. In Python: unittest.mock.Mock, patch, return_value, side_effect and assert_called_once_with.
How do you test that a function raises an exception?
In pytest: with pytest.raises(ValueError): gst(-1) (add match="negative" to check the message). In unittest: with self.assertRaises(ValueError): gst(-1). Both fail the test if no exception, or a different one, is raised.