Back to the blog

Tony CantaveHow I Write Small Tests for Python Scripts

By Tony Cantave ·

Most of the Python I write is not a big application. It is scripts: a file that cleans a CSV, a function that formats data for a report, a helper that checks API responses. For a long time I tested those scripts the way most people do. I ran them, looked at the output, and decided it seemed fine. That works until the day you change one line and something three steps away quietly breaks.

Writing and debugging Python samples as part of my AI evaluation work changed how I think about this. When you review code all day, you learn that the bugs that hurt most are not the dramatic crashes. They are the small wrong answers nobody notices. A few tests catch those early, and they do not take long to write.

Start by pulling logic into functions

You cannot easily test a script that does everything at the top level. The first step is moving the real work into small functions that take input and return output. Reading files, printing and calling APIs stay at the edges. The decisions live in the middle, where a test can reach them.

Here is a small example. Say a script reads order amounts as text and needs to turn them into numbers, skipping anything invalid. This lives in a file called amounts.py.

def parse_amount(text):
    cleaned = text.strip().replace("$", "").replace(",", "")
    if cleaned == "":
        return None
    try:
        value = float(cleaned)
    except ValueError:
        return None
    if value < 0:
        return None
    return round(value, 2)


def sum_file(path):
    total = 0.0
    for line in path.read_text().splitlines():
        value = parse_amount(line)
        if value is not None:
            total += value
    return round(total, 2)

Write the first tests

Install pytest with pip install pytest, then create a file whose name starts with test_, such as test_amounts.py. Every function in it whose name starts with test_ is a test.

from amounts import parse_amount

def test_plain_number():
    assert parse_amount("42") == 42.0

def test_dollar_sign_and_commas():
    assert parse_amount("$1,250.50") == 1250.5

def test_blank_is_none():
    assert parse_amount("   ") is None

def test_text_is_none():
    assert parse_amount("ten dollars") is None

Run pytest in that folder and it finds the file, runs every test, and tells you which ones failed and why. Plain assert statements are enough. When one fails, pytest shows the actual value next to the expected one, which is usually all you need to find the problem.

Test the edges, not just the happy path

The test that proves "42" becomes 42.0 is the least valuable one. The useful tests sit at the edges: blank strings, extra spaces, symbols, values that are far too large, and input in a format you did not expect. When I review code, the edge cases are where most bugs hide, and the same is true for my own scripts.

A good habit is to ask one question before writing tests: what is the strangest input this function will realistically see? Then write a test for exactly that.

Use parametrize to keep tests short

When you have many similar cases, pytest lets you list them in one place.

import pytest
from amounts import parse_amount

@pytest.mark.parametrize("text, expected", [
    ("42", 42.0),
    ("$1,250.50", 1250.5),
    (" 7.1 ", 7.1),
    ("", None),
    ("abc", None),
])
def test_parse_amount(text, expected):
    assert parse_amount(text) == expected

Each row runs as its own test. Adding a new case becomes a one line change, which means you will actually do it.

Turn every bug into a test

This is the habit that pays off most. When something breaks, I write a test that reproduces the bug before I fix it. I run it, watch it fail, fix the code, and watch it pass. Now that bug cannot come back without the test catching it. Over time the test file becomes a record of every mistake the script has already survived.

Keep tests fast and independent

Tests that touch real files, networks or databases get slow and unreliable. For small scripts I avoid that by passing data into functions instead of having them load it themselves. When a function really does need a file, pytest provides a built in tmp_path fixture that gives each test its own empty folder, so tests never step on each other or on your real data.

from amounts import sum_file

def test_sum_file_skips_bad_lines(tmp_path):
    sample = tmp_path / "orders.txt"
    sample.write_text("10\n$20.50\nnot a number\n\n")
    assert sum_file(sample) == 30.5

That one test checks the file reading, the parsing and the skipping of bad lines together, without touching anything outside a temporary folder.

What this changed for me

More than fifteen years in logistics and manufacturing taught me that checks built into a process beat inspection at the end. Tests are the same idea applied to code. They are not about perfection. They are about being able to change a script on Monday without worrying about what you broke in the version from Friday. Start with three tests for the function you trust least, run them every time you make a change, and add one more each time something surprises you.