BUCKYVERSE

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:

  1. Change import Mox to import Patch
  2. Remove all defmock statements
  3. Remove setup :verify_on_exit! and setup :set_mox_global
  4. Convert each Mox.expect call to Patch.with_mocked_function
  5. Wrap test blocks with with_mocked_function
  6. For multiple mocks, nest with_mocked_function calls
  7. 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:

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:

  1. Foundation Files: Core test helpers, shared utilities, and test configuration
  2. Low Complexity Files: Tests with simple mocking patterns (1-2 mocks per test)
  3. Medium Complexity Files: Tests with moderate mocking needs (3-5 mocks, some nesting)
  4. 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:

  1. Migrate all files in the batch
  2. Run tests for migrated files
  3. Fix any issues identified
  4. Run the full test suite to check for integration issues
  5. 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:

  1. For Uncommitted Changes:

    • Revert changes in problematic files
    • Continue with other files in the batch
    • Document issues for further investigation
  2. 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:

  1. Before starting each batch, record test coverage metrics
  2. After completing a batch, compare coverage metrics
  3. If coverage decreases, identify and fix gaps before proceeding
  4. 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:

  1. Transition Tool: These helpers are intended to make the migration easier, but once comfortable with Patch, prefer direct use of with_mocked_function.

  2. Tests for Helpers: The helpers are thoroughly tested to ensure they work as expected in various scenarios.

  3. Sequential Migration: Consider migrating one test file at a time, using the helpers to keep the changes minimal.

  4. Scope Management: Remember that all mocks are scoped to their blocks, unlike Mox's global setup approach.

Benefits of Using Patch

  1. Simpler Setup: No need for behavior definitions or mock generation
  2. More Flexible: Can mock any module/function, not just behaviors
  3. Clearer Scope: Each test explicitly shows which functions are mocked
  4. Automatic Restoration: Original function behavior is restored after the test block
  5. 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:

  1. Inventory of all Mox usage
  2. Migration planning and prioritization
  3. Implementation in batches
  4. Testing and verification
  5. 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

  1. Test Structure Changes:

    • With Patch, mocks and assertions must be defined together in the with_mocked_function block
    • Place all your test logic and assertions inside the mock function block
    • Use a single mock block for multiple related assertions when possible
  2. 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_function blocks or use our PatchHelpers.mock_functions/2 helper.
    • Sequential Mocks: For functions that need to return different values on subsequent calls, use PatchHelpers.expect_sequence/3.
  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

  1. Start with the Parallel Approach: Create *_patch.exs files alongside the original files to allow side-by-side comparison and easy rollback.

  2. Run Tests Frequently: Run tests after each file migration to catch issues early.

  3. Watch for Implicit Mox Features: Some tests may rely on implicit Mox behaviors like:

    • Global mock state
    • Default stubbing
    • Mock sharing between test modules
  4. 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.