All 57 lessons

Lesson 56 of 57 · Writing Professional Python

Testing Your Code with pytest

By Skillancy Editorial Team

Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).

"It works on my machine" is not a guarantee. Code that produced the right number yesterday can produce a wrong one tomorrow after someone changes a function, upgrades a library or feeds it an unexpected file. Automated tests are small programs that check your code's behaviour, run in seconds, and tell you the moment something breaks. For analysts, they are the difference between a report you hope is right and a report you can prove is right.

pytest is the standard Python testing tool and the friendliest one. A test is just a function whose name starts with test_ and which uses a plain assert. pytest finds them, runs them and, when one fails, shows you exactly what the values were. In this lesson we write the helper functions an analytics team really uses (margin, on-time rate, parsing rupee amounts), test them in increasing sophistication, and finish by writing tests that guard the data itself: unique order IDs, no negative quantities, valid statuses.

We run pytest from inside the lesson using subprocess, exactly as you would run it in a terminal with the command pytest. The output you see is the real pytest output (with the timing removed so that it is stable).

Worked examples

Example 1: Code under test, and a test runner helper

Question: What are we testing, and how do we run pytest from here?

import subprocess
import sys
import os
import re
from pathlib import Path

Path("analytics.py").write_text('''
import re

def margin_pct(price, cost):
    """Gross margin as a percentage of price."""
    if price <= 0:
        raise ValueError("price must be positive")
    return (price - cost) / price * 100

def on_time_rate(minutes, limit=30):
    """Share of deliveries within the limit; None values (cancelled) are ignored."""
    values = [m for m in minutes if m is not None]
    if not values:
        raise ValueError("no deliveries to measure")
    return sum(m <= limit for m in values) / len(values)

def parse_amount(text):
    """Turn a string like '₹1,299.50' into a float; blank text gives None."""
    cleaned = re.sub(r"[^\\d.]", "", text or "")
    return float(cleaned) if cleaned else None
''', encoding="utf-8")

def run_pytest(*args):
    env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
    r = subprocess.run([sys.executable, "-m", "pytest", "-q", "--no-header", "-p", "no:cacheprovider", "--color=no", *args],
                       capture_output=True, text=True, env=env)
    return r.returncode, re.sub(r" in [\d.]+s", "", r.stdout).strip()

def counts(output):
    return {word: int(n) for n, word in re.findall(r"(\d+) (passed|failed|skipped|xfailed|xpassed|error)", output)}

import analytics
print(round(analytics.margin_pct(48, 36), 1), analytics.parse_amount("₹1,299.50"))

Output

25.0 1299.5

How it works: We write a small module analytics.py with three functions we want to protect. The helper run_pytest runs python -m pytest as a subprocess and returns its exit code and output (exit code 0 means every test passed, which is how continuous-integration systems decide whether a change is safe). counts pulls the numbers out of pytest's one-line summary so we can refer to them. The helper strips the elapsed time so the output does not change from run to run.

Reading the result: The functions work on a quick manual check: a ₹48 product costing ₹36 has a margin of 25.0% and "₹1,299.50" parses to 1299.5. A manual check is not a test: it is gone the moment you close the terminal.

Example 2: Your first tests

Question: How do we turn those manual checks into permanent ones?

Path("test_analytics.py").write_text('''
from analytics import margin_pct, on_time_rate, parse_amount

def test_margin_pct_basic():
    assert margin_pct(100, 75) == 25

def test_on_time_rate_ignores_cancelled_orders():
    assert on_time_rate([20, 40, None, 25, None]) == 2 / 3

def test_parse_amount_handles_rupee_symbol_and_commas():
    assert parse_amount("₹1,299.50") == 1299.5

def test_parse_amount_blank_is_none():
    assert parse_amount("") is None
    assert parse_amount(None) is None
''', encoding="utf-8")

code, out = run_pytest("test_analytics.py")
print(out)
print("exit code:", code)

Output

....                                                                     [100%]
4 passed
exit code: 0

How it works: pytest collects files named test_*.py and functions named test_*. Each test is plain Python with a plain assert: if the condition is true the test passes, and if it is false (or the code raises an error) the test fails. A dot in the output is a pass. Good test names describe the behaviour ("blank is none"), so a failing test explains itself. Each test checks one behaviour, and the second test shows that real business rules (cancelled orders are ignored) are exactly what tests should capture.

Reading the result: pytest ran the file and reported "4 passed" with exit code 0.

Example 3: Reading a failure

Question: What does it look like when a test catches a bug?

Path("buggy.py").write_text('''
def discounted_price(price, discount_pct):
    return price - discount_pct          # BUG: subtracts the percentage points, not a percentage of the price
''', encoding="utf-8")

Path("test_buggy.py").write_text('''
from buggy import discounted_price

def test_ten_percent_off():
    assert discounted_price(200, 10) == 180
''', encoding="utf-8")

code, out = run_pytest("test_buggy.py", "--tb=short")
print(out)
print("exit code:", code)

Output

F                                                                        [100%]
=================================== FAILURES ===================================
_____________________________ test_ten_percent_off _____________________________
test_buggy.py:6: in test_ten_percent_off
    assert discounted_price(200, 10) == 180
E   assert 190 == 180
E    +  where 190 = discounted_price(200, 10)
=========================== short test summary info ============================
FAILED test_buggy.py::test_ten_percent_off - assert 190 == 180
1 failed
exit code: 1

How it works: When an assertion fails, pytest shows the failing line and the values involved, here assert 190 == 180 and the call that produced 190. That is the clue: 200 minus 10 is 190, so the code subtracts 10 rupees instead of 10 percent. The exit code is non-zero, so a scheduler or CI system would stop the release. A failing test is the test doing its job: it found the bug before a customer did.

Reading the result: pytest reports 1 failed and exit code 1. The line starting with E shows the actual value (190) against the expected one (180), which points straight at the bug.

Example 4: Fix the bug and re-run

Question: How do we confirm the fix?

Path("buggy.py").write_text('''
def discounted_price(price, discount_pct):
    return price * (1 - discount_pct / 100)
''', encoding="utf-8")

code, out = run_pytest("test_buggy.py")
print(out)
print("exit code:", code)

Output

.                                                                        [100%]
1 passed
exit code: 0

How it works: With the formula corrected, the same test now passes. The test stays in the project forever as a regression test: if anyone reintroduces the bug, it fails immediately. This is the cycle of professional work: reproduce the bug as a failing test, fix the code, watch the test pass, keep the test.

Reading the result: {'passed': 1} and exit code 0: the fix works and the check is permanent.

Example 5: parametrize: many cases, one test

Question: How do we test lots of inputs without copying the test?

Path("test_param.py").write_text('''
import pytest
from analytics import parse_amount

@pytest.mark.parametrize(
    "text, expected",
    [
        ("₹48", 48.0),
        ("₹1,299.50", 1299.5),
        ("Rs. 250", 250.0),
        ("  ₹ 75.5 ", 75.5),
        ("free", None),
        ("", None),
    ],
)
def test_parse_amount(text, expected):
    assert parse_amount(text) == expected
''', encoding="utf-8")

code, out = run_pytest("test_param.py", "--tb=short")
print(out)

Output

..F...                                                                   [100%]
=================================== FAILURES ===================================
_______________________ test_parse_amount[Rs. 250-250.0] _______________________
test_param.py:18: in test_parse_amount
    assert parse_amount(text) == expected
E   AssertionError: assert 0.25 == 250.0
E    +  where 0.25 = parse_amount('Rs. 250')
=========================== short test summary info ============================
FAILED test_param.py::test_parse_amount[Rs. 250-250.0] - AssertionError: asse...
1 failed, 5 passed

How it works: @pytest.mark.parametrize runs the same test function once for each row of inputs and expected results and reports each case separately, so one function becomes six tests and adding a case is one line. And look what it found: the case "Rs. 250" fails, because our parse_amount keeps the stray full stop after "Rs" and reads ".250" as 0.25. The single-case manual check in Example 1 never tried that input. Parametrized tables of awkward inputs (extra spaces, other currency symbols, blanks) are the cheapest way to find this kind of bug.

Reading the result: pytest reports 1 failed, 5 passed: five cases pass and the Rs. 250 case returns 0.25 instead of 250.0. The test table found a real bug in our code.

Now fix the function and re-run the same tests:

source = Path("analytics.py").read_text(encoding="utf-8")
start = source.index("def parse_amount")
fixed = source[:start] + '''def parse_amount(text):
    """Turn a string like '₹1,299.50' into a float; blank or non-numeric text gives None."""
    match = re.search(r"\\d[\\d,]*(?:\\.\\d+)?", text or "")
    return float(match.group().replace(",", "")) if match else None
'''
Path("analytics.py").write_text(fixed, encoding="utf-8")

code2, out2 = run_pytest("test_param.py", "test_analytics.py")
print(out2)
print("exit code:", code2)

Output

..........                                                               [100%]
10 passed
exit code: 0

How it works: The new version searches for the first run of digits (allowing commas and an optional decimal part) instead of deleting every non-digit character, so a stray full stop no longer sneaks into the number. We re-ran the parametrized tests and the earlier file, to check that the fix did not break anything that used to work: that second check is what a regression suite is for.

Reading the result: After the fix pytest reports 10 passed with exit code 0: the bug is fixed, and ten checks protect it from coming back.

Example 6: Testing that errors are raised

Question: How do we check that bad input is rejected?

Path("test_errors.py").write_text('''
import pytest
from analytics import margin_pct, on_time_rate

def test_zero_price_is_rejected():
    with pytest.raises(ValueError, match="price must be positive"):
        margin_pct(0, 10)

def test_no_deliveries_is_rejected():
    with pytest.raises(ValueError):
        on_time_rate([None, None])

def test_margin_can_be_negative():
    assert margin_pct(50, 60) == -20
''', encoding="utf-8")

code, out = run_pytest("test_errors.py")
print(out)

Output

...                                                                      [100%]
3 passed

How it works: pytest.raises(ValueError) passes only if the block does raise that exception, and match= also checks the message with a regular expression. Testing the failure paths matters as much as the happy path: a function that quietly returns a wrong number for a zero price is more dangerous than one that stops loudly. The third test pins down a business decision (a loss-making product has a negative margin, not an error).

Reading the result: {'passed': 3}: the functions reject invalid input as designed and allow a negative margin.

Example 7: Comparing floats with approx

Question: Why does 0.1 + 0.2 == 0.3 fail, and how do tests avoid that?

print(0.1 + 0.2 == 0.3, 0.1 + 0.2)

Path("test_float.py").write_text('''
import pytest
from analytics import margin_pct, on_time_rate

def test_exact_comparison_is_fragile():
    assert 0.1 + 0.2 == 0.3

def test_approx_comparison_is_robust():
    assert 0.1 + 0.2 == pytest.approx(0.3)

def test_margin_of_real_product():
    assert margin_pct(48, 36.31) == pytest.approx(24.35, abs=0.01)

def test_on_time_share():
    assert on_time_rate([10, 20, 31]) == pytest.approx(0.6667, rel=1e-3)
''', encoding="utf-8")

code, out = run_pytest("test_float.py", "--tb=short")
print(out)

Output

False 0.30000000000000004
F...                                                                     [100%]
=================================== FAILURES ===================================
_______________________ test_exact_comparison_is_fragile _______________________
test_float.py:7: in test_exact_comparison_is_fragile
    assert 0.1 + 0.2 == 0.3
E   assert (0.1 + 0.2) == 0.3
=========================== short test summary info ============================
FAILED test_float.py::test_exact_comparison_is_fragile - assert (0.1 + 0.2) =...
1 failed, 3 passed

How it works: Computers store most decimals approximately, so 0.1 + 0.2 is 0.30000000000000004, and an exact comparison fails. pytest.approx compares within a tolerance: rel is relative and abs is absolute. Use it for every test of a computed float. Notice the first test fails on purpose here, to show that exact float comparison is fragile; it should have used approx.

Reading the result: The summary is 1 failed, 3 passed: the exact comparison failed (1 failure) while the three tolerance-based tests passed.

Example 8: Fixtures for shared set-up

Question: How do we avoid repeating the same data preparation in every test?

Path("conftest.py").write_text('''
import pandas as pd
import pytest

@pytest.fixture
def small_orders():
    return pd.DataFrame({
        "order_id": ["O1", "O2", "O3", "O4"],
        "city": ["Delhi", "Delhi", "Pune", "Pune"],
        "order_status": ["Delivered", "Cancelled", "Delivered", "Delivered"],
        "value": [100.0, 50.0, 200.0, 300.0],
    })
''', encoding="utf-8")

Path("test_fixture.py").write_text('''
import pandas as pd

def revenue_by_city(df):
    delivered = df[df["order_status"] == "Delivered"]
    return delivered.groupby("city")["value"].sum()

def test_cancelled_orders_are_excluded(small_orders):
    result = revenue_by_city(small_orders)
    assert result["Delhi"] == 100.0

def test_all_cities_present(small_orders):
    assert set(revenue_by_city(small_orders).index) == {"Delhi", "Pune"}

def test_fixture_is_fresh_each_time(small_orders):
    small_orders.loc[0, "value"] = 999
    assert small_orders.loc[0, "value"] == 999
    assert len(small_orders) == 4
''', encoding="utf-8")

code, out = run_pytest("test_fixture.py")
print(out)

Output

...                                                                      [100%]
3 passed

How it works: A fixture is a function decorated with @pytest.fixture that prepares something tests need. Any test that lists the fixture's name as an argument receives its return value; pytest builds a fresh one per test by default, so one test cannot damage another's data (the third test modifies its copy freely). Fixtures placed in a file named conftest.py are shared by every test in the folder. A tiny hand-made DataFrame with known answers is ideal for testing: you can compute the expected result by eye.

Reading the result: The three tests all received the same fixture without repeating its construction: {'passed': 3}. The expected Delhi revenue of ₹100 is easy to verify by hand because the cancelled order is excluded.

Example 9: tmp_path and monkeypatch

Question: How do we test code that reads files or environment variables without touching real ones?

Path("loader.py").write_text('''
import os
import pandas as pd

def load_orders(path):
    df = pd.read_csv(path)
    if df["order_id"].duplicated().any():
        raise ValueError("duplicate order IDs")
    return df

def report_title():
    return f"FreshBasket report for {os.environ.get('REPORT_MONTH', 'unknown month')}"
''', encoding="utf-8")

Path("test_loader.py").write_text('''
import pytest
from loader import load_orders, report_title

def test_load_orders_reads_file(tmp_path):
    f = tmp_path / "orders.csv"
    f.write_text("order_id,value\\nO1,10\\nO2,20\\n")
    assert len(load_orders(f)) == 2

def test_duplicates_are_rejected(tmp_path):
    f = tmp_path / "orders.csv"
    f.write_text("order_id,value\\nO1,10\\nO1,20\\n")
    with pytest.raises(ValueError, match="duplicate"):
        load_orders(f)

def test_title_uses_environment(monkeypatch):
    monkeypatch.setenv("REPORT_MONTH", "June 2026")
    assert report_title() == "FreshBasket report for June 2026"

def test_title_default(monkeypatch):
    monkeypatch.delenv("REPORT_MONTH", raising=False)
    assert report_title().endswith("unknown month")
''', encoding="utf-8")

code, out = run_pytest("test_loader.py")
print(out)

Output

....                                                                     [100%]
4 passed

How it works: tmp_path is a built-in fixture that gives each test its own empty temporary folder, which is deleted afterwards, so tests can create files freely without touching your real data. monkeypatch temporarily changes environment variables, attributes or functions and undoes the change when the test ends, so one test's settings never leak into another. Together they let you test file loaders, config readers and scripts deterministically.

Reading the result: {'passed': 4}. Each file test worked in its own temporary folder, and the environment tests did not disturb each other.

Example 10: Tests for the data itself

Question: How do we guard the quality of the actual order files?

Path("test_data_quality.py").write_text('''
import pandas as pd
import pytest

VALID_STATUSES = {"Delivered", "Cancelled", "Returned"}

@pytest.fixture(scope="module")
def orders():
    return pd.read_csv("orders.csv", parse_dates=["order_date"])

@pytest.fixture(scope="module")
def items():
    return pd.read_csv("order_items.csv")

def test_order_ids_are_unique(orders):
    assert orders["order_id"].is_unique

def test_statuses_are_known(orders):
    assert set(orders["order_status"]) <= VALID_STATUSES

def test_every_item_belongs_to_an_order(orders, items):
    assert items["order_id"].isin(orders["order_id"]).all()

def test_quantities_and_prices_are_positive(items):
    assert (items["quantity"] > 0).all()
    assert (items["unit_price"] > 0).all()

def test_delivered_orders_have_delivery_time(orders):
    delivered = orders[orders["order_status"] == "Delivered"]
    assert delivered["delivery_minutes"].notna().all()

def test_dates_are_not_in_the_future(orders):
    assert orders["order_date"].max() <= pd.Timestamp("2026-06-30")
''', encoding="utf-8")

code, out = run_pytest("test_data_quality.py")
print(out)

Output

......                                                                   [100%]
6 passed

How it works: Tests are not only for functions: they can assert facts about your data, run on every refresh, and catch problems (duplicated IDs, unknown statuses, orphan rows, impossible values) before they reach a dashboard. scope="module" loads each file once for all tests in the file instead of once per test. These tests encode what "healthy data" means for FreshBasket; when a new export violates one, you hear about it immediately and know which rule broke.

Reading the result: All 6 data-quality checks passed on the current files. If tomorrow's export contained a duplicated order, test_order_ids_are_unique would fail with exit code 1.

Common mistakes

1. Tests that depend on each other or on order. Each test must set up its own world; use fixtures.

2. Comparing floats with ==. Use pytest.approx.

3. Testing implementation instead of behaviour, which makes tests break whenever you refactor. Test inputs and outputs.

4. Only testing the happy path. Test blanks, zeros, missing values, duplicates and errors.

5. Never running the tests. Run them before every commit and automatically in CI.

A test that compares floats exactly fails for the wrong reason:

assert 0.1 + 0.2 == 0.3

Output

Traceback (most recent call last):
  File "example.py", line 1, in <module>
    assert 0.1 + 0.2 == 0.3
           ^^^^^^^^^^^^^^^^
AssertionError

Practice questions

Practice 1 (Easy): A test for a new function

Add a function to_lakh(amount) to analytics.py that converts rupees to lakhs (divide by 100,000), and write one test using pytest.approx.

Show solution
Path("analytics.py").write_text(Path("analytics.py").read_text(encoding="utf-8") + '''

def to_lakh(amount):
    return amount / 100000
''', encoding="utf-8")

Path("test_lakh.py").write_text('''
import pytest
from analytics import to_lakh

def test_to_lakh():
    assert to_lakh(3_500_000) == pytest.approx(35.0)
    assert to_lakh(0) == 0
''', encoding="utf-8")

code, out = run_pytest("test_lakh.py")
print(out, code)

Output

.                                                                        [100%]
1 passed 0

The new test result is 1 passed. Notice that we appended to the module instead of rewriting it, so the earlier functions are untouched.

Practice 2 (Medium): Parametrize the on-time rate

Write a parametrized test for on_time_rate with at least four cases (all on time, none on time, a mix, a custom limit) and run it.

Show solution
Path("test_ontime_param.py").write_text('''
import pytest
from analytics import on_time_rate

@pytest.mark.parametrize(
    "minutes, limit, expected",
    [
        ([10, 20, 30], 30, 1.0),
        ([31, 40, 50], 30, 0.0),
        ([20, 40], 30, 0.5),
        ([20, 40, None], 30, 0.5),
        ([20, 40], 45, 1.0),
    ],
)
def test_on_time_rate(minutes, limit, expected):
    assert on_time_rate(minutes, limit) == pytest.approx(expected)
''', encoding="utf-8")

code, out = run_pytest("test_ontime_param.py")
print(out)

Output

.....                                                                    [100%]
5 passed

{'passed': 5}: five cases from one function. The boundary case (exactly 30 minutes counts as on time) is the kind of detail that is easy to get wrong and worth pinning down with a test.

Practice 3 (Hard): Test first

Using test-first development, write a failing test for top_city(df) (the city with the highest delivered revenue), watch it fail, then implement the function so that it passes.

Show solution
Path("test_topcity.py").write_text('''
from topcity import top_city

def test_top_city_ignores_cancelled(small_orders):
    assert top_city(small_orders) == "Pune"
''', encoding="utf-8")

code1, out1 = run_pytest("test_topcity.py", "--tb=short")
print(out1.splitlines()[-1], "| exit code", code1)

Path("topcity.py").write_text('''
def top_city(df):
    delivered = df[df["order_status"] == "Delivered"]
    return delivered.groupby("city")["value"].sum().idxmax()
''', encoding="utf-8")

code2, out2 = run_pytest("test_topcity.py")
print(out2.splitlines()[-1], "| exit code", code2)

Output

1 error | exit code 2
1 passed | exit code 0

First run: 1 error (the module did not even exist), second run: 1 passed. Writing the test first forces you to decide what the function should do before you decide how, and a test that has never failed has never proved it can.

Key takeaways

  • A test is a function named test_* with an assert; pytest finds, runs and explains failures.
  • Turn every bug into a regression test, and write tests first when you can.
  • Use parametrize for many cases, pytest.raises for errors and pytest.approx for floats.
  • Fixtures (conftest.py), tmp_path and monkeypatch keep tests independent of real files and settings.
  • Test the data too: uniqueness, valid values, referential integrity, ranges.
  • Exit code 0 means pass: that is what schedulers and CI use to decide whether it is safe to proceed.

Frequently asked questions

How many tests do I need?

Enough that you would trust a change when they pass. Start with the logic that would be most expensive to get wrong (money, dates, filters), then add a test for each bug you find.

What is test coverage?

The share of your code lines executed by the tests, measured with pytest --cov (the pytest-cov plugin). High coverage does not prove the tests are good, but low coverage shows what is never checked.

Should I test pandas code?

Yes: put the logic in functions that take a DataFrame and return one, then test them with small hand-made frames and pd.testing.assert_frame_equal. Leave exploratory notebook code alone until it hardens into a function.

What is the difference between skip and xfail?

@pytest.mark.skip(reason=...) does not run the test; @pytest.mark.xfail runs it and expects it to fail (a known bug), and reports when it unexpectedly passes. Both keep known gaps visible without breaking the build.

Where do tests live?

Usually in a tests/ folder next to your code, with files named test_*.py and shared fixtures in conftest.py. Run everything with pytest, one file with pytest tests/test_x.py and a subset with pytest -k parse.

How do tests fit into automation?

Run them in CI (GitHub Actions, GitLab CI) on every change, and run the data-quality tests as the first step of a scheduled pipeline so that a bad export stops the job before it produces a wrong report.

Newsletter

Keep learning Python

One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.

Ready to practise? Try the Python interview questions