Python

Unit Testing in Python: PyTest and Unittest for Writing Robust Code

1. Why Unit Testing Matters

Unit testing is the practice of verifying small, isolated pieces of code (functions, methods, classes) to ensure they behave as intended. In Python, this is especially important because the language is dynamically typed, so many errors only appear at runtime.

Automated tests serve as a safety net during refactoring and enable continuous integration with frequent deployments. Well-designed unit tests improve code quality, refactorability, development speed, and serve as executable documentation.

Key Benefits:

  • Catch regressions early before they reach production
  • Enable confident refactoring of existing code
  • Provide fast feedback during development
  • Document expected behavior and edge cases
  • Support continuous integration and deployment

2. Overview: unittest vs PyTest

Python provides two primary frameworks for unit testing. Both solve the same core problem—writing and running automated tests—but they make different trade-offs in simplicity, power, and ecosystem integration.

High-Level Comparison

Aspect unittest pytest
Standard Library Yes – no extra dependency No – install via pip
Style Class-based, xUnit style Function-based, simpler
Test Discovery python -m unittest discover pytest auto-discovery
Assertions Rich assertion methods Plain assert with introspection
Fixtures setUp/tearDown methods Powerful, reusable fixtures
Parameterization Third-party tools needed Built-in @pytest.mark.parametrize
Plugin Ecosystem Limited Extensive and mature
Best For Standard-library-only, legacy code Modern projects, large test suites

3. Getting Started with unittest

Basic Structure

unittest is part of the Python standard library. A test is written by subclassing unittest.TestCase and defining methods whose names start with test_.

Example 1: Basic unittest
import unittest

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

class TestMathOperations(unittest.TestCase):
    def test_add_positive_numbers(self):
        self.assertEqual(add(2, 3), 5)

    def test_add_negative_numbers(self):
        self.assertEqual(add(-1, -4), -5)

if __name__ == "__main__":
    unittest.main()

Running unittest Tests

# Run a specific test module
python -m unittest test_math_unittest

# Run with verbose output
python -m unittest -v test_math_unittest

# Discover and run all tests
python -m unittest discover

Setup and Teardown

Use setUp and tearDown hooks to prepare and clean up test state before and after each test, avoiding duplication.

Example 2: setUp and tearDown in unittest
import unittest

class UserService:
    def __init__(self):
        self.users = {}

    def add_user(self, username, email):
        if username in self.users:
            raise ValueError("User already exists")
        self.users[username] = email

    def get_email(self, username):
        return self.users.get(username)

class TestUserService(unittest.TestCase):
    def setUp(self):
        # Runs before each test
        self.service = UserService()
        self.service.add_user("alice", "alice@example.com")

    def tearDown(self):
        # Runs after each test
        del self.service

    def test_add_user_success(self):
        self.service.add_user("bob", "bob@example.com")
        self.assertEqual(self.service.get_email("bob"), "bob@example.com")

    def test_add_user_duplicate_raises(self):
        with self.assertRaises(ValueError):
            self.service.add_user("alice", "alice@other.com")

if __name__ == "__main__":
    unittest.main()

Common Assertion Methods

  • assertEqual(a, b) – Check if a equals b
  • assertTrue(x) – Verify x is true
  • assertFalse(x) – Verify x is false
  • assertIsNone(x) – Check if x is None
  • assertIn(a, b) – Check if a is in b
  • assertRaises(Exception, func, *args) – Verify exception is raised
See also  How to Calculate Age from Date of Birth in Python Using Datetime (Years, Months, Days)

4. Getting Started with PyTest

Installation

pip install pytest

Basic Test Structure

Pytest is known for its concise syntax. Tests are simple functions without needing to create classes.

Example 3: Basic pytest
def add(a, b):
    return a + b

def test_add_positive_numbers():
    assert add(2, 3) == 5

def test_add_negative_numbers():
    assert add(-1, -4) == -5

Run tests with:

pytest
# or with output
pytest -v

Fixtures: Setup and Cleanup

Pytest fixtures are reusable, composable setup/teardown mechanisms. They are more powerful than unittest’s approach and can be shared across test files via conftest.py.

Example 4: pytest Fixtures
import pytest

class UserService:
    def __init__(self):
        self.users = {}

    def add_user(self, username, email):
        if username in self.users:
            raise ValueError("User already exists")
        self.users[username] = email

    def get_email(self, username):
        return self.users.get(username)

@pytest.fixture
def user_service():
    service = UserService()
    service.add_user("alice", "alice@example.com")
    return service

def test_add_user_success(user_service):
    user_service.add_user("bob", "bob@example.com")
    assert user_service.get_email("bob") == "bob@example.com"

def test_add_user_duplicate_raises(user_service):
    with pytest.raises(ValueError):
        user_service.add_user("alice", "alice@other.com")

Parameterized Tests

Parameterized tests allow multiple input/output combinations with a single test function, greatly improving coverage and reducing duplication.

Example 5: pytest Parametrization
import pytest

def is_even(n: int) -> bool:
    return n % 2 == 0

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

Fixture Scopes

Fixtures can have different scopes: function (default), class, module, or session.

Example 6: Fixture Scopes
import pytest

@pytest.fixture(scope="session")
def expensive_resource():
    """Created once per test session"""
    print("Setting up expensive resource")
    resource = {"data": [1, 2, 3, 4, 5]}
    yield resource
    print("Cleaning up expensive resource")

@pytest.fixture(scope="function")
def simple_data():
    """Created for each test"""
    return {"value": 42}

def test_with_expensive(expensive_resource):
    assert len(expensive_resource["data"]) == 5

def test_with_simple(simple_data):
    assert simple_data["value"] == 42

5. unittest vs PyTest: Choosing the Right Tool

When to Use unittest

  • Environment forbids third-party dependencies
  • Large legacy test suite already using unittest
  • Team familiar with xUnit/JUnit patterns
  • Need standard library only solution

When to Use pytest

  • Greenfield projects or modernizing existing code
  • Need powerful fixtures and parametrization
  • Want less boilerplate and cleaner syntax
  • Require extensive plugin ecosystem (coverage, xdist, hypothesis)
  • Large test suites where maintainability is priority
Recommendation: For modern Python projects, pytest is generally preferred due to superior ergonomics and extensive ecosystem. However, unittest remains solid for standard-library-only environments.

See also  How to pass parameters in Flask

6. Writing Robust Tests: Best Practices

1. Follow the AAA Pattern: Arrange – Act – Assert

Structure each test into three clear, distinct phases:

  • Arrange: Set up inputs and state
  • Act: Call the function or method under test
  • Assert: Verify the result
Example 7: AAA Pattern
def test_transfer_funds_success():
    # Arrange
    source = Account(balance=100)
    target = Account(balance=50)

    # Act
    transfer_funds(source, target, 30)

    # Assert
    assert source.balance == 70
    assert target.balance == 80

2. One Focused Behavior Per Test

Each test should validate a single behavior or scenario. This improves clarity and produces precise failure signals.

Bad: Testing multiple behaviors in one test

def test_user_creation_and_welcome_email():
    user = create_user("alice@example.com")
    assert user.email == "alice@example.com"
    send_welcome_email(user)
    assert email_service.last_sent_to == "alice@example.com"

Better: Separate tests for each behavior

def test_user_creation_stores_email():
    user = create_user("alice@example.com")
    assert user.email == "alice@example.com"

def test_welcome_email_sends_to_user_email():
    user = User("alice@example.com")
    send_welcome_email(user)
    assert email_service.last_sent_to == "alice@example.com"

3. Keep Tests Fast and Deterministic

  • Avoid real network calls, file systems, and databases—use mocks instead
  • Make tests deterministic: use fixed seeds, avoid dependencies on current time
  • Isolate side effects with fixtures

4. Use Fixtures to Remove Duplication

Share setup logic across tests using fixtures (pytest) or setUp/tearDown (unittest). Keep fixtures small and focused.

5. Mock External Dependencies Carefully

Mocking isolates the unit under test from external systems. This is essential for fast, reliable unit tests.

Example 8: Mocking with unittest.mock
from unittest.mock import MagicMock

def send_notification(client, user_id, message):
    user = client.get_user(user_id)
    client.send_email(user.email, message)

def test_send_notification_uses_user_email():
    mock_client = MagicMock()
    mock_client.get_user.return_value.email = "alice@example.com"

    send_notification(mock_client, 123, "Hello")

    mock_client.send_email.assert_called_once_with("alice@example.com", "Hello")
Caution: Over-mocking can hide integration problems. Combine unit tests with integration and end-to-end tests for comprehensive coverage.

6. Organize Tests Like Production Code

Mirror application modules in the tests directory for clarity and scalability.

project/
    package/
        __init__.py
        models.py
        services.py
    tests/
        __init__.py
        test_models.py
        test_services.py
        api/
            test_api_endpoints.py
        integration/
            test_database_integration.py

7. Track Coverage, But Don’t Chase 100%

Use coverage tools to identify untested paths, but focus on critical paths and edge cases, not just metrics.

pytest --cov=package --cov-report=term-missing

8. Write Clear, Descriptive Test Names

Test names should describe what is being tested and expected outcome: test_<unit>_<behavior>_<expected_result>

7. Practical Example: Same Logic in unittest and PyTest

Consider a simple discount calculator. Here’s how to test it with both frameworks:

The Code Under Test

def apply_discount(price, discount_percent):
    if not (0 <= discount_percent <= 100):
        raise ValueError("Invalid discount")
    return price * (1 - discount_percent / 100)

unittest Version

Example 9: unittest Tests
import unittest
from pricing import apply_discount

class TestApplyDiscount(unittest.TestCase):
    def test_full_price_when_zero_discount(self):
        self.assertEqual(apply_discount(100, 0), 100)

    def test_half_price_when_50_percent_discount(self):
        self.assertEqual(apply_discount(100, 50), 50)

    def test_raises_for_negative_discount(self):
        with self.assertRaises(ValueError):
            apply_discount(100, -1)

    def test_raises_for_discount_over_100(self):
        with self.assertRaises(ValueError):
            apply_discount(100, 150)

if __name__ == "__main__":
    unittest.main()

pytest Version with Parametrization

Example 10: pytest Tests with Parametrization
import pytest
from pricing import apply_discount

@pytest.mark.parametrize(
    "price, discount, expected",
    [
        (100, 0, 100),
        (100, 50, 50),
        (200, 25, 150),
    ],
)
def test_apply_discount_valid(price, discount, expected):
    assert apply_discount(price, discount) == expected

@pytest.mark.parametrize("discount", [-1, 101])
def test_apply_discount_invalid(discount):
    with pytest.raises(ValueError):
        apply_discount(100, discount)

The pytest version is more compact and scales better as test cases grow—a key reason many teams favor pytest for high-coverage suites.

See also  How to calculate bonds in Python

8. Practical Recommendations

For Most Modern Python Projects

1. Adopt pytest as the primary framework

  • Leverage fixtures, parametrization, and plugins
  • Use plugins: pytest-cov, pytest-xdist, pytest-mock, hypothesis
  • Organize with conftest.py for shared fixtures

2. Use unittest where constraints require it

  • Standard-library-only setups
  • Existing large unittest suites
  • Can potentially integrate pytest incrementally

Enforce Testing Discipline

  • Readable: Tests should clearly document intent
  • Fast: Run frequently during development
  • Independent: Tests don’t depend on each other
  • Deterministic: Same input always produces same output
  • Focused: Each test validates one behavior

Combine with Higher-Level Tests

Unit Tests

Logic correctness of individual functions and classes. Fast, focused, isolated.

Integration Tests

Multiple components working together. Test external systems, databases, APIs.

End-to-End Tests

User flows through entire application. Slower but verify real-world behavior.

CI/CD Integration

Run tests on every push/PR. Treat failing tests as production incidents.

Pro Tip: Organize tests in a pyramid: Many fast unit tests, fewer integration tests, minimal slow end-to-end tests. This provides comprehensive coverage with reasonable execution time.

Recommended Test Coverage by Type

  • Happy Path: Normal, expected usage—the majority of tests
  • Edge Cases: Boundary conditions, empty inputs, maximum values
  • Error Handling: Invalid inputs, exceptions, error states
  • Performance: Critical paths should not regress

Useful Testing Tools and Libraries

  • pytest-cov: Coverage measurement plugin for pytest
  • pytest-xdist: Parallel test execution
  • pytest-mock: Simplified mocking with pytest
  • hypothesis: Property-based testing for finding edge cases
  • coverage.py: Measure code coverage
  • tox: Test automation across multiple Python versions