Python Testing Basics With pytest
Write and run tests with pytest, read a failing test, and test errors and edge cases. After this, you can change your code and know within seconds if you broke something.
Course content
Sign in to save your progress.
-
Set Up Projects That Don't Break Each Other
-
Python Virtual Environments and pip: One Project, One Environment
-
Pinning Python Dependencies With requirements.txt
-
-
-
Organize Code Other People Can Read
-
Python Modules and Packages: Splitting One Big File
-
Python Imports: Why ModuleNotFoundError Keeps Happening
-
Python Classes: When a Dictionary Is No Longer Enough
-
-
-
Work With Real Files
-
Python File Handling: Open, Read, Write, and Always Use with
-
Python pathlib: Stop Building File Paths by Hand
-
Reading, Cleaning and Writing CSV Data in Python
-
-
-
Shorter Code That Still Says What It Does
-
Python Comprehensions: Lists, Sets and Dictionaries
-
When a Comprehension Should Be a Plain Loop
-
-
-
Prove It Works Before Someone Else Finds Out
-
Python Testing Basics With pytest
-
Debugging Python With print, logging and pdb
-
-
- Final Check: Python Intermediate
A test is a small piece of code that runs your code and checks the result. Writing tests feels slower on day one. It is faster from day two, because every change after that gets checked in seconds instead of by hand, or by a customer.
Skip ahead: If you already write pytest tests, read its failure output, use pytest.raises and parametrize, continue to "Debugging Python With print, logging and pdb."
Do you need tests for this?
Not for everything. A one-time script you run once and delete doesn't need tests. Code that runs every week, code that touches money or other people's data, and code that more than one person changes does. If you would be embarrassed by a wrong result, test it.
Install pytest
Inside the project's virtual environment:
python -m pip install pytest
The code to test
# pricing.py
def apply_discount(amount, percent):
if not 0 <= percent <= 100:
raise ValueError("percent must be between 0 and 100")
return round(amount * (100 - percent) / 100, 2)
def shipping_fee(subtotal):
if subtotal >= 1000:
return 0
return 150
Your first tests
Make a file whose name starts with test_, with functions whose names start with test_. Inside, use plain assert:
# test_pricing.py
from pricing import apply_discount, shipping_fee
def test_discount_ten_percent():
assert apply_discount(1500, 10) == 1350
def test_no_discount():
assert apply_discount(999.99, 0) == 999.99
def test_free_shipping_at_1000():
assert shipping_fee(1000) == 0
def test_shipping_below_1000():
assert shipping_fee(999) == 150
python3 -m pytest -q -p no:cacheprovider
.... [100%]
4 passed in ...s
Each dot is a passing test. pytest found the file and the functions by their names. No classes, no setup. (-q keeps the output short. You can leave it off, and -p no:cacheprovider is only there to stop pytest writing a cache folder in this example.)
Reading a failure
Now someone changes the free-shipping rule and makes a small mistake:
# pricing.py
def apply_discount(amount, percent):
if not 0 <= percent <= 100:
raise ValueError("percent must be between 0 and 100")
return round(amount * (100 - percent) / 100, 2)
def shipping_fee(subtotal):
if subtotal > 1000: # changed from >= by mistake
return 0
return 150
python3 -m pytest -q -p no:cacheprovider
..F. [100%]
=================================== FAILURES ===================================
__________________________ test_free_shipping_at_1000 __________________________
def test_free_shipping_at_1000():
> assert shipping_fee(1000) == 0
E assert 150 == 0
E + where 150 = shipping_fee(1000)
test_pricing.py:14: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_free_shipping_at_1000 - assert 150 == 0
1 failed, 3 passed in ...s
Read it from the bottom, like a traceback. Which test failed, in which file and line, and the actual values: shipping_fee(1000) returned 150 where the test expected 0. You know exactly what broke before opening pricing.py. Without the test, the first person to notice would be a customer paying 150 for shipping on a 1,000 order.
Testing that errors happen
Good code refuses bad input. Test that too, with pytest.raises:
# test_errors.py
import pytest
from pricing import apply_discount
def test_discount_over_100_is_refused():
with pytest.raises(ValueError, match="between 0 and 100"):
apply_discount(1000, 150)
If apply_discount doesn't raise ValueError with that message, the test fails.
Many cases, one test: parametrize
Bugs live at the edges: zero, the exact limit, one below it. Instead of writing a function per case, list the cases:
# test_shipping.py
import pytest
from pricing import shipping_fee
@pytest.mark.parametrize("subtotal, expected", [
(0, 150),
(999.99, 150),
(1000, 0),
(5000, 0),
])
def test_shipping_fee(subtotal, expected):
assert shipping_fee(subtotal) == expected
python3 -m pytest -q -p no:cacheprovider test_errors.py test_shipping.py
...F. [100%]
...
FAILED test_shipping.py::test_shipping_fee[1000-0] - assert 150 == 0
1 failed, 4 passed in ...s
The error test passes, and the parametrized test pinpoints the exact case: subtotal 1000. 999.99 and 5000 still behave. The test ID in square brackets, [1000-0], tells you which row of the list failed. Now fix the bug and run everything:
sed -i 's/subtotal > 1000: # changed from >= by mistake/subtotal >= 1000:/' pricing.py
python3 -m pytest -q -p no:cacheprovider
......... [100%]
9 passed in ...s
(The sed line just edits the file for this example. Normally you fix it in your editor.) Nine tests, all passing, in a fraction of a second. Checking the same nine cases by hand every time someone touches the pricing would take minutes, and nobody would actually do it.
⚠ Warning: Never "fix" a failing test by changing the expected value until it passes, unless you have confirmed the rule itself changed. A test that fails is reporting a difference between what the code does and what someone decided it should do. Find out which one is wrong first. Editing the test to match the bug is how wrong behaviour becomes official.
What to test first
- Rules with money, dates or limits. Discounts, fees, deadlines, age checks.
- The edges. Zero, empty list, exactly the limit, one below it.
- Every bug you fix. Write a test that fails because of the bug, then fix it. That bug can never quietly come back.
- Cleaning functions. Like
clean_rowfrom the CSV lesson: give it a bad row and check it raises the right error.
Common mistakes
- Tests that don't check anything. A test that calls a function without
assertpasses as long as nothing crashes. That proves very little. - Comparing floats exactly after calculations.
0.1 + 0.2 == 0.3isFalsein Python. Usepytest.approx, for exampleassert total == pytest.approx(0.3), or round likeapply_discountdoes. - Tests that depend on each other or on the real database. Each test should set up what it needs and run on its own, in any order.
- Naming the file
pricing_test.pyortests.py. pytest findstest_*.pyand*_test.pyby default. Stick totest_*.pyand you never have to think about it.
💡 Tip: Run one test while you work on it with python -m pytest -k shipping (any test with "shipping" in its name), and the whole suite before you commit. Add -x to stop at the first failure when many tests fail for the same reason.
What happens if you skip this step
Every change becomes a small gamble. You fix one thing, you check the part you changed, and something two functions away breaks without anybody knowing. Eventually people stop changing the code because they are afraid of it. Tests are what make it safe to keep improving code that already works.
The free-shipping limit changes to 1,500 next month. Without tests, how would you prove that every place that uses it got updated correctly?
About
Write and run tests with pytest, read a failing test, and test errors and edge cases. After this, you can change your code and know within seconds if you broke something.
- Duration
- 10 min
- Last update
- 09/11/2026
Comments (0)
Sign in to commentNo comments yet.