Pytest Quick Start Guide

Let's get you up to speed on Pytest, mocking, fixtures, patching, and monkey patching so you can start writing tests quickly!

Pytest Fundamentals

Pytest is a powerful and easy-to-use testing framework for Python.

  • Installation: pip install pytest
  • Running Tests: Navigate to your project directory in the terminal and run pytest. Pytest automatically discovers test files (files starting with test_ or ending with _test.py) and test functions/methods (functions starting with test_).
  • Assertions: Pytest uses standard Python assert statements.
    Python
    def test_addition():
        assert 1 + 1 == 2
    
    def test_list_contains():
        my_list = [1, 2, 3]
        assert 2 in my_list
    
  • Test Classes: You can group related tests into classes.
    Python
    class TestCalculator:
        def test_add(self):
            assert 2 + 3 == 5
    
        def test_subtract(self):
            assert 5 - 2 == 3
    

Fixtures

Fixtures are functions that Pytest runs before (and sometimes after) your tests. They are used to set up a known working environment for your tests, such as creating a temporary database, setting up a test client, or providing test data.

  • Definition: Decorate a function with @pytest.fixture().
  • Usage: Pass the fixture function's name as an argument to your test function. Pytest will automatically discover and run the fixture.
  • Scope: Fixtures can have different scopes:
    • function (default): Run once per test function.
    • class: Run once per test class.
    • module: Run once per module.
    • session: Run once per Pytest session.

Example:

Python
import pytest

@pytest.fixture
def sample_data():
    """A fixture that provides a dictionary of sample data."""
    return {"name": "Alice", "age": 30}

def test_user_name(sample_data):
    assert sample_data["name"] == "Alice"

def test_user_age(sample_data):
    assert sample_data["age"] == 30

Mocking & Patching

Mocking is the act of replacing parts of your system under test with mock objects that simulate the behavior of real objects. This is crucial for isolating the code you're testing and preventing external dependencies (like databases, network calls, or complex calculations) from affecting your tests.

unittest.mock (built-in in Python 3.3+) is the standard library for mocking. Pytest integrates well with it.

  • unittest.mock.Mock: A flexible class that allows you to create mock objects. You can define their return values, side effects, and track how they are called.

    Python
    from unittest.mock import Mock
    
    def test_mock_return_value():
        my_mock = Mock(return_value="mocked_result")
        assert my_mock() == "mocked_result"
    
    def test_mock_calls():
        my_mock = Mock()
        my_mock.do_something("arg1", keyword_arg="value")
        my_mock.do_something.assert_called_with("arg1", keyword_arg="value")
        my_mock.do_something.assert_called_once()
    
  • unittest.mock.patch: The most common way to replace objects with mocks. It's a context manager or a decorator that temporarily replaces an object during the execution of your test.

    Key Principle for Patching: You need to patch where an object is looked up, not necessarily where it's defined.

    Example (assuming my_module.py has fetch_data_from_api()):

    Python
    # my_module.py
    import requests
    
    def fetch_data_from_api(url):
        response = requests.get(url)
        response.raise_for_status()
        return response.json()
    
    # test_my_module.py
    from unittest.mock import patch
    from my_module import fetch_data_from_api
    
    def test_fetch_data_success():
        with patch('my_module.requests.get') as mock_get:
            mock_response = Mock()
            mock_response.json.return_value = {"key": "value"}
            mock_get.return_value = mock_response
    
            result = fetch_data_from_api("http://example.com")
            assert result == {"key": "value"}
            mock_get.assert_called_once_with("http://example.com")
    
    # Using @patch as a decorator (more common for functions)
    @patch('my_module.requests.get')
    def test_fetch_data_success_decorator(mock_get): # mock_get is passed as an argument
        mock_response = Mock()
        mock_response.json.return_value = {"key": "value"}
        mock_get.return_value = mock_response
    
        result = fetch_data_from_api("http://example.com")
        assert result == {"key": "value"}
        mock_get.assert_called_once_with("http://example.com")
    
  • pytest-mock (Highly Recommended for Pytest Users): This plugin provides a Pytest fixture named mocker which simplifies patching. It's essentially a wrapper around unittest.mock but with Pytest's fixture system, meaning the patches are automatically cleaned up after each test.

    • Installation: pip install pytest-mock
    • Usage:
      Python
      # test_my_module.py
      from my_module import fetch_data_from_api
      
      def test_fetch_data_with_mocker(mocker): # 'mocker' is the fixture
          mock_get = mocker.patch('my_module.requests.get')
          mock_response = mocker.Mock() # Use mocker.Mock() or unittest.mock.Mock()
          mock_response.json.return_value = {"key": "value"}
          mock_get.return_value = mock_response
      
          result = fetch_data_from_api("http://example.com")
          assert result == {"key": "value"}
          mock_get.assert_called_once_with("http://example.com")
      
      mocker.patch is cleaner and integrates perfectly with Pytest's fixture system for automatic cleanup.

Monkey Patching

Monkey patching refers to modifying or extending a program at runtime by changing or adding to an existing class, module, or function. While powerful, it can also lead to less maintainable and harder-to-debug code if overused.

In the context of testing, patching (as discussed above) is a form of monkey patching, specifically for testing purposes. unittest.mock.patch and pytest-mock.mocker.patch handle the temporary modification and restoration for you.

  • When to use (carefully): When you need to temporarily change the behavior of a function or method that is difficult to mock directly (e.g., a function in a third-party library that you can't modify, or a global variable).

  • Pytest's monkeypatch fixture: Pytest provides a built-in monkeypatch fixture for this purpose, offering a safe and controlled way to perform monkey patching. It automatically undoes the changes after the test.

    • monkeypatch.setattr(obj, name, value): Sets an attribute on an object.
    • monkeypatch.delattr(obj, name): Deletes an attribute.
    • monkeypatch.setenv(name, value): Sets an environment variable.
    • monkeypatch.delenv(name): Deletes an environment variable.
    • monkeypatch.syspath_prepend(path): Prepends a path to sys.path.

    Example:

    Python
    # my_settings.py
    DEFAULT_TIMEOUT = 5
    
    # my_app.py
    import my_settings
    
    def get_timeout():
        return my_settings.DEFAULT_TIMEOUT
    
    # test_my_app.py
    import pytest
    from my_app import get_timeout
    
    def test_get_timeout_modified(monkeypatch):
        # Temporarily change DEFAULT_TIMEOUT for this test
        monkeypatch.setattr('my_settings.DEFAULT_TIMEOUT', 10)
        assert get_timeout() == 10
    
    def test_get_timeout_original():
        # After the previous test, the original value is restored
        assert get_timeout() == 5
    

Quick Guide to Writing Tests for Your Classes

  1. Identify Units: Determine the smallest independent units of code you want to test (usually public methods of your classes).
  2. Isolate Dependencies: For each method, identify its external dependencies (other classes, functions, network calls, database calls, file system operations).
  3. Use Fixtures for Setup: If multiple tests need the same setup (e.g., an instance of your class with specific initial state), create a Pytest fixture.
  4. Mock External Dependencies: Use mocker.patch (from pytest-mock) or unittest.mock.patch to replace external dependencies with mocks. Define the behavior of these mocks (return values, side effects).
  5. Assert Outcomes: Use assert statements to verify:
    • The return value of the method under test.
    • Side effects (e.g., if a mock method was called with specific arguments).
    • State changes in your object.
    • Exceptions raised.

Example Class and Test Structure:

Python
# my_service.py
import requests

class DataService:
    def __init__(self, base_url):
        self.base_url = base_url

    def fetch_user_data(self, user_id):
        try:
            response = requests.get(f"{self.base_url}/users/{user_id}")
            response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
            return response.json()
        except requests.exceptions.RequestException as e:
            print(f"Error fetching data: {e}")
            return None

    def save_data(self, data):
        # Imagine this saves to a database
        print(f"Saving data: {data}")
        return True

# test_my_service.py
import pytest
from my_service import DataService
from unittest.mock import Mock # Can also use mocker.Mock() with pytest-mock

@pytest.fixture
def data_service_instance():
    """Fixture to provide a DataService instance."""
    return DataService("http://api.example.com")

def test_fetch_user_data_success(data_service_instance, mocker):
    # 1. Mock the external dependency: requests.get
    mock_get = mocker.patch('requests.get')

    # 2. Configure the mock's behavior (return value of the response)
    mock_response = Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"id": 1, "name": "Test User"}
    mock_get.return_value = mock_response

    # 3. Call the method under test
    result = data_service_instance.fetch_user_data(1)

    # 4. Assert the outcome
    assert result == {"id": 1, "name": "Test User"}
    mock_get.assert_called_once_with("http://api.example.com/users/1")


def test_fetch_user_data_network_error(data_service_instance, mocker):
    # 1. Mock the external dependency to raise an exception
    mock_get = mocker.patch('requests.get')
    mock_get.side_effect = requests.exceptions.ConnectionError("Connection refused")

    # 2. Call the method under test
    result = data_service_instance.fetch_user_data(2)

    # 3. Assert the outcome (e.g., None is returned in this case)
    assert result is None
    mock_get.assert_called_once_with("http://api.example.com/users/2")

def test_save_data_prints_message(data_service_instance, capsys):
    # Using capsys fixture to capture stdout/stderr
    data_service_instance.save_data({"id": 3, "data": "abc"})
    captured = capsys.readouterr()
    assert "Saving data: {'id': 3, 'data': 'abc'}" in captured.out

This should give you a solid foundation to start writing effective tests for your Python classes using Pytest and mocking techniques! Good luck!

Comments

Popular posts from this blog

Self-contained Raspberry Pi surveillance System Without Continue Internet

COBOT with GenAI and Federated Learning

AI in Education: Embracing Change for Future-Ready Learning