Skip to content
Python Testing — Complete Guide with pytest

Python Testing — Complete Guide with pytest

DodaTech Updated Jun 15, 2026 6 min read

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

  • unittest basics: TestCase, assertions, setUp/tearDown
  • pytest fundamentals: fixtures, parametrize, conftest.py
  • Mocking with unittest.mock to 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 / b

unittest — 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 PASSED

Fixtures — 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 -m

Output:

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:

  1. Red: Write a failing test first
  2. Green: Write the minimum code to pass
  3. 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) == expected

Mini 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.

TopicDescriptionLink
Python Type HintsStatic typing with mypyhttps://tutorials.dodatech.com/programming-languages/python/py-type-hints/
Python Error HandlingExceptions and logginghttps://tutorials.dodatech.com/programming-languages/python/py-error-handling/
Python GeneratorsLazy iteration with yieldhttps://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