Python Testing — Complete Guide with pytest
Testing is the practice of writing code that verifies your program behaves correctly. In Python, the two main testing frameworks are unittest (built-in) and pytest (third-party, more popular).
What You’ll Learn
unittestbasics: TestCase, assertions, setUp/tearDownpytestfundamentals: fixtures, parametrize, conftest.py- Mocking with
unittest.mockto isolate code from external dependencies - Measuring test coverage with
coverage.py - Test-Driven Development (TDD) workflow
Why Testing Matters
Durga Antivirus Pro has thousands of tests — every signature update must be verified against known malware before deployment. DodaZIP tests compression on dozens of file formats. Without tests, a single bug can corrupt user data. Testing gives you confidence to refactor, add features, and ship without fear.
flowchart LR
A["File I/O"] --> B["Error Handling"]
B --> C["Testing"]
C --> D["Type Hints"]
D --> E["Async"]
E --> F["Deployment"]
A:::done --> B:::done --> C:::current
style A fill:#2563eb,stroke:#2563eb,color:#fff
style B fill:#2563eb,stroke:#2563eb,color:#fff
style C fill:#2563eb,stroke:#2563eb,color:#fff
style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
style E fill:#dbeafe,stroke:#2563eb,color:#1e40af
style F fill:#f1f5f9,stroke:#94a3b8,color:#64748b
Calculator App — The System Under Test
Here’s the calculator we’ll test throughout this tutorial:
# calculator.py
def add(a: float, b: float) -> float:
return a + b
def subtract(a: float, b: float) -> float:
return a - b
def multiply(a: float, b: float) -> float:
return a * b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / bunittest — Built-in Testing
# test_calculator_unittest.py
import unittest
from calculator import add, subtract, multiply, divide
class TestCalculator(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
self.assertEqual(add(-1, 1), 0)
def test_subtract(self):
self.assertEqual(subtract(10, 3), 7)
def test_divide(self):
self.assertEqual(divide(10, 2), 5)
with self.assertRaises(ValueError):
divide(10, 0)
def setUp(self):
print("Setup before each test")
def tearDown(self):
print("Cleanup after each test")
if __name__ == "__main__":
unittest.main()Run with: python -m unittest test_calculator_unittest.py
pytest — The Modern Way
Install pytest first: pip install pytest
# test_calculator.py
import pytest
from calculator import add, subtract, multiply, divide
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
def test_subtract():
assert subtract(10, 3) == 7
def test_divide():
assert divide(10, 2) == 5
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)Run with: pytest test_calculator.py -v
Output:
test_calculator.py::test_add PASSED
test_calculator.py::test_subtract PASSED
test_calculator.py::test_divide PASSED
test_calculator.py::test_divide_by_zero PASSEDFixtures — Reusable Test Data
Fixtures replace setUp/tearDown with a cleaner, more flexible approach:
import pytest
@pytest.fixture
def sample_data():
"""Provide test data for calculator tests."""
return {"a": 10, "b": 3, "expected": {"add": 13, "subtract": 7}}
def test_add_with_fixture(sample_data):
assert add(sample_data["a"], sample_data["b"]) == sample_data["expected"]["add"]
def test_subtract_with_fixture(sample_data):
assert subtract(sample_data["a"], sample_data["b"]) == sample_data["expected"]["subtract"]Parametrize — Test Multiple Cases
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(1.5, 2.5, 4.0),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
@pytest.mark.parametrize("a,b", [
(10, 0),
(0, 0),
(-5, 0),
])
def test_divide_by_zero_parametrized(a, b):
with pytest.raises(ValueError):
divide(a, b)Mocking with unittest.mock
Mocking replaces real objects with test doubles. Use it when your code calls external APIs, databases, or file systems:
# weather.py
import requests
def get_temperature(city: str) -> float:
response = requests.get(f"https://api.weather.com/v1/{city}")
data = response.json()
return data["temperature"]
# test_weather.py
from unittest.mock import patch
import pytest
from weather import get_temperature
@patch("weather.requests.get")
def test_get_temperature(mock_get):
mock_response = mock_get.return_value
mock_response.json.return_value = {"temperature": 22.5}
result = get_temperature("Mumbai")
assert result == 22.5
mock_get.assert_called_once_with("https://api.weather.com/v1/Mumbai")The @patch decorator replaces requests.get with a mock. The test never makes a real HTTP call.
Test Coverage
Install coverage: pip install coverage
coverage run -m pytest test_calculator.py
coverage report -mOutput:
Name Stmts Miss Cover Missing
---------------------------------------------
calculator.py 8 0 100%
test_calculator.py 14 0 100%
---------------------------------------------
TOTAL 22 0 100%Aim for 80%+ coverage. 100% doesn’t mean bug-free — it means every line ran. You still need meaningful assertions.
TDD Workflow (Test-Driven Development)
Red → Green → Refactor:
- Red: Write a failing test first
- Green: Write the minimum code to pass
- Refactor: Clean up while keeping tests green
# Step 1: Write the test first
def test_square():
assert square(5) == 25 # This will fail — square() doesn't exist yet
# Step 2: Write minimum code
def square(n: float) -> float:
return n * n
# Step 3: Refactor if needed
# (Already clean — move on)Common Mistakes
1. Testing Implementation, Not Behavior
Test what the function does, not how it’s written. Refactoring shouldn’t break tests.
2. Not Using pytest.raises for Expected Exceptions
# Bad — catches everything
try:
divide(1, 0)
except:
pass
# Good
with pytest.raises(ValueError):
divide(1, 0)3. Forgetting self.assertEqual vs assert in unittest
In unittest.TestCase, use self.assertEqual(a, b), not assert a == b — the former gives better failure messages.
4. Mocking Everything
Mock external dependencies (APIs, databases), not pure logic. Pure functions don’t need mocking.
5. Flaky Tests
Tests that sometimes pass, sometimes fail destroy trust. Avoid: network calls, random data, time-dependent logic without mocking.
Practice Questions
1. What’s the difference between unittest and pytest?unittest is built-in with Java-style class-based tests. pytest is third-party, uses plain functions, has fixtures/parametrize, and gives better output.
2. Why use @patch instead of calling the real API?
Tests should be fast, deterministic, and not depend on external services. Mocking isolates the code under test.
3. What does coverage report -m show?
Which lines are covered, which are missing, and the percentage.
4. What’s the first step in TDD?
Write a failing test (Red).
Challenge: Write a test for a function validate_email(email) that checks for @ and . using pytest.parametrize with valid and invalid cases.
Solution
import re
def validate_email(email: str) -> bool:
return bool(re.match(r"^[\w.+-]+@[\w-]+\.[\w.]+$", email))
@pytest.mark.parametrize("email,expected", [
("user@example.com", True),
("user.name@example.co.in", True),
("user@", False),
("@example.com", False),
("user@.com", False),
])
def test_validate_email(email, expected):
assert validate_email(email) == expectedMini Project: Test the Calculator with Coverage
import pytest
from calculator import add, subtract, multiply, divide
class TestCalculatorSuite:
"""Comprehensive calculator test suite."""
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(2.5, 3.5, 6.0),
])
def test_add(self, a, b, expected):
assert add(a, b) == expected
@pytest.mark.parametrize("a,b,expected", [
(10, 3, 7),
(5, 5, 0),
(0, 5, -5),
])
def test_subtract(self, a, b, expected):
assert subtract(a, b) == expected
@pytest.mark.parametrize("a,b,expected", [
(3, 4, 12),
(0, 5, 0),
(-2, 3, -6),
])
def test_multiply(self, a, b, expected):
assert multiply(a, b) == expected
@pytest.mark.parametrize("a,b,expected", [
(10, 2, 5),
(7, 2, 3.5),
(0, 5, 0),
])
def test_divide(self, a, b, expected):
assert divide(a, b) == expected
@pytest.mark.parametrize("a", [10, 0, -5])
def test_divide_by_zero(self, a):
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(a, 0)Run: pytest -v --cov=calculator --cov-report=term-missing
Expected output: All tests pass with 100% coverage.
What’s Next
Testing is essential before writing production code. Next, learn type hints to catch errors before runtime.
| Topic | Description | Link |
|---|---|---|
| Python Type Hints | Static typing with mypy | https://tutorials.dodatech.com/programming-languages/python/py-type-hints/ |
| Python Error Handling | Exceptions and logging | https://tutorials.dodatech.com/programming-languages/python/py-error-handling/ |
| Python Generators | Lazy iteration with yield | https://tutorials.dodatech.com/programming-languages/python/py-generators/ |
Practice tip: Apply TDD to your BankAccount class — write tests for deposit, withdraw, and balance before implementing the methods.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro