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 ⧉:
- Nullables ⧉: Test doubles that behave like production code
- Parameterless Instantiation ⧉: Factory methods that hide dependency creation
- Thin Wrapper ⧉: Minimal abstraction over external APIs
- Embedded Stub ⧉: Test double implementation bundled with production code
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()andcreate_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:
- 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!
- 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:
- Simple usage - One call creates entire null dependency graph
- 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()
- Stable over time - Adding new dependencies doesn't break tests
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()orMyActions.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:
- No mocking: Use test doubles that behave like production code
- Real database: Test against actual PostgreSQL for confidence
- Two factories:
create()for production,create_null()for tests - Track requests: Use
track_requestsparameter to verify API calls - Embedded stubs: Test doubles live alongside production code
- Layered composition: Nullables compose naturally across layers
- 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 ⧉