Migrating from Mox to Patch
This document provides guidelines for our ongoing migration from Mox to Patch for mocking in tests, as specified in ADR-0030: Elixir CLI Tools Architecture.
Background
Our ADR-0030 specifies Patch as our preferred mocking library:
defp deps do
[
# ...
{:patch, "~> 0.12.0", only: :test}, # Function patching for tests
# ...
]
end
While our ADR correctly identifies Patch, we've been implementing our tests using Mox. This migration aims to align our codebase with the architecture decisions.
Key Differences
| Mox | Patch |
|---|---|
| Requires behavior definitions | Works with any module/function |
Uses defmock to create mock modules |
No mock generation needed |
Uses expect/stub outside of test block |
Uses with_mocked_function to wrap test code |
| Requires setup for verification | Verifications are automatic |
| Explicit global mocking setup | No concept of global mocking |
Migration Steps
For each test file, follow these steps:
- Change
import Moxtoimport Patch - Remove all
defmockstatements - Remove
setup :verify_on_exit!andsetup :set_mox_global - Convert each
Mox.expectcall toPatch.with_mocked_function - Wrap test blocks with
with_mocked_function - For multiple mocks, nest
with_mocked_functioncalls - Convert verifications to use assertions within the mock function or
assert_received
Conversion Patterns
1. Basic Mock
Before (Mox):
# Define mock
defmock(MyMock, for: MyBehaviour)
test "basic mock" do
# Set expectation
Mox.expect(MyMock, :my_function, fn arg -> arg * 2 end)
# Execute code that calls the mock
result = MyModule.do_something()
# Assert on result
assert result == :expected
end
After (Patch):
test "basic mock" do
# Wrap test in with_mocked_function
with_mocked_function MyModule, :my_function, fn arg -> arg * 2 end do
# Execute code that calls the mock
result = MyModule.do_something()
# Assert on result
assert result == :expected
end
end
2. Multiple Expectations
Before (Mox):
test "multiple expectations" do
Mox.expect(MyMock, :first_function, fn -> :first_result end)
Mox.expect(MyMock, :second_function, fn -> :second_result end)
result = MyModule.do_something()
assert result == :expected
end
After (Patch):
test "multiple expectations" do
with_mocked_function MyModule, :first_function, fn -> :first_result end do
with_mocked_function MyModule, :second_function, fn -> :second_result end do
result = MyModule.do_something()
assert result == :expected
end
end
end
3. Verifying Calls
Before (Mox):
test "verifying calls" do
Mox.expect(MyMock, :my_function, fn arg ->
assert arg == :expected_arg
:result
end)
MyModule.do_something()
# Verify all expectations were called
Mox.verify!()
end
After (Patch):
test "verifying calls" do
test_pid = self()
with_mocked_function MyModule, :my_function, fn arg ->
# Verify argument
assert arg == :expected_arg
# Optionally send message for received verification
send(test_pid, {:called_with, arg})
:result
end do
MyModule.do_something()
# Verify function was called (optional if the assertion in the mock is sufficient)
assert_received {:called_with, :expected_arg}
end
end
Example Migration
We've created a full example of a test file migration in the repository:
- Original (Mox): test/codexis/cli/config_command_test.exs
- Migrated (Patch): test/codexis/cli/config_command_test_patch.exs
Additionally, see test/support/patch_example.exs for a dedicated example showing Patch usage patterns.
Comprehensive Migration Plan
File Prioritization
Based on our analysis, test files will be migrated in the following priority order:
- Foundation Files: Core test helpers, shared utilities, and test configuration
- Low Complexity Files: Tests with simple mocking patterns (1-2 mocks per test)
- Medium Complexity Files: Tests with moderate mocking needs (3-5 mocks, some nesting)
- High Complexity Files: Tests with complex mocking patterns (6+ mocks, deep nesting)
Batch Schedule
We'll migrate files in 4 batches spread over 2 weeks:
| Batch | Description | Timeline | Files |
|---|---|---|---|
| 1 | Foundation & Setup | Days 1-2 | test_helper.exs, test/support/* |
| 2 | Low Complexity | Days 3-5 | test/codexis/cli/, test/codexis/config/ |
| 3 | Medium Complexity | Days 6-9 | test/codexis/build_manager/, test/codexis/parsers/ |
| 4 | High Complexity | Days 10-14 | test/codexis/llm/, test/codexis/processors/ |
Each batch should follow this workflow:
- Migrate all files in the batch
- Run tests for migrated files
- Fix any issues identified
- Run the full test suite to check for integration issues
- Commit changes when all tests pass
Special Cases and Patterns
| Pattern | Migration Approach |
|---|---|
| Global Mocks | Replace set_mox_global with direct mocking in each test |
| Multiple Calls | Use the message-based approach with send and assert_received |
| Complex Verifications | Use assertions in mock function and extract complex checks to helper functions |
| Stubbed Mocks | Use the same with_mocked_function pattern without verification assertions |
| Sequential Calls | Create a state variable in the test and check in the mock function |
Rollback Strategy
If issues arise during migration, follow these rollback procedures:
-
For Uncommitted Changes:
- Revert changes in problematic files
- Continue with other files in the batch
- Document issues for further investigation
-
For Committed Batches:
- Create a revert commit restoring Mox implementation
- Add detailed notes about the issues encountered
- Schedule a separate task to address identified problems
Test Coverage Preservation
To ensure test quality remains high:
- Before starting each batch, record test coverage metrics
- After completing a batch, compare coverage metrics
- If coverage decreases, identify and fix gaps before proceeding
- Run the full test suite with randomized order to catch dependency issues
Helper Modules for Migration
To simplify the migration process, we've created helper modules that provide a more familiar interface for developers used to Mox:
PatchHelpers Module
The Codexis.Test.PatchHelpers module (in test/support/patch_helpers.ex) provides utility functions that make the transition smoother:
# Import the helpers
import Codexis.Test.PatchHelpers
# Simplified usage examples:
# 1. Mock a function with a single argument (similar to Mox.expect)
expect_call(RealModule, :function_name, fn arg -> :mocked_result end)
# 2. Mock a function with multiple arguments
expect_call_multi(RealModule, :function_name, fn arg1, arg2 -> arg1 + arg2 end)
# 3. Verify a function was called with specific arguments
verify = verify_call()
with_mocked_function RealModule, :function_name, fn arg ->
send(verify, {:called_with, arg})
:mocked_result
end do
# Test code that calls the mocked function
assert_called(verify, {:called_with, :expected_value})
end
# 4. Mock multiple functions without deep nesting
mock_functions([
{Module1, :function1, fn -> :result1 end},
{Module2, :function2, fn arg -> arg * 2 end}
]) do
# Test code that uses both mocked functions
end
# 5. Mock a sequence of calls to the same function
expect_sequence(RealModule, :function_name, [
fn -> :first_call_result end,
fn -> :second_call_result end
])
All helper functions are thoroughly tested in test/support/patch_helpers_test.exs.
Migration Tips
When using these helpers, keep in mind:
-
Transition Tool: These helpers are intended to make the migration easier, but once comfortable with Patch, prefer direct use of
with_mocked_function. -
Tests for Helpers: The helpers are thoroughly tested to ensure they work as expected in various scenarios.
-
Sequential Migration: Consider migrating one test file at a time, using the helpers to keep the changes minimal.
-
Scope Management: Remember that all mocks are scoped to their blocks, unlike Mox's global setup approach.
Benefits of Using Patch
- Simpler Setup: No need for behavior definitions or mock generation
- More Flexible: Can mock any module/function, not just behaviors
- Clearer Scope: Each test explicitly shows which functions are mocked
- Automatic Restoration: Original function behavior is restored after the test block
- Better Test Isolation: Mocks only apply within their own block scope
Migration Schedule
Follow our migration task plan (Task #13) to systematically migrate all test files from Mox to Patch. The task includes detailed subtasks for:
- Inventory of all Mox usage
- Migration planning and prioritization
- Implementation in batches
- Testing and verification
- Documentation updates
Further Reading
Lessons Learned from Initial Migrations
Based on our experience migrating the first batch of test files, we've identified some important patterns and considerations:
Key Migration Patterns
-
Test Structure Changes:
- With Patch, mocks and assertions must be defined together in the
with_mocked_functionblock - Place all your test logic and assertions inside the mock function block
- Use a single mock block for multiple related assertions when possible
- With Patch, mocks and assertions must be defined together in the
-
Common Issues and Solutions:
- Persistent Test State: Unlike Mox, Patch doesn't maintain state between test cases, which may require restructuring tests expecting persistent mock state.
- Multiple Mocks: When a test needs multiple mocks, nest the
with_mocked_functionblocks or use ourPatchHelpers.mock_functions/2helper. - Sequential Mocks: For functions that need to return different values on subsequent calls, use
PatchHelpers.expect_sequence/3.
-
Performance Considerations:
- Patch provides more localized mocking, which can be more efficient
- Tests using Patch may run faster as they have less global state management overhead
Tips for Smooth Migration
-
Start with the Parallel Approach: Create
*_patch.exsfiles alongside the original files to allow side-by-side comparison and easy rollback. -
Run Tests Frequently: Run tests after each file migration to catch issues early.
-
Watch for Implicit Mox Features: Some tests may rely on implicit Mox behaviors like:
- Global mock state
- Default stubbing
- Mock sharing between test modules
-
Simplify Test Logic: The migration is a good opportunity to simplify complex test logic and make tests more maintainable.
Before and After Examples
Test with Global Mock (Before)
# In setup
Mox.stub(MyMock, :fallback_function, fn -> :default_result end)
# In one test
Mox.expect(MyMock, :target_function, fn -> :result_1 end)
# In another test - the stub is still active
test "uses default" do
assert MyModule.use_fallback() == :default_result
end
Test with Localized Mock (After)
test "uses default" do
with_mocked_function MyRealModule, :fallback_function do
fn -> :default_result end
assert MyModule.use_fallback() == :default_result
end
end
Continue updating migration documentation as more patterns and solutions emerge during the batch migration process.