Skip to main content

Testing with Pytest

Introduction​

Testing ensures your code works as expected and continues working after changes. Pytest is the most popular testing framework for Python that makes it easy to write unit tests with minimal boilerplate code.

Pytest automatically discovers test files and functions, provides excellent error reporting, and supports fixtures, parametrized testing, and plugins.


Concepts​

Test Discovery

  • Test files: usually test_*.py or *_test.py
  • Test functions: must start with test_
  • Test classes: must start with Test (optional)

Basic Structure

  • Use assert statements for testing
  • Pytest provides detailed assertion error messages
  • No need for special test classes or imports

Common Assertions

  • assert value == expected
  • assert value in collection
  • assert callable() raises Exception

Running Tests

  • pytest - run all tests
  • pytest test_file.py - run specific file
  • pytest -v - verbose output
  • pytest -k "pattern" - run tests matching pattern

Examples​

# 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

# test_calculator.py
import pytest
from calculator import add, multiply, divide

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

def test_multiply():
assert multiply(2, 4) == 8
assert multiply(-2, 3) == -6

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

def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)

Running the tests:

pytest test_calculator.py -v

Challenges​

1. multiply test​

  • Write a function multiply(a, b) and test that multiply(2, 4) equals 8.
💡 Show Answer
# math_operations.py
def multiply(a, b):
return a * b

# test_math_operations.py
from math_operations import multiply

def test_multiply():
assert multiply(2, 4) == 8
assert multiply(3, 5) == 15
assert multiply(-2, 3) == -6
assert multiply(0, 100) == 0

def test_multiply_floats():
assert multiply(2.5, 4) == 10.0
assert multiply(1.5, 2.5) == 3.75

# Run with: pytest test_math_operations.py::test_multiply -v

2. is_even test​

  • Write a function is_even(n) and test for both even and odd numbers.
💡 Show Answer
# number_utils.py
def is_even(n):
return n % 2 == 0

# test_number_utils.py
from number_utils import is_even

def test_is_even():
# Test even numbers
assert is_even(2) == True
assert is_even(4) == True
assert is_even(0) == True
assert is_even(-2) == True

# Test odd numbers
assert is_even(1) == False
assert is_even(3) == False
assert is_even(-1) == False

# Alternative using parametrize
import pytest

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

3. reverse_string test​

  • Write a function reverse_string(text) and test with "hello" → "olleh".
💡 Show Answer
# string_utils.py
def reverse_string(text):
return text[::-1]

# test_string_utils.py
import pytest
from string_utils import reverse_string

def test_reverse_string():
assert reverse_string("hello") == "olleh"
assert reverse_string("world") == "dlrow"
assert reverse_string("Python") == "nohtyP"

def test_reverse_string_edge_cases():
assert reverse_string("") == ""
assert reverse_string("a") == "a"
assert reverse_string("12321") == "12321" # palindrome

def test_reverse_string_with_spaces():
assert reverse_string("hello world") == "dlrow olleh"
assert reverse_string(" spaces ") == " secaps "

# Test with multiple implementations
def reverse_string_loop(text):
result = ""
for char in text:
result = char + result
return result

def reverse_string_list(text):
return ''.join(reversed(text))

@pytest.mark.parametrize("func", [reverse_string, reverse_string_loop, reverse_string_list])
def test_reverse_implementations(func):
assert func("hello") == "olleh"
assert func("Python") == "nohtyP"

4. JSON parsing error​

  • Test that invalid JSON raises an error using pytest.raises().
💡 Show Answer
# test_json_parsing.py
import json
import pytest

def test_json_parse_invalid():
with pytest.raises(json.JSONDecodeError):
json.loads("bad json")

def test_json_parse_invalid_with_message():
with pytest.raises(json.JSONDecodeError, match="Expecting value"):
json.loads("bad json")

def test_json_parse_valid():
# Test that valid JSON works
result = json.loads('{"name": "Alice", "age": 30}')
assert result == {"name": "Alice", "age": 30}

def test_json_parse_different_errors():
# Test different types of JSON errors
with pytest.raises(json.JSONDecodeError):
json.loads('{"incomplete": ')

with pytest.raises(json.JSONDecodeError):
json.loads('{"trailing": "comma",}')

with pytest.raises(json.JSONDecodeError):
json.loads('{"unquoted": key}')

# Custom function that wraps JSON parsing
def safe_json_parse(text):
try:
return json.loads(text)
except json.JSONDecodeError:
return None

def test_safe_json_parse():
assert safe_json_parse('{"valid": true}') == {"valid": True}
assert safe_json_parse("invalid json") is None

5. find_max test​

  • Write a function find_max(numbers) and test with [1, 5, 3].
💡 Show Answer
# list_utils.py
def find_max(numbers):
if not numbers:
raise ValueError("Cannot find max of empty list")
return max(numbers)

# test_list_utils.py
import pytest
from list_utils import find_max

def test_find_max():
assert find_max([1, 5, 3]) == 5
assert find_max([10, 2, 8, 1]) == 10
assert find_max([-1, -5, -3]) == -1

def test_find_max_single_element():
assert find_max([42]) == 42
assert find_max([-10]) == -10

def test_find_max_empty_list():
with pytest.raises(ValueError, match="Cannot find max of empty list"):
find_max([])

def test_find_max_with_duplicates():
assert find_max([5, 5, 5]) == 5
assert find_max([1, 3, 3, 2]) == 3

# Alternative implementation with different behavior for empty list
def find_max_safe(numbers):
return max(numbers) if numbers else None

def test_find_max_safe():
assert find_max_safe([1, 5, 3]) == 5
assert find_max_safe([]) is None
assert find_max_safe([42]) == 42

# Parametrized test for multiple cases
@pytest.mark.parametrize("numbers, expected", [
([1, 5, 3], 5),
([10, 2, 8, 1], 10),
([-1, -5, -3], -1),
([42], 42),
([5, 5, 5], 5)
])
def test_find_max_parametrized(numbers, expected):
assert find_max(numbers) == expected

6. Calculator class​

  • Create a Calculator class and test all its methods.
💡 Show Answer
# calculator.py
class Calculator:
def add(self, a, b):
return a + b

def subtract(self, a, b):
return a - b

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

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

def power(self, base, exponent):
return base ** exponent

# test_calculator.py
import pytest
from calculator import Calculator

class TestCalculator:
def setup_method(self):
"""Setup method called before each test method"""
self.calc = Calculator()

def test_add(self):
assert self.calc.add(2, 3) == 5
assert self.calc.add(-1, 1) == 0
assert self.calc.add(0, 0) == 0
assert self.calc.add(1.5, 2.5) == 4.0

def test_subtract(self):
assert self.calc.subtract(5, 3) == 2
assert self.calc.subtract(0, 5) == -5
assert self.calc.subtract(-2, -3) == 1

def test_multiply(self):
assert self.calc.multiply(3, 4) == 12
assert self.calc.multiply(-2, 3) == -6
assert self.calc.multiply(0, 100) == 0

def test_divide(self):
assert self.calc.divide(10, 2) == 5
assert self.calc.divide(7, 2) == 3.5
assert self.calc.divide(-6, 3) == -2

def test_divide_by_zero(self):
with pytest.raises(ValueError, match="Cannot divide by zero"):
self.calc.divide(10, 0)

def test_power(self):
assert self.calc.power(2, 3) == 8
assert self.calc.power(5, 0) == 1
assert self.calc.power(2, -1) == 0.5

# Alternative using fixtures
@pytest.fixture
def calculator():
return Calculator()

def test_calculator_operations(calculator):
assert calculator.add(2, 3) == 5
assert calculator.multiply(2, 4) == 8
assert calculator.subtract(10, 3) == 7

# Parametrized test for multiple operations
@pytest.mark.parametrize("a, b, operation, expected", [
(2, 3, "add", 5),
(10, 3, "subtract", 7),
(4, 5, "multiply", 20),
(15, 3, "divide", 5),
(2, 3, "power", 8)
])
def test_calculator_parametrized(calculator, a, b, operation, expected):
method = getattr(calculator, operation)
assert method(a, b) == expected

7. String validator​

  • Create functions to validate email and test edge cases.
💡 Show Answer
# validators.py
import re

def is_valid_email(email):
if not email or not isinstance(email, str):
return False

pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))

def is_strong_password(password):
if not password or len(password) < 8:
return False

has_upper = any(c.isupper() for c in password)
has_lower = any(c.islower() for c in password)
has_digit = any(c.isdigit() for c in password)
has_special = any(c in "!@#$%^&*()_+-=" for c in password)

return has_upper and has_lower and has_digit and has_special

# test_validators.py
import pytest
from validators import is_valid_email, is_strong_password

class TestEmailValidator:
def test_valid_emails(self):
valid_emails = [
"user@example.com",
"test.email@domain.org",
"user+tag@example.co.uk",
"firstname.lastname@company.com"
]
for email in valid_emails:
assert is_valid_email(email), f"Expected {email} to be valid"

def test_invalid_emails(self):
invalid_emails = [
"invalid.email", # no @
"@domain.com", # no local part
"user@", # no domain
"user@domain", # no TLD
"user name@domain.com", # space in local part
"", # empty string
None, # None value
"user@domain.", # TLD too short
]
for email in invalid_emails:
assert not is_valid_email(email), f"Expected {email} to be invalid"

class TestPasswordValidator:
def test_strong_passwords(self):
strong_passwords = [
"Password123!",
"MyStr0ng_Pass",
"C0mplex#Password",
"Aa1!bcde"
]
for password in strong_passwords:
assert is_strong_password(password), f"Expected {password} to be strong"

def test_weak_passwords(self):
weak_passwords = [
"password", # no uppercase, digits, special chars
"PASSWORD", # no lowercase, digits, special chars
"Password", # no digits, special chars
"Pass123", # too short, no special chars
"12345678", # no letters, special chars
"", # empty
None, # None
"Aa1!" # too short
]
for password in weak_passwords:
assert not is_strong_password(password), f"Expected {password} to be weak"

# Parametrized tests
@pytest.mark.parametrize("email, expected", [
("user@example.com", True),
("invalid.email", False),
("test@domain.org", True),
("@invalid.com", False),
("user@domain", False)
])
def test_email_validation_parametrized(email, expected):
assert is_valid_email(email) == expected

@pytest.mark.parametrize("password, expected", [
("Password123!", True),
("password", False),
("PASSWORD123!", False), # no lowercase
("Password!", False), # no digit
("Pass123", False), # no special char
("Aa1!", False) # too short
])
def test_password_validation_parametrized(password, expected):
assert is_strong_password(password) == expected

8. File operations​

  • Test file reading and writing operations.
💡 Show Answer
# file_utils.py
import json
import os

def write_text_file(filename, content):
with open(filename, 'w') as f:
f.write(content)

def read_text_file(filename):
with open(filename, 'r') as f:
return f.read()

def write_json_file(filename, data):
with open(filename, 'w') as f:
json.dump(data, f, indent=2)

def read_json_file(filename):
with open(filename, 'r') as f:
return json.load(f)

def file_exists(filename):
return os.path.exists(filename)

# test_file_utils.py
import pytest
import tempfile
import os
from file_utils import (
write_text_file, read_text_file,
write_json_file, read_json_file,
file_exists
)

class TestFileOperations:
def setup_method(self):
"""Create a temporary directory for each test"""
self.temp_dir = tempfile.mkdtemp()

def teardown_method(self):
"""Clean up temporary files after each test"""
import shutil
shutil.rmtree(self.temp_dir)

def test_write_and_read_text_file(self):
filename = os.path.join(self.temp_dir, "test.txt")
content = "Hello, World!"

write_text_file(filename, content)
assert file_exists(filename)

read_content = read_text_file(filename)
assert read_content == content

def test_write_and_read_json_file(self):
filename = os.path.join(self.temp_dir, "test.json")
data = {"name": "Alice", "age": 30, "skills": ["Python", "Testing"]}

write_json_file(filename, data)
assert file_exists(filename)

read_data = read_json_file(filename)
assert read_data == data

def test_read_nonexistent_file(self):
filename = os.path.join(self.temp_dir, "nonexistent.txt")

with pytest.raises(FileNotFoundError):
read_text_file(filename)

def test_file_exists_nonexistent(self):
filename = os.path.join(self.temp_dir, "nonexistent.txt")
assert not file_exists(filename)

# Using pytest fixtures for cleaner test setup
@pytest.fixture
def temp_file():
"""Fixture that provides a temporary file and cleans it up"""
import tempfile
fd, filename = tempfile.mkstemp()
os.close(fd) # Close the file descriptor
yield filename
if os.path.exists(filename):
os.unlink(filename)

def test_text_operations_with_fixture(temp_file):
content = "Test content"
write_text_file(temp_file, content)
assert read_text_file(temp_file) == content

def test_json_operations_with_fixture(temp_file):
data = {"test": True, "value": 42}
write_json_file(temp_file, data)
assert read_json_file(temp_file) == data

9. Mock external dependencies​

  • Test a function that makes HTTP requests using mocking.
💡 Show Answer
# weather_service.py
import requests

class WeatherService:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.weather.com"

def get_temperature(self, city):
url = f"{self.base_url}/weather"
params = {"city": city, "key": self.api_key}

response = requests.get(url, params=params)
if response.status_code == 200:
data = response.json()
return data.get("temperature")
else:
raise Exception(f"API error: {response.status_code}")

def is_sunny(self, city):
url = f"{self.base_url}/weather"
params = {"city": city, "key": self.api_key}

response = requests.get(url, params=params)
if response.status_code == 200:
data = response.json()
return data.get("condition") == "sunny"
return False

# test_weather_service.py
import pytest
from unittest.mock import Mock, patch
from weather_service import WeatherService

class TestWeatherService:
def setup_method(self):
self.service = WeatherService("test-api-key")

@patch('weather_service.requests.get')
def test_get_temperature_success(self, mock_get):
# Mock successful API response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"temperature": 25}
mock_get.return_value = mock_response

temperature = self.service.get_temperature("London")

assert temperature == 25
mock_get.assert_called_once_with(
"https://api.weather.com/weather",
params={"city": "London", "key": "test-api-key"}
)

@patch('weather_service.requests.get')
def test_get_temperature_api_error(self, mock_get):
# Mock API error response
mock_response = Mock()
mock_response.status_code = 404
mock_get.return_value = mock_response

with pytest.raises(Exception, match="API error: 404"):
self.service.get_temperature("InvalidCity")

@patch('weather_service.requests.get')
def test_is_sunny_true(self, mock_get):
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"condition": "sunny"}
mock_get.return_value = mock_response

result = self.service.is_sunny("Miami")
assert result is True

@patch('weather_service.requests.get')
def test_is_sunny_false(self, mock_get):
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"condition": "rainy"}
mock_get.return_value = mock_response

result = self.service.is_sunny("Seattle")
assert result is False

@patch('weather_service.requests.get')
def test_is_sunny_api_error(self, mock_get):
mock_response = Mock()
mock_response.status_code = 500
mock_get.return_value = mock_response

result = self.service.is_sunny("ErrorCity")
assert result is False

# Alternative using pytest-mock plugin (if installed)
def test_with_mocker(mocker):
service = WeatherService("test-key")

# Mock the requests.get function
mock_get = mocker.patch('weather_service.requests.get')
mock_response = mocker.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"temperature": 30}
mock_get.return_value = mock_response

temperature = service.get_temperature("Tokyo")
assert temperature == 30

10. Integration test​

  • Create a comprehensive test suite for a todo list application.
💡 Show Answer
# todo.py
class TodoItem:
def __init__(self, title, description=""):
self.title = title
self.description = description
self.completed = False
self.id = None

def mark_completed(self):
self.completed = True

def mark_pending(self):
self.completed = False

def __str__(self):
status = "✓" if self.completed else "○"
return f"{status} {self.title}"

class TodoList:
def __init__(self):
self.items = []
self.next_id = 1

def add_item(self, title, description=""):
item = TodoItem(title, description)
item.id = self.next_id
self.next_id += 1
self.items.append(item)
return item

def remove_item(self, item_id):
self.items = [item for item in self.items if item.id != item_id]

def get_item(self, item_id):
for item in self.items:
if item.id == item_id:
return item
return None

def get_completed_items(self):
return [item for item in self.items if item.completed]

def get_pending_items(self):
return [item for item in self.items if not item.completed]

def mark_item_completed(self, item_id):
item = self.get_item(item_id)
if item:
item.mark_completed()
return True
return False

def get_stats(self):
total = len(self.items)
completed = len(self.get_completed_items())
pending = len(self.get_pending_items())
return {"total": total, "completed": completed, "pending": pending}

# test_todo.py
import pytest
from todo import TodoItem, TodoList

class TestTodoItem:
def test_create_item(self):
item = TodoItem("Buy groceries", "Milk, bread, eggs")
assert item.title == "Buy groceries"
assert item.description == "Milk, bread, eggs"
assert item.completed == False
assert item.id is None

def test_create_item_without_description(self):
item = TodoItem("Call mom")
assert item.title == "Call mom"
assert item.description == ""
assert item.completed == False

def test_mark_completed(self):
item = TodoItem("Task")
item.mark_completed()
assert item.completed == True

def test_mark_pending(self):
item = TodoItem("Task")
item.mark_completed()
item.mark_pending()
assert item.completed == False

def test_string_representation(self):
item = TodoItem("Test task")
assert str(item) == "○ Test task"

item.mark_completed()
assert str(item) == "✓ Test task"

class TestTodoList:
def setup_method(self):
self.todo_list = TodoList()

def test_add_item(self):
item = self.todo_list.add_item("Buy milk")
assert item.title == "Buy milk"
assert item.id == 1
assert len(self.todo_list.items) == 1

def test_add_multiple_items(self):
item1 = self.todo_list.add_item("Task 1")
item2 = self.todo_list.add_item("Task 2")

assert item1.id == 1
assert item2.id == 2
assert len(self.todo_list.items) == 2

def test_remove_item(self):
item = self.todo_list.add_item("Task to remove")
self.todo_list.remove_item(item.id)
assert len(self.todo_list.items) == 0

def test_get_item(self):
item = self.todo_list.add_item("Find me")
found_item = self.todo_list.get_item(item.id)
assert found_item == item

def test_get_item_not_found(self):
item = self.todo_list.get_item(999)
assert item is None

def test_mark_item_completed(self):
item = self.todo_list.add_item("Complete me")
success = self.todo_list.mark_item_completed(item.id)
assert success == True
assert item.completed == True

def test_mark_nonexistent_item_completed(self):
success = self.todo_list.mark_item_completed(999)
assert success == False

def test_get_completed_items(self):
item1 = self.todo_list.add_item("Task 1")
item2 = self.todo_list.add_item("Task 2")
item3 = self.todo_list.add_item("Task 3")

item1.mark_completed()
item3.mark_completed()

completed = self.todo_list.get_completed_items()
assert len(completed) == 2
assert item1 in completed
assert item3 in completed

def test_get_pending_items(self):
item1 = self.todo_list.add_item("Task 1")
item2 = self.todo_list.add_item("Task 2")
item1.mark_completed()

pending = self.todo_list.get_pending_items()
assert len(pending) == 1
assert item2 in pending

def test_get_stats(self):
self.todo_list.add_item("Task 1")
item2 = self.todo_list.add_item("Task 2")
item3 = self.todo_list.add_item("Task 3")

item2.mark_completed()
item3.mark_completed()

stats = self.todo_list.get_stats()
assert stats == {"total": 3, "completed": 2, "pending": 1}

# Integration tests
class TestTodoListIntegration:
def test_complete_workflow(self):
todo_list = TodoList()

# Add several items
grocery_item = todo_list.add_item("Buy groceries", "Milk, bread, eggs")
call_item = todo_list.add_item("Call dentist")
exercise_item = todo_list.add_item("Go for a run")

# Check initial state
assert len(todo_list.items) == 3
assert all(not item.completed for item in todo_list.items)

# Complete some tasks
todo_list.mark_item_completed(grocery_item.id)
todo_list.mark_item_completed(call_item.id)

# Verify completion
assert grocery_item.completed
assert call_item.completed
assert not exercise_item.completed

# Check statistics
stats = todo_list.get_stats()
assert stats["total"] == 3
assert stats["completed"] == 2
assert stats["pending"] == 1

# Remove completed items
for item in todo_list.get_completed_items():
todo_list.remove_item(item.id)

# Verify only pending items remain
assert len(todo_list.items) == 1
assert todo_list.items[0] == exercise_item

# Final stats
final_stats = todo_list.get_stats()
assert final_stats["total"] == 1
assert final_stats["pending"] == 1
assert final_stats["completed"] == 0

# Fixtures for more complex testing
@pytest.fixture
def sample_todo_list():
todo_list = TodoList()
todo_list.add_item("Buy milk")
todo_list.add_item("Call mom")
todo_list.add_item("Finish project")
return todo_list

def test_with_sample_data(sample_todo_list):
assert len(sample_todo_list.items) == 3
sample_todo_list.mark_item_completed(1)
assert len(sample_todo_list.get_completed_items()) == 1