- What is pytest β a powerful testing framework
- Why use pytest β simpler, cleaner tests
- Writing tests β no classes required
- Fixtures β setup and teardown made easy
- Parametrize β test multiple inputs
- Marks β organize and skip tests
What is Pytest?
Pytest is a popular testing framework for Python. It's simpler and more powerful than the built-in unittest module. Many developers prefer pytest because it requires less boilerplate code and has useful features like fixtures and parameterized testing.
Think of pytest like a modern testing toolkit. While unittest is like a basic toolset, pytest is like a complete workshop with specialized tools for every testing need. It's fast, flexible, and makes testing fun.
To use pytest, you need to install it first: pip install pytest
π‘ Key concept: Pytest is a mature testing framework that makes writing and running tests simple and enjoyable.
Why Pytest?
The Benefits of Pytest
Pytest offers several advantages over the built-in unittest module.
# Why Pytest?
print("=" * 50)
print("WHY PYTEST?")
print("=" * 50)
# ============================================================
# UNITTEST STYLE (More Code)
# ============================================================
print("\n1. UNITTEST STYLE")
print("""
import unittest
class TestMath(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
def test_subtract(self):
self.assertEqual(subtract(5, 3), 2)
if __name__ == '__main__':
unittest.main()
""")
# ============================================================
# PYTEST STYLE (Less Code)
# ============================================================
print("\n2. PYTEST STYLE")
print("""
def test_add():
assert add(2, 3) == 5
def test_subtract():
assert subtract(5, 3) == 2
""")
print(" No classes needed!")
print(" No assert methods needed!")
# ============================================================
# BENEFITS SUMMARY
# ============================================================
print("\n" + "-" * 30)
print("BENEFITS OF PYTEST")
print("-" * 30)
print("""
- No classes required (just functions)
- Uses plain assert statements
- Detailed failure reports
- Powerful fixtures
- Parametrize tests easily
- Many plugins available
- Automatic test discovery
- Works with unittest tests too
""")
Benefits of pytest:
- No classes needed β just write functions
- Plain asserts β use
assertstatements - Better output β detailed failure reports
- Powerful fixtures β more flexible than setUp
- Parametrize β test multiple inputs easily
Quick Check: What's the main advantage of pytest over unittest? (Answer: Less boilerplate code and more powerful features)
Your First Pytest
Writing and Running Your First Test
Writing a pytest is as simple as writing a function that starts with test_ and uses assert statements.
# Your First Pytest
print("=" * 50)
print("YOUR FIRST PYTEST")
print("=" * 50)
# ============================================================
# CODE TO TEST
# ============================================================
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
# ============================================================
# PYTEST TESTS (Save in test_calculator.py)
# ============================================================
print("\n1. PYTEST TESTS")
print("""
# test_calculator.py
def test_add():
assert add(2, 3) == 5
assert add(-2, 3) == 1
assert add(0, 0) == 0
def test_multiply():
assert multiply(2, 3) == 6
assert multiply(-2, 3) == -6
assert multiply(0, 5) == 0
def test_divide():
assert divide(6, 3) == 2
assert divide(10, 2) == 5
def test_divide_by_zero():
import pytest
with pytest.raises(ValueError):
divide(10, 0)
""")
# ============================================================
# RUNNING TESTS
# ============================================================
print("\n2. RUNNING TESTS")
print("""
Run tests:
pytest test_calculator.py
Run with verbose output:
pytest -v test_calculator.py
Run all tests in directory:
pytest
Run specific test:
pytest test_calculator.py::test_add
""")
# ============================================================
# EXAMPLE OUTPUT
# ============================================================
print("\n3. EXAMPLE OUTPUT")
print("""
$ pytest test_calculator.py -v
============================= test session starts ==============================
collected 4 items
test_calculator.py::test_add PASSED [ 25%]
test_calculator.py::test_multiply PASSED [ 50%]
test_calculator.py::test_divide PASSED [ 75%]
test_calculator.py::test_divide_by_zero PASSED [100%]
============================== 4 passed in 0.02s ===============================
""")
# ============================================================
# ASSERTION DETAILS
# ============================================================
print("\n4. ASSERTION DETAILS")
print("""
If a test fails, pytest shows detailed information:
def test_add():
assert add(2, 3) == 6 # Fails!
Output:
E assert 5 == 6
E + where 5 = add(2, 3)
""")
First pytest key points:
- Test functions β start with
test_ - Assert statements β use plain
assert - Run tests β
pytest filename.py - pytest.raises β test for exceptions
Quick Check: How do you test for exceptions in pytest? (Answer: with pytest.raises(ExceptionType):)
Fixtures in Pytest
Better Setup and Teardown
Pytest fixtures are more powerful than unittest's setUp/tearDown. They can be used across multiple tests and can have different scopes.
# Fixtures in Pytest
print("=" * 50)
print("FIXTURES IN PYTEST")
print("=" * 50)
# ============================================================
# CODE TO TEST
# ============================================================
class Calculator:
def __init__(self):
self.result = 0
def add(self, x):
self.result += x
return self.result
def subtract(self, x):
self.result -= x
return self.result
def reset(self):
self.result = 0
return self.result
# ============================================================
# PYTEST WITH FIXTURES
# ============================================================
print("\n1. FIXTURES EXAMPLE")
print("""
import pytest
# Fixture - runs before each test
@pytest.fixture
def calculator():
\"\"\"Create a calculator with initial state\"\"\"
print(" Creating calculator")
calc = Calculator()
yield calc # This is what tests receive
print(" Cleaning up")
# Tests using the fixture
def test_add(calculator):
assert calculator.add(5) == 5
assert calculator.add(3) == 8
def test_subtract(calculator):
assert calculator.subtract(10) == -10
assert calculator.add(5) == -5
def test_reset(calculator):
calculator.add(10)
assert calculator.result == 10
calculator.reset()
assert calculator.result == 0
""")
# ============================================================
# FIXTURE SCOPES
# ============================================================
print("\n2. FIXTURE SCOPES")
print("""
@pytest.fixture(scope="function") # Default - runs per test
@pytest.fixture(scope="class") # Runs once per test class
@pytest.fixture(scope="module") # Runs once per module
@pytest.fixture(scope="session") # Runs once per test session
# Example:
@pytest.fixture(scope="module")
def db_connection():
# This runs once for all tests in the module
print(" Connecting to database")
connection = "DB Connection"
yield connection
print(" Disconnecting from database")
""")
# ============================================================
# USING MULTIPLE FIXTURES
# ============================================================
print("\n3. USING MULTIPLE FIXTURES")
print("""
@pytest.fixture
def user():
return {"name": "Alice", "age": 30}
@pytest.fixture
def logged_in_user(user):
# Use one fixture to create another
return {"user": user, "logged_in": True}
def test_user_profile(logged_in_user):
assert logged_in_user["user"]["name"] == "Alice"
assert logged_in_user["logged_in"] is True
""")
# ============================================================
# FIXTURE YIELD (Cleanup)
# ============================================================
print("\n4. FIXTURE CLEANUP (yield)")
print("""
@pytest.fixture
def temp_file():
# Setup
with open("temp.txt", "w") as f:
f.write("test data")
yield "temp.txt" # Provide the file to tests
# Teardown (after test runs)
import os
if os.path.exists("temp.txt"):
os.remove("temp.txt")
""")
# ============================================================
# CONFTEST.PY (Shared Fixtures)
# ============================================================
print("\n5. CONFTEST.PY")
print("""
# Create a file called conftest.py in your test directory
# Fixtures defined here are available to all tests
# conftest.py
import pytest
@pytest.fixture
def global_fixture():
return "Available everywhere"
# Any test can use this fixture without importing
""")
Fixtures key points:
- @pytest.fixture β decorator to create a fixture
- yield β provides the value and runs cleanup after
- Scopes β function, class, module, session
- conftest.py β share fixtures across test files
Quick Check: What does yield do in a pytest fixture? (Answer: It provides the fixture value to tests and runs cleanup code after the test)
Parametrize Tests
Test Multiple Inputs Easily
@pytest.mark.parametrize lets you run the same test with different inputs. This is much cleaner than writing separate tests.
# Parametrize Tests
print("=" * 50)
print("PARAMETRIZE TESTS")
print("=" * 50)
# ============================================================
# CODE TO TEST
# ============================================================
def is_even(n):
return n % 2 == 0
def to_uppercase(text):
return text.upper()
def factorial(n):
if n < 0:
raise ValueError("Negative factorial")
if n == 0:
return 1
return n * factorial(n - 1)
# ============================================================
# PARAMETRIZE EXAMPLES
# ============================================================
print("\n1. BASIC PARAMETRIZE")
print("""
import pytest
# Test multiple values in one test
@pytest.mark.parametrize("number, expected", [
(2, True),
(3, False),
(4, True),
(5, False),
(10, True),
(11, False),
])
def test_is_even(number, expected):
assert is_even(number) == expected
""")
# ============================================================
# MULTIPLE PARAMETERS
# ============================================================
print("\n2. MULTIPLE PARAMETERS")
print("""
@pytest.mark.parametrize("input_text, expected", [
("hello", "HELLO"),
("World", "WORLD"),
("Python", "PYTHON"),
("123", "123"),
])
def test_to_uppercase(input_text, expected):
assert to_uppercase(input_text) == expected
""")
# ============================================================
# COMBINED PARAMETRIZE
# ============================================================
print("\n3. COMBINED PARAMETRIZE")
print("""
# Combine multiple parametrize decorators
@pytest.mark.parametrize("n", [0, 1, 2, 3, 4, 5])
@pytest.mark.parametrize("expected", [1, 1, 2, 6, 24, 120])
def test_factorial(n, expected):
assert factorial(n) == expected
# Or use with tuples
@pytest.mark.parametrize("n, expected", [
(0, 1),
(1, 1),
(2, 2),
(3, 6),
(4, 24),
(5, 120),
])
def test_factorial(n, expected):
assert factorial(n) == expected
""")
# ============================================================
# PARAMETRIZE WITH EXCEPTIONS
# ============================================================
print("\n4. PARAMETRIZE WITH EXCEPTIONS")
print("""
@pytest.mark.parametrize("n, expected_exception", [
(-1, ValueError),
(-5, ValueError),
(0, None), # No exception
(1, None),
])
def test_factorial_exceptions(n, expected_exception):
if expected_exception:
with pytest.raises(expected_exception):
factorial(n)
else:
assert factorial(n) is not None
""")
# ============================================================
# PARAMETRIZE WITH IDS
# ============================================================
print("\n5. PARAMETRIZE WITH IDS")
print("""
# Add IDs to test cases for better reporting
@pytest.mark.parametrize("n, expected", [
(0, 1),
(1, 1),
(2, 2),
(3, 6),
(4, 24),
(5, 120),
], ids=["zero", "one", "two", "three", "four", "five"])
def test_factorial(n, expected):
assert factorial(n) == expected
""")
Parametrize key points:
- @pytest.mark.parametrize β run test with multiple inputs
- Arguments β list of (input, expected) tuples
- Multiple parameters β can have multiple inputs
- ids β name test cases for better output
Quick Check: What decorator lets you run a test with multiple inputs? (Answer: @pytest.mark.parametrize)
Marks and Skipping
Organize, Skip, and Select Tests
Pytest marks let you categorize tests, skip tests, and run specific groups of tests.
# Marks and Skipping
print("=" * 50)
print("MARKS AND SKIPPING")
print("=" * 50)
# ============================================================
# MARKING TESTS
# ============================================================
print("\n1. MARKING TESTS")
print("""
import pytest
# Mark a test as slow
@pytest.mark.slow
def test_heavy_computation():
import time
time.sleep(2)
assert True
# Mark a test as integration test
@pytest.mark.integration
def test_database_connection():
assert True
# Mark a test with multiple tags
@pytest.mark.slow
@pytest.mark.integration
def test_slow_integration():
assert True
""")
# ============================================================
# SKIPPING TESTS
# ============================================================
print("\n2. SKIPPING TESTS")
print("""
# Skip a test unconditionally
@pytest.mark.skip(reason="Not implemented yet")
def test_unimplemented():
assert False
# Skip a test conditionally
import sys
@pytest.mark.skipif(sys.version_info < (3, 8), reason="Requires Python 3.8+")
def test_python_version():
assert True
# Skip based on a condition
@pytest.mark.skipif(not hasattr(os, "getlogin"), reason="No getlogin")
def test_user_login():
assert True
""")
# ============================================================
# EXPECTED FAILURES (xfail)
# ============================================================
print("\n3. EXPECTED FAILURES (xfail)")
print("""
# Mark a test as expected to fail
@pytest.mark.xfail(reason="Known bug")
def test_buggy_function():
assert False # This will show as xfail (expected failure)
# Conditional xfail
@pytest.mark.xfail(sys.platform == "win32", reason="Not supported on Windows")
def test_platform_specific():
assert True
""")
# ============================================================
# RUNNING TESTS BY MARK
# ============================================================
print("\n4. RUNNING TESTS BY MARK")
print("""
# Run only slow tests
pytest -m slow
# Run only tests with both slow and integration
pytest -m "slow and integration"
# Run all except slow tests
pytest -m "not slow"
# Run tests with multiple marks
pytest -m "slow or integration"
""")
# ============================================================
# CUSTOM MARKS REGISTRATION
# ============================================================
print("\n5. CUSTOM MARKS REGISTRATION")
print("""
# Register custom marks in pytest.ini:
# [pytest]
# markers =
# slow: marks tests as slow
# integration: marks tests as integration tests
# unit: marks tests as unit tests
""")
# ============================================================
# SUMMARY OF MARKS
# ============================================================
print("\n" + "-" * 30)
print("MARKS SUMMARY")
print("-" * 30)
print("""
βββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββ
β Mark β Purpose β
βββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββ€
β @pytest.mark.skip β Skip test unconditionally β
β @pytest.mark.skipif β Skip test if condition is true β
β @pytest.mark.xfail β Test is expected to fail β
β @pytest.mark.param β Run test with multiple parameters β
β @pytest.mark.slow β Custom mark (any name) β
β @pytest.mark.timeoutβ Set timeout (requires plugin) β
βββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββ
""")
Marks key points:
- @pytest.mark.skip β skip a test
- @pytest.mark.skipif β skip conditionally
- @pytest.mark.xfail β expected failure
- Custom marks β categorize tests
- Run with -m β select tests by mark
Quick Check: How do you run only tests marked as "slow"? (Answer: pytest -m slow)
Real-World Example
Testing a User Authentication System
# Real-World Example: Testing User Authentication
import pytest
import hashlib
import time
print("=" * 60)
print("TESTING USER AUTHENTICATION SYSTEM")
print("=" * 60)
# ============================================================
# CODE TO TEST
# ============================================================
class UserAuth:
"""Simple user authentication system"""
def __init__(self):
self.users = {}
self.sessions = {}
self.failed_attempts = {}
def _hash_password(self, password):
return hashlib.sha256(password.encode()).hexdigest()[:16]
def register_user(self, username, password):
if username in self.users:
raise ValueError(f"User {username} already exists")
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
self.users[username] = self._hash_password(password)
self.failed_attempts[username] = 0
return f"User {username} registered"
def login(self, username, password):
if username not in self.users:
raise ValueError(f"User {username} not found")
if self.failed_attempts.get(username, 0) >= 3:
return "Account locked due to too many failed attempts"
if self.users[username] == self._hash_password(password):
self.failed_attempts[username] = 0
self.sessions[username] = int(time.time())
return "Login successful"
else:
self.failed_attempts[username] = self.failed_attempts.get(username, 0) + 1
return "Invalid password"
def logout(self, username):
if username in self.sessions:
del self.sessions[username]
return "Logged out"
return "User not logged in"
def is_logged_in(self, username):
return username in self.sessions
def get_failed_attempts(self, username):
return self.failed_attempts.get(username, 0)
# ============================================================
# PYTEST TESTS
# ============================================================
print("\n1. TEST FILE (test_auth.py)")
print("""
import pytest
@pytest.fixture
def auth():
\"\"\"Create a fresh auth system for each test\"\"\"
return UserAuth()
def test_register_user(auth):
result = auth.register_user("alice", "SecurePass123")
assert result == "User alice registered"
assert "alice" in auth.users
def test_register_duplicate_user(auth):
auth.register_user("alice", "SecurePass123")
with pytest.raises(ValueError, match="User alice already exists"):
auth.register_user("alice", "NewPass456")
def test_register_short_password(auth):
with pytest.raises(ValueError, match="Password must be at least 8 characters"):
auth.register_user("bob", "short")
def test_login_success(auth):
auth.register_user("alice", "SecurePass123")
result = auth.login("alice", "SecurePass123")
assert result == "Login successful"
assert auth.is_logged_in("alice") is True
def test_login_wrong_password(auth):
auth.register_user("alice", "SecurePass123")
result = auth.login("alice", "WrongPassword")
assert result == "Invalid password"
assert auth.get_failed_attempts("alice") == 1
assert auth.is_logged_in("alice") is False
def test_login_lockout(auth):
auth.register_user("alice", "SecurePass123")
for _ in range(3):
auth.login("alice", "WrongPassword")
result = auth.login("alice", "SecurePass123")
assert result == "Account locked due to too many failed attempts"
def test_logout(auth):
auth.register_user("alice", "SecurePass123")
auth.login("alice", "SecurePass123")
result = auth.logout("alice")
assert result == "Logged out"
assert auth.is_logged_in("alice") is False
def test_login_user_not_found(auth):
with pytest.raises(ValueError, match="User unknown not found"):
auth.login("unknown", "password")
# Parametrized tests for multiple users
@pytest.mark.parametrize("username, password", [
("alice", "SecurePass123"),
("bob", "MyP@ssw0rd!"),
("charlie", "SecretPassword"),
])
def test_multiple_users(auth, username, password):
auth.register_user(username, password)
result = auth.login(username, password)
assert result == "Login successful"
assert auth.is_logged_in(username) is True
""")
Real-world example key points:
- Fixture β creates a fresh auth system for each test
- Test registration β register users with validation
- Test login β success, failure, and lockout
- Parametrize β test multiple users
- Exception testing β test error cases
Quick Check: What would you use a fixture for in this example? (Answer: To create a fresh UserAuth instance for each test)
Best Practices
Writing Great Tests with Pytest
# Best Practices for Pytest
print("=" * 60)
print("BEST PRACTICES FOR PYTEST")
print("=" * 60)
# ============================================================
# 1. NAME TESTS CLEARLY
# ============================================================
print("\n1. NAME TESTS CLEARLY")
print("""
# Good - clear description
def test_login_with_correct_password_returns_success():
...
def test_login_with_wrong_password_returns_error():
...
# Bad - vague name
def test_login():
...
""")
# ============================================================
# 2. USE FIXTURES FOR COMMON SETUP
# ============================================================
print("\n2. USE FIXTURES FOR COMMON SETUP")
print("""
# Good - use fixture
@pytest.fixture
def user():
return {"name": "Alice", "email": "alice@example.com"}
def test_user_email(user):
assert user["email"] == "alice@example.com"
# Bad - repeat setup in each test
def test_user_email():
user = {"name": "Alice", "email": "alice@example.com"}
assert user["email"] == "alice@example.com"
""")
# ============================================================
# 3. USE PARAMETRIZE FOR MULTIPLE CASES
# ============================================================
print("\n3. USE PARAMETRIZE FOR MULTIPLE CASES")
print("""
# Good - one test with multiple cases
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(0, 5, 5),
(-2, 3, 1),
])
def test_add(a, b, expected):
assert add(a, b) == expected
# Bad - multiple similar tests
def test_add_positive():
assert add(2, 3) == 5
def test_add_zero():
assert add(0, 5) == 5
def test_add_negative():
assert add(-2, 3) == 1
""")
# ============================================================
# 4. KEEP TESTS INDEPENDENT
# ============================================================
print("\n4. KEEP TESTS INDEPENDENT")
print("""
# Good - each test creates its own data
def test_user_creation():
user = User("alice")
assert user.name == "alice"
def test_user_email():
user = User("alice", "alice@example.com")
assert user.email == "alice@example.com"
# Bad - tests share state
user = User("alice") # Global state
def test_user_creation():
assert user.name == "alice"
def test_user_email():
# This test depends on the previous one
assert user.email == "alice@example.com"
""")
# ============================================================
# 5. USE APPROPRIATE ASSERTIONS
# ============================================================
print("\n5. USE APPROPRIATE ASSERTIONS")
print("""
# Good - use pytest's rich assertions
def test_result():
result = calculate()
assert result == 10
assert "error" not in result
assert len(result) > 5
# Bad - too many assertions
def test_result():
result = calculate()
assert result == 10
assert result == 10
assert result == 10 # Redundant
""")
# ============================================================
# 6. TEST EDGE CASES
# ============================================================
print("\n6. TEST EDGE CASES")
print("""
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_empty_list():
assert process([]) == []
def test_negative_numbers():
assert process(-5) == "negative"
""")
# ============================================================
# 7. SUMMARY
# ============================================================
print("\n" + "=" * 60)
print("BEST PRACTICES SUMMARY")
print("=" * 60)
print("""
- Name tests clearly and descriptively
- Use fixtures for common setup
- Use parametrize for multiple cases
- Keep tests independent
- Test edge cases and errors
- Use appropriate assertions
- Run tests frequently
""")
Best practices summary:
- Clear names β describe what's being tested
- Use fixtures β avoid repetition
- Parametrize β test multiple cases
- Independent tests β no shared state
- Edge cases β test error conditions
Quick Check: Why should tests be independent? (Answer: So they can run in any order without affecting each other)
Try It Yourself
Experiment with pytest in the editor below.
PYTEST - PRACTICE
==================================================
You've Got It!
You now understand the pytest framework in Python. You know how to write tests, use fixtures, parametrize tests, and use marks to organize your tests.
Quick Quiz
Test what you've learned:
Frequently Asked Questions
What is pytest in Python?
What's the difference between pytest and unittest?
How do I run pytest?
pytest in your terminal. You can also specify a file: pytest test_file.py, a test function: pytest test_file.py::test_function, or use options like -v for verbose output.
What are fixtures in pytest?
@pytest.fixture decorator. Fixtures can be shared across tests, have different scopes, and can be parameterized.
How do I skip a test in pytest?
@pytest.mark.skip(reason="message") to skip a test unconditionally, or @pytest.mark.skipif(condition, reason="message") to skip conditionally.
What is conftest.py used for?
Where to Go From Here
Now that you understand pytest, check out these related topics:
Mocking
Learn how to mock dependencies in your tests.
Learn More βUnit Testing
Learn about the built-in unittest module.
Learn More βTesting Assignments
Practice your testing skills with assignments.
Learn More β