Testing & Best Practices

Python · Lesson 55 of 56 8 min read Updated

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:

ItemStyleExample
Indentation4 spaces, never tabs
Variables and functionslowercase with underscores (snake_case)total_marks, send_reminder()
ClassesCapWordsBankAccount
ConstantsUPPER_CASEGST_RATE = 18
OperatorsSpaces around =, +, ==total = price + tax
Default argumentsNo spaces around =def f(rate=18):
Blank linesTwo between top-level functions and classes
ImportsAt the top, one per line, standard library firstimport json

PEP 8 itself suggests lines of at most 79 characters; many modern projects allow 88. Compare these two versions of the same function:

Python
def CalcTotal( Items,Discount = 0 ):
  t=0
  for x in Items: t=t+x
  return t-t*Discount/100
PythonRun it yourself
def 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))
Output
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:

Terminal
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:

Text
[tool.ruff]
line-length = 88

Docstrings 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.
PythonRun it yourself
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])
Output
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:

Python
"""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.

Python
# 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.34

Run pytest in the project folder. It finds every test_*.py file and runs every test_ function:

Terminal
pytest
Output
============================= 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:

Output
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") and dataclasses.
  • 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 with test_.
  • Comparing floats exactly after maths. 0.1 + 0.2 == 0.3 is False. Round the result or use pytest.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 condition raises AssertionError if the condition is false.
  • pytest runs every test_ function in test_*.py files; use plain assert and pytest.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
Python
# 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"
Python
# 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?

2. Which file and function will pytest find automatically?

3. What does assert total == 900 do when total is 900?

4. What does ruff format . do?

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