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 NoneRun 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) == expectedEach 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.5That 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.