Python Testing with pytest: Write Tests That Actually Help
Imagine you wrote a spell-checker. It works great on the word "hello." But what about "HeLLo"? Or an empty string? Or a number? You won't know until something breaks - unless you write tests.
A test is a small piece of code that checks whether your other code works correctly. It's like a safety net for your programs. Every time you make a change, you run your tests, and they instantly tell you if you broke something.
In this tutorial, you'll learn to write tests using pytest-style conventions - the most popular testing approach in Python. We'll use plain assert statements and simple test functions that you can run right here in your browser.
Why Should You Write Tests?
You might think: "I can just run my code and see if it works." That's fine for tiny programs. But real projects have hundreds of functions. You can't manually check every single one after every change.
Tests solve three big problems:
Here's a simple example. Say you write a function that adds two numbers:
That print statement tells you the answer is 5, but you have to check it with your eyes every time. What if someone changes the function later? Nobody will remember to re-run that print.
How Do You Write Your First Test Function?
A test function is just a regular Python function whose name starts with test_. Inside, you use assert to check that something is true. If the assertion passes, the test passes. If it fails, Python raises an error.
Notice the pattern: each test function has a descriptive name that explains what it checks. The name test_add_positive_numbers tells you exactly what scenario is being tested.
In a real project, you'd use the pytest framework to discover and run tests automatically. Here, we call them manually - but the logic is identical.
How Do Assert Statements Work?
The assert statement is the heart of every test. It says: "I believe this thing is true. If it's not, stop everything and tell me."
Here are the most useful types of assertions you'll write:
# Equality
assert result == expected
# Truthiness
assert is_valid
assert not is_empty
# Membership
assert item in collection
assert key in dictionary
# Type checking
assert isinstance(value, int)
# Comparisons
assert count > 0
assert length <= max_lengthWhen an assertion fails, Python tells you exactly what went wrong:
What Are Edge Cases and Why Do They Matter?
An edge case is an unusual or extreme input that your code might not handle well. Think of it like stress-testing a bridge - you don't just drive a car over it, you also check what happens with a full convoy of trucks.
How Should You Organize Your Tests?
As your project grows, you'll have dozens or even hundreds of tests. Keeping them organized is essential. The standard approach is to group related tests together.
One powerful technique is using a test runner - a function that collects and runs all your test functions, reporting which passed and which failed:
def test1():
assert add(1, 2) == 3
def test2():
assert add(-1, 1) == 0
def another_test():
# Won't be found by pytest!
assert add(0, 0) == 0def test_add_positive_numbers():
assert add(1, 2) == 3
def test_add_mixed_signs():
assert add(-1, 1) == 0
def test_add_zeros():
assert add(0, 0) == 0What Is Test-Driven Thinking?
Here's a mindset shift that will make you a better programmer: instead of writing code first and testing later, think about your tests while you design your function.
Before you write a single line of code, ask yourself: "How will I know this works?" The answer to that question is your test.
Practice Exercises
Write a function called square that returns the square of a number. Then write a function called test_square that tests it with at least 3 different assert statements (test with a positive number, zero, and a negative number). Call test_square() and print All tests passed! at the end.
# Write the square function
def square(n):
pass
# Write the test function
def test_square():
# Add at least 3 assert statements
pass
# Run the test and print result
The function is_adult is supposed to return True if age is 18 or older, and False otherwise. The tests are correct, but the function has a bug. Fix the function so all tests pass, then print All tests passed!.
def is_adult(age):
return age > 18 # Bug is here!
def test_is_adult():
assert is_adult(18) == True
assert is_adult(21) == True
assert is_adult(17) == False
assert is_adult(0) == False
test_is_adult()
print('All tests passed!')Read the code below carefully. The test runner catches assertion errors and reports results. Predict exactly what gets printed. Think about which tests will pass and which will fail.
def clamp(value, low, high):
if value < low:
return low
if value > high:
return high
return value
tests = [
('in_range', lambda: clamp(5, 1, 10) == 5),
('below_min', lambda: clamp(-3, 0, 100) == 0),
('above_max', lambda: clamp(50, 1, 10) == 10),
('at_boundary', lambda: clamp(10, 1, 10) == 10),
]
for name, check in tests:
if check():
print(f'PASS: {name}')
else:
print(f'FAIL: {name}')The truncate function shortens a string to a maximum length and adds "..." if it was cut. Write a test_truncate function that tests at least 4 edge cases: a string shorter than the limit, a string exactly at the limit, a string longer than the limit, and an empty string. Call the test and print All tests passed!.
def truncate(text, max_length):
if len(text) <= max_length:
return text
return text[:max_length] + '...'
def test_truncate():
# Test: string shorter than limit
# Test: string exactly at limit
# Test: string longer than limit
# Test: empty string
pass
test_truncate()
print('All tests passed!')Write a function called run_tests that takes a list of test functions, runs each one inside a try/except block, and prints a summary at the end. Use the format shown: print PASS: <function_name> or FAIL: <function_name> for each test, then print a blank line followed by <passed> passed, <failed> failed. Use the provided test functions to demonstrate it.
def divide(a, b):
if b == 0:
return None
return a / b
def test_divide_normal():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
assert divide(10, 0) is None
def test_divide_negative():
assert divide(-6, 3) == -2.0
def run_tests(tests):
# Your code here
pass
run_tests([test_divide_normal, test_divide_by_zero, test_divide_negative])The tests below work but are repetitive. Refactor them to use a data-driven approach: store test cases as a list of tuples (input, expected) and loop through them. The function, all assertions, and the final print statement should remain. Print Testing: <input> -> <expected> for each case, then All 5 tests passed! at the end.
def grade(score):
if score >= 90:
return 'A'
elif score >= 80:
return 'B'
elif score >= 70:
return 'C'
elif score >= 60:
return 'D'
else:
return 'F'
# These tests work but are repetitive - refactor them!
def test_grade_a():
assert grade(95) == 'A'
def test_grade_b():
assert grade(85) == 'B'
def test_grade_c():
assert grade(75) == 'C'
def test_grade_d():
assert grade(65) == 'D'
def test_grade_f():
assert grade(45) == 'F'
test_grade_a()
test_grade_b()
test_grade_c()
test_grade_d()
test_grade_f()
print('All 5 tests passed!')