Skip to content

The Nullable Pattern

This document describes the Nullable pattern used throughout the Payment Gateway for testing without mocks.

Overview

The Nullable pattern is a testing approach that eliminates the need for mocks by using test doubles that behave like production code. Instead of mocking external dependencies, we create "null" versions that work identically but don't make external API calls.

The philosophy behind this pattern is expressed in James Shore's Testing Without Mocks ⧉.

Core principle: Test with real code paths and a real database, replacing only external API clients with embedded stubs.

Rationale

Why No Mocking?

The Payment Gateway follows a strict no-mocking philosophy for several reasons:

1. Real Database Testing

  • Tests use an actual PostgreSQL database
  • Catches database-specific issues (constraints, indexes, transactions)
  • Validates SQL queries and ORM behavior
  • Detects N+1 queries and performance issues

2. Complete Code Path Coverage

  • Tests execute the same business logic as production
  • No artificial test-only branches
  • Higher confidence in test results

3. Refactoring Safety

  • Tests don't break when implementation details change
  • Only public API contracts matter
  • Easier to evolve the codebase

4. Simpler Tests

  • No mock setup and verification boilerplate
  • No mock configuration spreading across tests
  • Tests focus on behavior, not implementation

Comparison

# ❌ Traditional Mock Approach
def test_create_card_with_mock(mocker):
    # Complex mock setup requires inner knowledge of dependencies and protocols
    mock_client = mocker.Mock()
    mock_client.create_payment_instrument.return_value = {"id": "CARD_ID"}
    card_actions = CardLifecycleActions(mock_client)

    # Act will call the mocked code and not production code
    card_id = card_actions.create_card(...)

    # Brittle verification
    mock_client.create_payment_instrument.assert_called_once_with(...)

# ✅ Nullable Pattern Approach
def test_create_card_with_nullable():
    # Simple test double
    card_actions = CardLifecycleActions.create_null()

    # Act will cal production code, only mocking external effects
    card_id = card_actions.create_card(...)

    # Test real business logic side effects
    card = CardModelBroker.get_card(current_session, id=card_id)
    assert card.status == "inactive"

When To Use

DO use Nullable pattern for:

  • External API clients (Adyen, JPMorgan, AWS services)
  • Action classes that call external APIs
  • Logic classes (protected layer) that orchestrate actions
  • Any class with dependencies on external systems

DO NOT use Nullable pattern for:

  • Queries (read-only, no external calls)
  • Model Brokers (database-only, no external dependencies)
  • Pure functions (no dependencies)
  • Dataclasses (no behavior)

Reference

The Nullable pattern implements several related patterns from James Shore's Testing Without Mocks ⧉:

Core Principles

The Nullable pattern provides a strong contract that distinguishes it from traditional mocking approaches:

1. Shared Production Code

Nullables use real production code, not test-specific implementations.

  • Both create() and create_null() use the same business logic
  • Tests execute through the same code paths as production
  • No artificial test-only branches or conditional logic
  • Higher confidence that tests validate actual behavior
  • All tests behave consistently
class MyActions:
    # Same business logic for both production and tests
    def perform_action(self, ...) -> EntityId:
        # This code runs identically in production and tests
        entity = self._build_entity(...)
        self.client.make_api_call(...)  # Only this varies!
        return self._persist_entity(entity)

2. Zero External Side Effects

Nullables guarantee no external API calls or side effects.

  • Embedded stubs never make network requests
  • No data sent to external services
  • No emails, SMS, or push notifications
  • Database changes are local and reversible

This guarantee enables two powerful use cases:

  1. Safe Testing
# Tests can run in parallel without external service quotas or rate limits
def test_create_100_entities():
    actions = MyActions.create_null()
    for _ in range(100):
        actions.perform_action(...)  # No external API calls!
  1. Production Dry Run
# Run real production code safely with dry_run flag
def dry_run_migration():
    actions = MyActions.create_null()  # Zero side effects

    for entity_data in legacy_entities:
        # Execute real production logic
        new_entity_id = actions.perform_action(...)
        # Verify migration would succeed
        assert MyModelBroker.get_entity(...).status == "active"

    current_session.rollback()  # Undo database changes

The strong contract means:

  • No difference between production and test code → tests are reliable
  • Zero external side effects → safe for dry run and parallel testing

3. Transitive Nullability

Nullability propagates through the dependency chain.

When a null factory creates an instance, all its dependencies are also created via their null factories:

class MyActions:
    @classmethod
    def create(cls) -> "MyActions":
        """Production factory - loads config and creates real API client"""
        from shared.helpers.config import current_config

        # Creates real API client (loads credentials, makes actual HTTP calls)
        client = ExternalApiClient.create()

        # Load configuration from application config
        config = {
            "timeout": current_config["API_TIMEOUT"],
            "retry_count": current_config["API_RETRY_COUNT"],
        }

        return cls(
            client=client,
            config=config,
        )

    @classmethod
    def create_null(cls, config: dict | None = None) -> "MyActions":
        """Null factory - uses dummy config and null API client"""

        # Creates null API client (no external calls, transitive!)
        client = ExternalApiClient.create_null()

        # Dummy config still ensures production code works
        config = config or {"timeout": 30, "retry_count": 3}

        return cls(
            client=client,
            config=config,
        )

class ExternalApiClient:
    @classmethod
    def create(cls) -> "ExternalApiClient":
        """Production factory - loads credentials and creates real HTTP client"""
        from shared.helpers.env import json_secret_from_config

        # Load credentials from secret manager
        credentials = json_secret_from_config("EXTERNAL_API_CREDENTIALS")
        if credentials is None:
            raise MissingCredentialsException()

        return cls(api_key=credentials["api_key"])

    @classmethod
    def create_null(cls) -> "ExternalApiClient":
        """Null factory - embedded stub"""
        return cls.StubbedApi()  # No real HTTP, embedded stub

Benefits of transitivity:

  1. Simple usage - One call creates entire null dependency graph
# Just one line - all dependencies are null
actions = MyActions.create_null()
  1. No internal knowledge required - Test code doesn't need to know component structure
# Don't need to know MyActions uses ExternalApiClient, config dict, etc.
# Don't need to manually mock each dependency
actions = MyActions.create_null()
  1. Stable over time - Adding new dependencies doesn't break tests
    # If MyActions adds a new dependency:
    # - create() instantiates it via its create()
    # - create_null() instantiates it via its create_null()
    # - Existing test code unchanged!
    actions = MyActions.create_null()
    

Comparison with mocking:

# ❌ Mock approach - brittle, requires internal knowledge
def test_with_mocks(mocker):
    mock_client = mocker.Mock()
    mock_http = mocker.Mock()
    test_config = {"api_key": "test"}
    # If we add or update a dependency, ALL tests break!
    actions = MyActions(mock_client, test_config, ...)

# ✅ Nullable approach - simple, stable, DRY
def test_with_nullables():
    actions = MyActions.create_null()  # Always works

Implementation

Class Structure

Every class with external dependencies implements two factory methods:

class MyActions:
    """
    This class performs actions using an external API.

    Implements the following Nullable patterns:
    - Nullables: https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#nullables
    - Parameterless instantiation: https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#instantiation
    """

    @classmethod
    def create(cls) -> "MyActions":
        """Normal factory - uses real external API client"""
        from shared.helpers.config import current_config

        client = ExternalApiClient.create()
        config = {
            "timeout": current_config["API_TIMEOUT"],
            "retry_count": current_config["API_RETRY_COUNT"],
        }

        return cls(
            client=client,
            config=config,
        )

    @classmethod
    def create_null(cls, config: dict | None = None) -> "MyActions":
        """Null factory - uses test double"""
        client = ExternalApiClient.create_null()
        config = config or {"timeout": 30, "retry_count": 3}

        return cls(
            client=client,
            config=config,
        )

    def __init__(self, client: ExternalApiClient, config: dict) -> None:
        self.client = client
        self.config = config

Factory + Constructor Pattern

The Nullable pattern uses a strict factory method + constructor separation:

Factory Methods (Public API)

Parameterless Instantiation: Factory methods have no required parameters - only optional ones for customization.

@classmethod
def create(cls) -> "MyActions":
    """Production factory - no required parameters"""
    # Factory is responsible for:
    # 1. Loading configuration from current_config
    # 2. Creating dependencies (ExternalApiClient.create())
    # 3. Calling constructor with all dependencies
    ...

@classmethod
def create_null(
    cls,
    config: dict | None = None,  # Optional customization
    track_requests: list | None = None,  # Optional testing helper
) -> "MyActions":
    """Null factory - no required parameters"""
    # Factory is responsible for:
    # 1. Providing sensible defaults for tests and dry runs
    # 2. Creating null dependencies (ExternalApiClient.create_null())
    # 3. Calling constructor with all dependencies
    ...

Key responsibilities of factory methods:

  • Dependency injection: Create and inject all dependencies
  • Configuration loading: Load from current_config, secrets, etc.
  • No required parameters: Callers just do MyActions.create() or MyActions.create_null()
  • Optional parameters only: For test customization or special cases

Constructor (Private API)

All dependencies are constructor parameters - never created inside constructor.

def __init__(self, client: ExternalApiClient, config: dict) -> None:
    """Constructor receives all dependencies - never creates them"""
    self.client = client  # Injected by factory
    self.config = config   # Injected by factory
    # Constructor only stores dependencies, never creates them

Key characteristics:

  • All parameters required: Constructor needs all dependencies to work
  • No creation logic: Never calls .create() or loads config inside __init__
  • Not called directly: Only factories call the constructor

Usage Rules

✅ DO: Use factory methods

# Production
actions = MyActions.create()  # Parameterless!

# Testing
actions = MyActions.create_null()  # Parameterless!
actions = MyActions.create_null(config={"timeout": 10})  # Optional customization

❌ DON'T: Call constructor directly

# WRONG - Don't instantiate directly
client = ExternalApiClient.create()
config = {"timeout": 30}
actions = MyActions(client, config)  # Never do this!

Why this matters:

  • Parameterless instantiation makes tests simple: create_null() provides everything
  • Factories encapsulate complexity - callers don't need to know about dependencies
  • Constructor enforces complete dependencies - can't create half-initialized objects

Embedded Stubs

For API integration testing, the pattern provides request tracking to verify that our business logic calls external APIs correctly without actually making the calls.

External API clients implement their own create_null() with embedded stubs ⧉:

class ExternalApiClient:
    """
    Thin wrapper around external API.

    Implements the following Nullable patterns:
    - Nullables: https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#nullables
    - Parameterless instantiation: https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#instantiation
    - Thin wrapper: https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#thin-wrapper
    - Embedded stub: https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#embedded-stub
    """

    @classmethod
    def create(cls) -> "ExternalApiClient":
        """Production factory - loads credentials and creates real HTTP client"""
        from shared.helpers.env import json_secret_from_config

        # Load credentials from secret manager
        credentials = json_secret_from_config("EXTERNAL_API_CREDENTIALS")
        if credentials is None:
            raise MissingCredentialsException()

        return cls(api_key=credentials["api_key"])

    @classmethod
    def create_null(
        cls,
        track_requests: list[tuple[str, dict, dict]] | None = None,
    ) -> "ExternalApiClient":
        """Null factory - embedded stub"""
        return cls.StubbedApi(track_requests)

    def __init__(self, api_key: str) -> None:
        self.api_key = api_key

    def make_api_call(self, request: dict, **kwargs) -> dict:
        """Delegates to underlying API (real or stub)"""
        # Real implementation would make HTTP calls
        pass

    class StubbedApi:
        """Embedded stub that tracks calls and returns canned responses"""

        def __init__(
            self,
            track_requests: list[tuple[str, dict, dict]] | None = None,
        ):
            self.track_requests = track_requests if track_requests is not None else []

        def make_api_call(self, request: dict, **kwargs) -> dict:
            # Track the call
            self.track_requests.append(("make_api_call", request, kwargs))

            # Return sensible default response
            return {
                "id": f"STUB_ID_{len(self.track_requests)}",
                "status": "success",
            }

In most cases, the embedded stub can simply define dummy implementations for the subset of API client methods that the code actually uses. These methods will initially do nothing or raise a NotImplementedError, but we can provide more elaborate implementations to support specific test scenarios at a later time.

Usage in Tests

Basic Usage

@pytest.fixture
def my_actions() -> MyActions:
    return MyActions.create_null()

@pytest.mark.usefixtures("db")
def test_perform_action(
    my_actions: MyActions,
    entity_id: EntityId,
):
    # Arrange
    # (no mock setup needed!)

    # Act
    result_id = my_actions.perform_action(
        current_session,
        entity_id=entity_id,
        param="value",
    )

    # Assert - test real database state
    entity = MyModelBroker.get_entity(current_session, id=result_id)
    assert entity is not None
    assert entity.status == "active"
    assert entity.entity_id == entity_id

Tracking External API Calls

Note

This convention is not part of James Shore's original pattern language, which proposes the Output Tracking pattern instead ⧉. However, using a track_requests array is simpler to put in place as it doesn't require an event mechanism.

The track_requests parameter provides test observability similar to mock assertions, but without the downsides of mocking:

  • Verifies integration: Ensures your business logic calls external APIs with correct parameters
  • No coupling: Tests don't break when internal implementation changes
  • Real behavior: Production code runs the same way in tests

This is particularly useful for API integration tests where you need to verify the API call parameters without actually making HTTP requests.

@pytest.fixture(name="track_api_requests")
def _create_track_api_requests():
    return []

@pytest.fixture
def my_actions(track_api_requests) -> MyActions:
    return MyActions.create_null(
        track_requests=track_api_requests
    )

@pytest.mark.usefixtures("db")
def test_perform_action_should_call_external_api(
    my_actions: MyActions,
    entity_id: EntityId,
    track_api_requests,
):
    # Act
    my_actions.perform_action(
        current_session,
        entity_id=entity_id,
        param="value",
    )

    # Assert - verify API was called correctly
    assert len(track_api_requests) == 1
    assert track_api_requests[0][0] == "make_api_call"
    assert track_api_requests[0][1]["entity_id"] == entity_id
    assert track_api_requests[0][1]["param"] == "value"

The tracker automatically accumulates all calls during a test, making it easy to verify sequences of external interactions:

@pytest.mark.usefixtures("db")
def test_should_create_multiple_entities(
    my_actions: MyActions,
    track_api_requests,
):
    # Arrange
    entity_id_1 = EntityIdFactory.create()
    entity_id_2 = EntityIdFactory.create()
    current_session.commit()

    # Act - multiple operations
    my_actions.perform_action(
        current_session,
        entity_id=entity_id_1,
        param="first",
    )
    my_actions.perform_action(
        current_session,
        entity_id=entity_id_2,
        param="second",
    )

    # Assert - verify all API calls
    assert len(track_api_requests) == 2
    assert track_api_requests[0][1]["param"] == "first"
    assert track_api_requests[1][1]["param"] == "second"

Best Practices

✅ DO

Always document which Nullable patterns you're using:

class MyActions:
    """
    Brief description.

    Implements the following Nullable patterns:
    - Nullables: https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#nullables
    - Parameterless instantiation: https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#instantiation

    Tags:
        - @adyen: api_name
    """

Provide sensible defaults in create_null():

@classmethod
def create_null(
    cls,
    configuration: Config | None = None,
    track_requests: list[tuple[str, dict, dict]] | None = None,
) -> "MyActions":
    if configuration is None:
        configuration = Config(
            # Dummy values that work for tests
            country_code=CountryCode.FRANCE,
            brand="dummy_brand",
        )

    return cls(
        client=Client.create_null(track_requests=track_requests),
        configuration=configuration,
    )

❌ DON'T

Don't mix real and null dependencies:

# ❌ BAD - mixing real service stub API
def create_null(cls) -> "MyActions":
    return cls(
        api_client=ApiClient.create_null(),  # Stub!
        other_service=OtherService.create(), # Real!
    )

# ✅ GOOD - all dependencies are null
def create_null(cls) -> "MyActions":
    return cls(
        api_client=ApiClient.create_null(),
        other_service=OtherService.create_null(),
    )

Don't use mocks alongside Nullables:

# ❌ BAD - defeats the purpose
def test_create_card(mocker):
    mock_broker = mocker.Mock()
    card_actions = CardLifecycleActions.create_null()
    ...

# ✅ GOOD - no mocks, use real database
def test_create_card():
    card_actions = CardLifecycleActions.create_null()
    card_id = card_actions.create_card(...)

    # Test real database state
    card = CardModelBroker.get_card(current_session, id=card_id)
    assert card.status == "inactive"

Don't return different types from factories:

# ❌ BAD - type inconsistency
@classmethod
def create(cls) -> "MyActions":
    return cls(RealClient())

@classmethod
def create_null(cls) -> dict:  # Wrong return type!
    return {"client": StubClient()}

# ✅ GOOD - same type from both factories
@classmethod
def create(cls) -> "MyActions":
    return cls(RealClient())

@classmethod
def create_null(cls) -> "MyActions":  # Same type!
    return cls(StubClient())

Architectural Patterns

Layered Nullables

The pattern composes well across architectural layers. Each layer implements its own factory methods and delegates to the layer below:

Public API Layer

class PublicLogic:
    """
    Public interface exposed to controllers.

    Implements Nullable patterns:
    - Nullables
    - Parameterless instantiation
    """

    @classmethod
    def create(cls) -> "PublicLogic":
        """Normal factory"""
        return cls(BusinessActions.create())

    @classmethod
    def create_null(
        cls,
        track_api_requests: list[tuple[str, dict, dict]] | None = None,
    ) -> "PublicLogic":
        """Null factory - delegates to business layer"""
        return cls(
            BusinessActions.create_null(track_requests=track_api_requests)
        )

    def __init__(self, actions: BusinessActions) -> None:
        self.actions = actions

    def perform_business_operation(self, session: Session, ...) -> ResultId:
        """Public API method delegates to business logic"""
        return self.actions.perform_operation(session, ...)

Internal Business Logic Layer

class BusinessActions:
    """Business logic layer - orchestrates operations"""

    @classmethod
    def create(cls) -> "BusinessActions":
        return cls(ExternalApiClient.create())

    @classmethod
    def create_null(
        cls,
        track_requests: list[tuple[str, dict, dict]] | None = None,
    ) -> "BusinessActions":
        """Null factory - delegates to external client"""
        return cls(ExternalApiClient.create_null(track_requests=track_requests))

    def __init__(self, client: ExternalApiClient) -> None:
        self.client = client

    def perform_operation(self, session: Session, ...) -> ResultId:
        """Business logic delegates to external API"""
        response = self.client.make_api_call(...)
        # Business logic continues...
        return result_id

External Client Layer

class ExternalApiClient:
    """External API wrapper with embedded stub"""

    @classmethod
    def create(cls) -> "ExternalApiClient":
        """Normal factory - real HTTP client"""
        from shared.helpers.env import json_secret_from_config

        credentials = json_secret_from_config("API_CREDENTIALS")
        return cls(api_key=credentials["api_key"])

    @classmethod
    def create_null(
        cls,
        track_requests: list[tuple[str, dict, dict]] | None = None,
    ) -> "ExternalApiClient":
        """Null factory - embedded stub"""
        return cls.StubbedApi(track_requests)

    class StubbedApi:
        """Embedded stub tracks calls and returns canned responses"""

        def __init__(
            self,
            track_requests: list[tuple[str, dict, dict]] | None = None,
        ):
            self.track_requests = track_requests if track_requests is not None else []

        def make_api_call(self, request: dict, **kwargs) -> dict:
            self.track_requests.append(("make_api_call", request, kwargs))
            return {"id": f"STUB_ID_{len(self.track_requests)}", "status": "success"}

Testing any layer:

Each layer can be tested independently because nullability is transitive:

# Test protected layer (includes business logic + client)
public_logic = PublicLogic.create_null(track_api_requests)

# Test business logic layer directly (includes client)
actions = BusinessActions.create_null(track_api_requests)

# Test client layer in isolation
client = ExternalApiClient.create_null(track_api_requests)

Composite Nullables

Use case: High-level classes that orchestrate multiple action classes, each with their own external dependencies.

Benefits:

  • Transitive nullability: Top-level create_null() automatically creates null instances for all dependencies
  • Simplified testing: Test complex workflows without managing each dependency individually
  • Single tracker: Share request tracking across all composed components
class PublicLogic:
    """High-level orchestrator that composes multiple action classes"""

    @classmethod
    def create(cls) -> "PublicLogic":
        return cls(
            entity_actions=EntityActions.create(),
            related_actions=RelatedActions.create(),
            notification_actions=NotificationActions.create(),
        )

    @classmethod
    def create_null(
        cls,
        track_api_requests: list[tuple[str, dict, dict]] | None = None,
    ) -> "PublicLogic":
        """Null factory composes null versions of all dependencies"""
        return cls(
            # All dependencies become null automatically (transitive!)
            entity_actions=EntityActions.create_null(
                track_requests=track_api_requests
            ),
            related_actions=RelatedActions.create_null(
                track_requests=track_api_requests
            ),
            notification_actions=NotificationActions.create_null(
                track_requests=track_api_requests
            ),
        )

    def __init__(
        self,
        entity_actions: EntityActions,
        related_actions: RelatedActions,
        notification_actions: NotificationActions,
    ) -> None:
        self.entity_actions = entity_actions
        self.related_actions = related_actions
        self.notification_actions = notification_actions

    def perform_complex_workflow(self, session: Session, ...) -> ResultId:
        """Orchestrates multiple actions"""
        entity_id = self.entity_actions.create_entity(session, ...)
        self.related_actions.link_to_entity(session, entity_id=entity_id)
        self.notification_actions.send_notification(session, entity_id=entity_id)
        return entity_id

# Testing is simple - one fixture, all dependencies become null
@pytest.fixture
def public_logic(track_api_requests) -> PublicLogic:
    return PublicLogic.create_null(track_api_requests=track_api_requests)

@pytest.mark.usefixtures("db")
def test_should_perform_complex_workflow(
    public_logic: PublicLogic,
    track_api_requests,
):
    # Act - test entire workflow
    result_id = public_logic.perform_complex_workflow(current_session, ...)

    # Assert - verify all external calls across all composed actions
    assert len(track_api_requests) == 3  # All 3 actions called API
    assert track_api_requests[0][0] == "create_entity_api_call"
    assert track_api_requests[1][0] == "link_entity_api_call"
    assert track_api_requests[2][0] == "send_notification_api_call"

Testing Patterns

Nullables Pytest Fixtures

Fixtures centralize the creation of null objects, making tests more readable and maintainable:

@pytest.fixture(name="track_api_requests")
def _create_track_api_requests():
    """Shared tracker for all API calls"""
    return []

@pytest.fixture
def my_actions(track_api_requests) -> MyActions:
    """Provides a null instance with tracking"""
    return MyActions.create_null(track_requests=track_api_requests)

# Simple tests stay clean and focused
@pytest.mark.usefixtures("db")
def test_perform_action(my_actions: MyActions):
    # No setup needed - fixture handles it!
    result_id = my_actions.perform_action(...)
    # Test assertions
    ...

# Only use the track API fixture when you need it
@pytest.mark.usefixtures("db")
def test_perform_action(my_actions: MyActions, track_api_requests):
    # Call action that does API requests
    result_id = my_actions.perform_action(...)
    # Test expected requests
    assert len(track_api_requests) == 1
    ...

Benefits of fixture-based setup:

  • DRY: Call create_null() once in fixture, not in every test
  • Readability: Tests focus on arrange-act-assert, not setup
  • Flexibility: Easy to mix nullable and non-nullable components
  • Consistency: All tests use the same initialization

Shared API Request Tracker Fixture

Use case: When multiple action classes call the same external API, share a single tracker across all fixtures.

Benefits:

  • Verify call sequences: Ensure operations happen in the correct order
  • Count total interactions: Track all API calls across multiple components
  • Integration testing: Test how components interact with shared external services
@pytest.fixture(name="track_api_requests")
def _create_track_api_requests():
    """Single tracker shared by all nullable fixtures"""
    return []

@pytest.fixture
def entity_actions(track_api_requests) -> EntityActions:
    """First action class - shares tracker"""
    return EntityActions.create_null(track_requests=track_api_requests)

@pytest.fixture
def related_actions(track_api_requests) -> RelatedActions:
    """Second action class - shares same tracker"""
    return RelatedActions.create_null(track_requests=track_api_requests)

@pytest.mark.usefixtures("db")
def test_should_call_api_in_sequence(
    entity_actions: EntityActions,
    related_actions: RelatedActions,
    track_api_requests,
):
    # Arrange
    entity_id = EntityIdFactory.create()
    current_session.commit()

    # Act - operations across multiple action classes
    entity_actions.create_entity(current_session, entity_id=entity_id)
    related_actions.link_to_entity(current_session, entity_id=entity_id)

    # Assert - verify call sequence across all actions
    assert len(track_api_requests) == 2
    assert track_api_requests[0][0] == "create_entity_api_call"
    assert track_api_requests[1][0] == "link_entity_api_call"

Configuration Override

Use case: Test behavior with specific configurations without creating a separate fixture for each variation.

Benefits:

  • Test edge cases: Verify behavior with unusual or boundary configurations
  • Test variants: Check country-specific, brand-specific, or environment-specific logic
  • Inline customization: Override defaults directly in test without fixture modifications
@pytest.mark.usefixtures("db")
def test_should_use_default_timeout():
    # Use default configuration
    my_actions = MyActions.create_null()

    # Test behavior with default configuration
    result = my_actions.perform_action(...)
    assert result is not None

@pytest.mark.usefixtures("db")
def test_should_use_specific_timeout():
    # Override default configuration inline
    my_actions = MyActions.create_null(
        config={
            "timeout": 1,  # Very short timeout for testing
            "retry_count": 0,  # No retries
        }
    )

    # Test behavior with custom configuration
    result = my_actions.perform_action(...)
    assert result is not None

@pytest.mark.usefixtures("db")
def test_should_handle_country_specific_logic():
    # Test France-specific behavior
    actions_fr = MyActions.create_null(
        config={"country_code": "FR", "language": "fr"}
    )

    # Test Spain-specific behavior
    actions_es = MyActions.create_null(
        config={"country_code": "ES", "language": "es"}
    )

    result_fr = actions_fr.perform_action(...)
    result_es = actions_es.perform_action(...)

    # Verify country-specific differences
    assert result_fr.country_code == "FR"
    assert result_es.country_code == "ES"

Summary

Key Takeaways:

  1. No mocking: Use test doubles that behave like production code
  2. Real database: Test against actual PostgreSQL for confidence
  3. Two factories: create() for production, create_null() for tests
  4. Track requests: Use track_requests parameter to verify API calls
  5. Embedded stubs: Test doubles live alongside production code
  6. Layered composition: Nullables compose naturally across layers
  7. Simple tests: Focus on behavior, not mock configuration

When to use:

  • Classes that call external APIs
  • Action and Logic classes with external dependencies
  • Any code that needs testing without real external calls

Benefits:

  • Higher test confidence (real code paths)
  • Easier refactoring (tests don't break on internal changes)
  • Simpler test code (no mock boilerplate)
  • Better error detection (real database, real business logic)

Reference: Testing Without Mocks by James Shore ⧉