Testing & Best Practices
Code that works today can break tomorrow when you change something else. Professional developers protect their code in two ways: they keep it readable, so mistakes are easy to spot, and they write tests, small programs that check the code automatically every time it changes.
This lesson covers the habits that make Python code easy to read (PEP 8, ruff and docstrings) and shows you how to write and run tests with pytest, the most popular testing tool.
PEP 8: the shared style guide
PEP 8 is the official style guide for Python code. Because almost everyone follows it, Python code from different people looks familiar. Its main rules:
| Item | Style | Example |
|---|---|---|
| Indentation | 4 spaces, never tabs | |
| Variables and functions | lowercase with underscores (snake_case) | total_marks, send_reminder() |
| Classes | CapWords | BankAccount |
| Constants | UPPER_CASE | GST_RATE = 18 |
| Operators | Spaces around =, +, == | total = price + tax |
| Default arguments | No spaces around = | def f(rate=18): |
| Blank lines | Two between top-level functions and classes | |
| Imports | At the top, one per line, standard library first | import json |
PEP 8 itself suggests lines of at most 79 characters; many modern projects allow 88. Compare these two versions of the same function:
def CalcTotal( Items,Discount = 0 ):
t=0
for x in Items: t=t+x
return t-t*Discount/100def calculate_total(prices, discount_percent=0):
"""Return the sum of prices after a percentage discount."""
subtotal = sum(prices)
return subtotal - subtotal * discount_percent / 100
print(calculate_total([250, 180, 570], discount_percent=10))900.0
Both run, but the second is much easier to read: clear names, consistent spacing, a docstring and the built-in sum() instead of a manual loop.
ruff: let a tool check your style
You do not need to remember every rule. A linter reads your code and reports style problems and likely bugs, such as unused imports or undefined names. A formatter rewrites your code's layout automatically. ruff is a very fast tool that does both. Install it in your project's virtual environment (see pip & Virtual Environments), then run it from the project folder:
python -m pip install ruff
ruff check .
ruff check --fix .
ruff format .ruff check . lists problems in every file in the folder (the dot means "this folder"), with the file name, line number and a short code for each rule. --fix repairs the problems that can be fixed safely, and ruff format . reformats all files in a consistent style. Most code editors can run ruff every time you save. You can set options in pyproject.toml:
[tool.ruff]
line-length = 88Docstrings that help
A docstring is the string on the first line of a module, function or class (you met them in Functions). Editors show docstrings when you hover over a name, and help() prints them. Good docstrings follow a simple pattern:
- The first line is one short sentence that says what the function does, ending with a full stop.
- If more is needed, add a blank line, then explain the arguments, the return value and any errors raised.
- Explain what and why, not every line of how; the code shows how.
def emi(principal: float, annual_rate: float, months: int) -> float:
"""Return the monthly instalment (EMI) for a loan.
principal: the amount borrowed, in rupees.
annual_rate: the yearly interest rate in percent, for example 10.5.
months: the number of monthly payments.
Raises ValueError if months is not positive.
"""
if months <= 0:
raise ValueError("months must be positive")
r = annual_rate / 12 / 100
if r == 0:
return round(principal / months, 2)
return round(principal * r * (1 + r) ** months / ((1 + r) ** months - 1), 2)
assert emi(120000, 0, 12) == 10000
assert emi(100000, 12, 12) == 8884.88
print("All checks passed")
print(emi.__doc__.splitlines()[0])All checks passed Return the monthly instalment (EMI) for a loan.
The two assert lines are the simplest possible tests. assert condition does nothing if the condition is true and raises AssertionError if it is false. They run in the browser, but for real projects you collect tests in separate files and run them with pytest.
Your first pytest tests
pytest finds and runs your tests and gives a clear report. Install it with python -m pip install pytest. Suppose your project has a file billing.py:
"""Billing helpers for a small shop."""
GST_RATE = 18
def add_gst(price: float, rate: int = GST_RATE) -> float:
"""Return the price including GST, rounded to 2 decimal places."""
if price < 0:
raise ValueError("price cannot be negative")
return round(price * (100 + rate) / 100, 2)
def split_bill(total: float, people: int) -> float:
"""Return each person's share of the total, rounded to 2 places."""
return round(total / people, 2)Tests go in a file whose name starts with test_. Each test is a function whose name starts with test_ and uses plain assert statements. pytest.raises() checks that some code raises the error you expect.
# test_billing.py
import pytest
from billing import add_gst, split_bill
def test_add_gst_uses_18_percent_by_default():
assert add_gst(100) == 118.0
def test_add_gst_with_custom_rate():
assert add_gst(1299, rate=5) == 1363.95
def test_negative_price_is_rejected():
with pytest.raises(ValueError):
add_gst(-50)
def test_split_bill_between_three_friends():
assert split_bill(1000, 3) == 333.34Run pytest in the project folder. It finds every test_*.py file and runs every test_ function:
pytest============================= test session starts =============================
platform win32 -- Python 3.14.8, pytest-9.2.0, pluggy-1.7.0
rootdir: C:\projects\billing
collected 4 items
test_billing.py ...F [100%]
================================== FAILURES ===================================
____________________ test_split_bill_between_three_friends ____________________
def test_split_bill_between_three_friends():
> assert split_bill(1000, 3) == 333.34
E assert 333.33 == 333.34
E + where 333.33 = split_bill(1000, 3)
test_billing.py:20: AssertionError
=========================== short test summary info ===========================
FAILED test_billing.py::test_split_bill_between_three_friends - assert 333.33...
========================= 1 failed, 3 passed in 0.72s =========================The header lines and the time will differ on your computer. Each dot is a passing test and F is a failure. pytest shows exactly what went wrong: the function returned 333.33, but the test expected 333.34. Here the test is wrong, because 1000 divided by 3 is 333.33 when rounded. When a test fails, always ask which one is wrong: the code or the expectation. Fix the expected value and run pytest -v (verbose) to see each test by name:
test_billing.py::test_add_gst_uses_18_percent_by_default PASSED [ 25%] test_billing.py::test_add_gst_with_custom_rate PASSED [ 50%] test_billing.py::test_negative_price_is_rejected PASSED [ 75%] test_billing.py::test_split_bill_between_three_friends PASSED [100%] ============================== 4 passed in 0.11s ==============================
Good tests are small, test one behaviour each, and have names that describe that behaviour. Test the normal case, the edge cases (zero, empty lists, the largest value) and the error cases. Python also includes a built-in testing module, unittest, which you will see in older projects; pytest can run those tests too.
Everyday best practices
- Use a virtual environment for every project and record dependencies.
- Keep functions small and give them names that say what they do.
- Add type hints to function signatures; see Type Hints.
- Prefer modern tools: f-strings,
pathlib,with open(..., encoding="utf-8")anddataclasses. - Catch specific exceptions, never a bare
except:. - Use version control such as Git, and commit small, working steps.
- Run ruff and pytest before you share your code.
Common mistakes
- Testing by printing. Looking at printed output by eye does not scale. Write asserts that check the values for you.
- Test files that pytest cannot find. Files must be named
test_*.py(or*_test.py) and test functions must start withtest_. - Comparing floats exactly after maths.
0.1 + 0.2 == 0.3isFalse. Round the result or usepytest.approx(0.3). - Ignoring linter warnings. An "unused variable" warning often points to a real bug, such as a typo in a name.
Key points
- PEP 8 is Python's style guide: 4 spaces, snake_case names, CapWords classes, UPPER_CASE constants.
- ruff checks style and common bugs (
ruff check .) and formats code (ruff format .). - Docstrings start with a one-sentence summary and explain arguments, results and errors.
assert conditionraisesAssertionErrorif the condition is false.- pytest runs every
test_function intest_*.pyfiles; use plainassertandpytest.raises(). - When a test fails, decide whether the code or the test's expectation is wrong.
Exercise
Create a file grades.py with a function grade(marks) that returns "A" for 90 and above, "B" for 75 to 89, "C" for 40 to 74, "Fail" below 40, and raises ValueError for marks outside 0 to 100. Give it a docstring. Then write test_grades.py with at least four pytest tests, including the boundary values 90 and 40 and one test for the error, and run pytest -v.
Show solution
# grades.py
def grade(marks: int) -> str:
"""Return the letter grade for marks between 0 and 100."""
if not 0 <= marks <= 100:
raise ValueError("marks must be between 0 and 100")
if marks >= 90:
return "A"
if marks >= 75:
return "B"
if marks >= 40:
return "C"
return "Fail"# test_grades.py
import pytest
from grades import grade
def test_ninety_is_an_a():
assert grade(90) == "A"
def test_eighty_nine_is_a_b():
assert grade(89) == "B"
def test_forty_just_passes():
assert grade(40) == "C"
def test_thirty_nine_fails():
assert grade(39) == "Fail"
def test_marks_above_hundred_are_rejected():
with pytest.raises(ValueError):
grade(101)Running pytest -v should report 5 passed. Boundary values such as 89, 90, 39 and 40 are where bugs like > instead of >= hide, so they are the most valuable tests.
Quick Quiz
1. Which name follows PEP 8 for a function?
Functions and variables use lowercase words joined by underscores (snake_case).
2. Which file and function will pytest find automatically?
pytest collects files named test_*.py and functions whose names start with test_.
3. What does assert total == 900 do when total is 900?
An assert only raises AssertionError when its condition is false.
4. What does ruff format . do?
ruff's formatter fixes layout; ruff check reports lint problems.
Learn Python with a mentor
Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
