This guide outlines the process for migrating test mocks from the Mox framework to the Patch framework within the Codexis project.
1. Introduction & Rationale
The primary goal of this migration is to standardize our mocking approach on the Patch library, moving away from Mox. This decision is in line with Architectural Decision Record (ADR) 0030, which identified Patch as the preferred mocking library for its benefits in terms of API simplicity, lexical scoping of mocks, and improved maintainability.
Benefits of Migrating to Patch:
- Lexical Scoping: Mocks in Patch are active only within a specific
do...endblock, reducing the chance of mocks unintentionally affecting other tests or parts of a test. - Simpler API: Patch often requires less boilerplate. There's no need for separate mock module definitions (
Mox.defmock) or explicit global setup (Mox.set_mox_global()). - Direct Function Patching: Patch mocks functions directly on the original modules, which can be more intuitive than defining and interacting with separate mock modules.
- Compile-Time Checks (Optional): Using
Patch.instead/3, you can get compile-time feedback if the function you're trying to patch doesn't exist or has a different arity, leading to more robust tests.
2. Key Differences: Mox vs. Patch
| Feature | Mox | Patch |
|---|---|---|
| Mock Definition | Requires Mox.defmock/2 for a behaviour. |
No explicit mock definition; functions are patched directly. |
| Scoping | Global or per-process via set_mox_global |
Lexically scoped to with_mocked_function/3 or Patch.instead/3 block. |
| Expect vs. Stub | Distinct expect (verified) & stub. |
No direct distinction; behavior is defined, verification is separate. |
| Verification | Mox.verify!/1 or Mox.verify_once!/1. |
Assertions within mock function, or assert_received if using messages. |
| Compile Checks | Relies on behaviour for contract. | Patch.instead/3 provides compile-time arity/existence checks. |
3. Migration Patterns
These patterns are based on common Mox usage and their equivalents in Patch, largely derived from test/support/patch_example.exs.
3.1. Mox.expect / Mox.stub (Mocking a function with a specific implementation)
-
Mox way (conceptual):
# MyMockBehaviour defines the function signature # defmodule MyMockBehaviour do # @callback my_function(any()) :: any() # end # In test_helper.exs or support/mocks.ex # Mox.defmock(MyMock, for: MyMockBehaviour) # In test: # Mox.expect(MyMock, :my_function, fn arg -> "mocked result for " <> inspect(arg) end) # # or Mox.stub(...) # result = ModuleUnderTest.calls_my_function(input_arg) # assert result == "mocked result for #{input_arg}" -
Patch way:
# In test: import Patch with_mocked_function OriginalModule, :my_function, fn arg -> "mocked result for " <> inspect(arg) end do result = ModuleUnderTest.calls_my_function(input_arg) assert result == "mocked result for #{input_arg}" end -
Notes:
- Patch mocks the function directly on the
OriginalModule. - The mock is lexically scoped.
- Verification is handled by assertions about the outcome or side-effects.
- Patch mocks the function directly on the
3.2. Mox.verify! (Verifying a mock was called / Verifying arguments)
-
Mox way (conceptual):
# Mox.expect(MyMock, :my_function, fn expected_arg -> "some_result" end) # ModuleUnderTest.calls_my_function(actual_arg) # Mox.verify!(MyMock) -
Patch way:
Option A: Assertions within the mock function body
with_mocked_function OriginalModule, :my_function, fn actual_arg -> assert actual_arg == expected_arg, "Mocked function called with unexpected argument" "some_result" end do ModuleUnderTest.calls_my_function(actual_arg_to_pass) # Verification happens implicitly when the mock is called and the assertion passes. endOption B: Using
assert_received(useful for ensuring a call happened)test_pid = self() with_mocked_function OriginalModule, :my_function, fn actual_arg -> send(test_pid, {:my_function_called, actual_arg}) "some_result" end do ModuleUnderTest.calls_my_function(actual_arg_to_pass) assert_received {:my_function_called, ^actual_arg_to_pass} end
3.3. Mocking a function that raises an exception
-
Mox way (conceptual):
# Mox.expect(MyMock, :my_function_that_raises, fn _ -> # raise RuntimeError, message: "Boom from Mox!" # end) # # assert_raise RuntimeError, "Boom from Mox!", fn -> # ModuleUnderTest.calls_function_that_raises() # end -
Patch way:
import Patch with_mocked_function OriginalModule, :my_function_that_raises, fn _ -> raise RuntimeError, message: "Boom from Patch!" end do assert_raise RuntimeError, "Boom from Patch!", fn -> ModuleUnderTest.calls_function_that_raises() end end
4. Step-by-Step Migration Process for a Test File
-
Remove Mox Imports/Setup:
- Delete
import Mox. - Remove any
Mox.defmockcalls related to the test, typically found intest/support/mocks.exor directly in the test file for local mocks. - Remove Mox-specific setup like
setup :verify_on_exit!orsetup :set_mox_global.
- Delete
-
Import Patch:
- Add
import Patchat the top of your test file.
- Add
-
Convert Mock Usage:
- Replace
Mox.expect/3orMox.stub/3calls withPatch.with_mocked_function/3. - Ensure the code that relies on the mock is inside the
do...endblock ofwith_mocked_function.
- Replace
-
Adapt Verification Logic:
- If
Mox.verify!/1was used, convert this to assertions within the mock function's body (if checking arguments) or useassert_receivedif you were primarily ensuring the mock was called. - Assert the expected outcome of the function under test.
- If
-
Run Tests:
- Execute your tests to ensure they pass and the behavior remains identical.
5. Common Pitfalls & Troubleshooting
- Forgetting
import Patch: Tests will fail withundefined function Patch.with_mocked_function/3. - Mock Not Active (Scope Issues): If the code that should use the mock is outside the
do...endblock ofwith_mocked_function, the original function will be called. - Verification Logic: Remember that Patch doesn't have a global
verify!equivalent. Verification must be explicit within the test or mock. - Arity Mismatches with
Patch.instead/3: IfPatch.instead/3is used, it will fail at compile time if the function signature doesn't match. This is a feature, not a bug.with_mocked_function/3does not perform this check.
6. Patch-Specific Features & Best Practices
-
Patch.instead/3: Prefer this overwith_mocked_function/3when you want compile-time assurance that the function you are patching exists on the module with the correct arity. This helps catch typos or refactoring errors early.import Patch.Instead # Required import instead OriginalModule, :my_function, fn arg -> "mocked with arity check" end do # test code end -
Patch.attribute/3: Useful for mocking module attributes (@some_attribute). -
Keep Mocks Focused: Mock only what is necessary for the specific test case. Patch's lexical scoping helps with this.
-
Test Behavior, Not Implementation Details: As much as possible, mock at the boundaries of your system and test the behavior of your units, not how they internally call their dependencies.
7. FAQ
-
Q: Do I still need
test/support/mocks.exforMox.defmock?- A: No. Patch mocks functions directly on existing modules, so
Mox.defmockand the concept of pre-defined mock modules are not needed.
- A: No. Patch mocks functions directly on existing modules, so
-
Q: How do I mock multiple functions in one test?
-
A: You can nest
with_mocked_function(orPatch.instead) blocks. Alternatively, if the mocks are independent, you can have multiple separate blocks within the same test.```elixir with_mocked_function ModuleA, :func1, fn -> :result1 end do with_mocked_function ModuleB, :func2, fn -> :result2 end do # ... test code using both mocks ... end end ```
-
-
Q: What if the module I want to mock doesn't exist in the current environment (e.g., an HTTP client not used in
test)?- A: Patch requires the module and function to exist to be patched (especially with
Patch.instead). If you're mocking a dependency that might not be available, consider defining a simple version of that module for the test environment or using configuration to swap implementations, rather than relying solely on Patch for modules that might not compile. The typical pattern is to mock existing modules.
- A: Patch requires the module and function to exist to be patched (especially with
This guide provides a starting point for migration. Always refer to the official Patch documentation for the most detailed and up-to-date information.