How Substitute3 can help you to write cleaner and more concise Tests

A comparison between the packages Substitute3 and Mock for testing with focus on layered architecture.

How Substitute3 can help you to write cleaner and more concise Tests
Photo by marc belver colomer / Unsplash

When async crept into a Python clean architecture project, the test setup needed to follow. The repository layer had become a collection of coroutines, and the question was how to replace exactly one piece of that infrastructure while keeping everything else real and low effort. We will also talk about if Substitute3 is a viable competitor for the popular Mock library.

Surgical Replacement, Not a Full Mock Tree

The setup below only substitutes the UserRepository. Everything else — the rest of the infrastructure bindings — is wired through the actual inject_infrastructure call, the same function production code uses. The exclude_from_binding parameter tells it to skip UserRepository since we have already bound a substitute for it.

@staticmethod
def inject_services(binder):
    return_value: Result[User] = User.from_(user_id="id1", name="No Name", email="test@me.com")
    mock_repository: UserRepository = Substitute()
    mock_repository.get_user.returns_async(return_value)
    mock_session: AsyncSession = Substitute()
    binder.bind(UserRepository, mock_repository)
    inject_infrastructure(binder, session=mock_session, exclude_from_binding=[UserRepository])

inject.configure(inject_services)

UserService itself is never instantiated with explicit arguments in the test. The @inject.autoparams() decorator on its constructor resolves UserRepository from the container automatically, so the test just calls UserService() and gets a fully wired object with our substitute in place.

Substitute vs Mock for Async Methods

With unittest.mock, making a method async requires reaching for AsyncMock and assigning it explicitly to the attribute you want to intercept:

from unittest.mock import MagicMock, AsyncMock

mock_repository = MagicMock(spec=UserRepository)
mock_repository.get_user = AsyncMock(return_value=return_value)

The AsyncMock class exists precisely because MagicMock does not produce awaitable callables. You need to know which methods are async and handle them differently at the declaration site.

Substitute keeps the same API for both cases. Synchronous stubs use .returns(), async stubs use .returns_async():

mock_repository: UserRepository = Substitute()
mock_repository.get_user.returns_async(return_value)

Assertions read in plain language regardless of whether the method is async:

mock_repository.get_user.was_called_with("id1")

Compare that with Mock's assert_called_once_with, which raises on failure through an exception whose message is not always obvious. Substitute's complaint system raises typed complaint objects with clear descriptions, which makes a failing assertion easier to read in a test report.

The Result Type for Testing

get_user returns Result[User], a union of Success and Failure from the Resolute library. Rather than checking for None or catching exceptions, the calling code and the test both inspect the result explicitly. is_success(result) is a one-word statement of intent, and result.value.email only makes sense to access after that check passes. The type system and the test assertions align.

result = await user_service.get_user("id1")
assert is_success(result)
assert result.value.email == "test@me.com"

The pattern removes an entire category of defensive checks from service code because a Failure propagated from the repository can never be silently treated as a valid domain object.

Final verdict: Substitute3 vs Mock

1. Async support is first-class

  # Substitute
  mock_repository.get_user.returns_async(return_value)                                                                                                                 
                                                                                                                                                                       
  # Mock requires extra ceremony                                                                                                                                       
  mock_repository.get_user = AsyncMock(return_value=return_value)                                                                                                      
  # or: mock_repository.get_user.return_value = coroutine(...)                                                                                                         

2. Fluent, readable API inspired by NSubstitute (.NET)

  # Substitute — reads like a sentence                                                                                                                                 
  calculator.add(1, 2).returns(3)
  database.insert.was_called_with(City('New York'))
  database.insert.was_called_exactly(TWICE)                                                                                                                            
                                                                                                                                                                       
  # Mock — property-based, less discoverable                                                                                                                           
  mock.add.return_value = 3                                                                                                                                            
  mock.insert.assert_called_with(City('New York'))                                                                                                                     
  mock.insert.assert_called_exactly(2)

3. Type-based matching for return values

  # Returns 9 for any two ints, without specifying exact values                                                                                                        
  calculator.add(int, int).returns(9)

Mock has no equivalent — you'd need a side_effect function.

4. Specific and readable complaint exceptions

12 distinct exception types (MissingCallComplaint, ArgumentMissmatchComplaint, etc.) vs Mock's generic AssertionError with a text message. Easier to distinguish failures in test output.

5. No spec required — works naturally with typed code

Since our domain uses typed return values like Result[User], we configure the mock with the actual typed object and don't need to spec anything.


The main tradeoff: Mock is stdlib (no dependency), has broader ecosystem support, and MagicMock auto-specs more behaviors. Substitute shines specifically in async-heavy, typed, clean-architecture codebases like ours.

substitute3
A friendly substitute for python mocking frameworks