Toggle Menu Icon
Working with Python

Testing

Testing is one of those things I wish I’d learned earlier in my Python journey. It saves time debugging and gives you confidence when making changes. Here’s how to get started with pytest, one of the most popular testing frameworks.

Installing pytest

You can install pytest using uv or with pip

# Using uv (recommended)
uv add pytest

# Or with pip
pip install pytest

That’s it pytest is ready to go.

Your First Test

Let’s start with something simple. I’ll create a basic function and then test it.

Create a file called calculator.py:

def add(a, b):
    return a + b

def multiply(a, b):
    return a * b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Now create a test file called test_calculator.py:

from calculator import add, multiply, divide
import pytest

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

def test_multiply():
    assert multiply(3, 4) == 12
    assert multiply(-2, 5) == -10
    assert multiply(0, 100) == 0

def test_divide():
	assert divide(10, 2) == 5
	assert divide(7, 2) == 3.5

def test_divide_by_zero():
	with pytest.raises(ValueError):
		divide(10, 0)

Run the tests:

pytest test_calculator.py

You’ll see output like this:

========================= test session starts =======================
collected 4 items

test_calculator.py ....                                        [100%]

========================= 4 passed in 0.02s =========================

Understanding Assertions

The assert statement is your main tool for testing. It lets you verify that your code works as expected by checking if a condition is True. If the condition is False, the test fails and raises an AssertionError. Think of it as saying “I assert that this should be True” - if it’s not, something’s wrong with your code.

def test_assertions():
    # Basic equality
    assert 2 + 2 == 4

    # Not equal
    assert 5 != 3

    # Greater than, less than
    assert 10 > 5
    assert 3 < 7

    # In/not in
    assert 'hello' in 'hello world'
    assert 'x' not in 'hello'

    # True/False
    assert True
    assert not False

    # None checks
    result = None
    assert result is None

    # Type checks
    assert isinstance([1, 2, 3], list)

    # Length checks
    assert len([1, 2, 3]) == 3

Testing Exceptions

When you expect your code to raise an exception, use pytest.raises():

import pytest

def validate_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age > 150:
        raise ValueError("Age seems unrealistic")
    return age

def test_age_validation():
    # These should work fine
    assert validate_age(25) == 25
    assert validate_age(0) == 0

    # These should raise exceptions
    with pytest.raises(ValueError):
        validate_age(-1)

    with pytest.raises(ValueError):
        validate_age(200)

    # You can also check the exception message
    with pytest.raises(ValueError, match="Age cannot be negative"):
        validate_age(-5)

Running Tests

Here are the different ways to run tests:

# Run all tests
pytest

# Run tests in a specific file
pytest test_calculator.py

# Run a specific test function
pytest test_calculator.py::test_add

# Run tests with more verbose output
pytest -v

# Run tests and stop at first failure
pytest -x

Fixtures

Fixtures in pytest are a powerful feature used to set up and tear down resources needed by your tests. They are helper functions that run before your test setting up your environment or data needed for the test.

Use by decorating the helper function with @pytest.fixture and then passing it in as a parameter to your test. The return value from the function is set to the function variable.

import pytest

@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

@pytest.fixture
def user_data():
    return {
        'name': 'John Doe',
        'email': 'john@example.com',
        'age': 30
    }

def test_list_operations(sample_data):
    assert len(sample_data) == 5
    assert sum(sample_data) == 15
    assert max(sample_data) == 5

def test_user_validation(user_data):
    assert user_data['name'] == 'John Doe'
    assert '@' in user_data['email']
    assert user_data['age'] > 0

Use yield to return a value from setup, and then any code after yield will be run in teardown. Here’s an example, setting up a database connection or temporary files. Here is an example using yield to do both for testing a sqlite database from my tasks cli app.

import pytest

@pytest.fixture
def temp_file():
    """Fixture to create temp file"""
    with tempfile.NamedTemporaryFile() as tf:
        yield tf.name
    
    # tempfile will close and auto delete
    
def connection(temp_file):
    """Fixture to create temp db and yield connection"""            
    conn = sqlite3.connection(temp_file)
    create_schema(conn)
    yield conn
    conn.close()

def test_task_create(connection):
    entry = { "task_entry": "Test create task" }
    task_id = Task.create(connection, entry)
    assert task_id is not None

Parametrized Tests

When you want to test the same function with different inputs, use @pytest.mark.parametrize:

import pytest

def is_even(n):
    return n % 2 == 0

@pytest.mark.parametrize("number,expected", [
    (2, True),
    (3, False),
    (4, True),
    (5, False),
    (0, True),
    (-2, True),
    (-3, False),
])
def test_is_even(number, expected):
    assert is_even(number) == expected

@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (10, -5, 5),
])
def test_addition(a, b, expected):
    assert add(a, b) == expected

Testing with Files

Use tempfile to create temporary files for testing code that works with files:

import tempfile
import os

def save_data(filename, data):
    with open(filename, 'w') as f:
        for item in data:
            f.write(f"{item}\n")

def load_data(filename):
    with open(filename, 'r') as f:
        return [line.strip() for line in f]

def test_file_operations():
    # Create a temporary file
    with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
        tmp_name = tmp.name

    try:
        # Test saving data
        test_data = ['apple', 'banana', 'cherry']
        save_data(tmp_name, test_data)

        # Test loading data
        loaded_data = load_data(tmp_name)
        assert loaded_data == test_data

    finally:
        # Clean up
        os.unlink(tmp_name)

Tips for Better Tests

  1. Test one thing at a time - each test should focus on a single behavior
  2. Use descriptive test names - test_user_login_with_invalid_password is better than test_login
  3. Test the happy path and edge cases - what happens with empty inputs, None values, etc.
  4. Don’t test implementation details - test what the function does, not how it does it
  5. Keep tests simple - if your test is complex, maybe your code is too complex
  6. Run tests frequently - I run them before every commit

Testing might feel like extra work at first, but I promise it will save you hours of debugging later. Start small, add tests as you go, and soon it’ll become second nature.