View Source Patch.Mock.Code (patch v0.13.0)

Patch mocks out modules by generating mock modules and recompiling them for a target module.

Patch's approach to mocking a module provides some powerful affordances.

  • Private functions can be mocked.
  • Internal function calls are effected by mocks regardless of the function's visibility without having to change the way code is written.
  • Private functions can be optionally exposed in the facade to make it possible to test a private function directly without changing its visibility in code.

Mocking Strategy

There are 4 logical modules and 1 GenServer that are involved when mocking a module.

The 4 logical modules:

  • target - The module to be mocked.
  • facade - The target module is replaced by a facade module that intercepts all external calls and redirects them to the delegate module.
  • original - The target module is preserved as the original module, with the important transformation that all local calls are redirected to the delegate module.
  • delegate - This module is responsible for checking with the server to see if a call is mocked and should be intercepted. If so, the mock value is returned, otherwise the original function is called.

Each target module has an associated GenServer, a Patch.Mock.Server that has keeps state about the history of function calls and holds the mock data to be returned on interception. See Patch.Mock.Server for more information.

example-target-module

Example Target Module

To better understand how Patch works, consider the following example module.

defmodule Example do
  def public_function(argument_1, argument_2) do
    {:public, private_function(argument_1, argument_2)}
  end

  defp private_function(argument_1, argument_2) do
    {:private, argument_1, argument_2}
  end
end

facade-module

facade module

The facade module is automatically generated based off the exports of the target module. It takes on the name of the provided module.

For each exported function, a function is generated in the facade module that calls the delegate module.

defmodule Example do
  def public_function(argument_1, argument_2) do
    Patch.Mock.Delegate.For.Example.public_function(argument_1, argument_2)
  end
end

delegate-module

delegate module

The delegate module is automatically generated based off all the functions of the target module. It takes on a name based off the target module, see Patch.Mock.Naming.delegate/1.

For each function, a function is generated in the delegate module that calls Patch.Mock.Server.delegate/3 delegating to the server named for the target module, see Patch.Mock.Naming.server/1.

defmodule Patch.Mock.Delegate.For.Example do
  def public_function(argument_1, argument_2) do
    Patch.Mock.Server.delegate(
      Patch.Mock.Server.For.Example,
      :public_function,
      [argument_1, argument_2]
    )
  end

  def private_function(argument_1, argument_2) do
    Patch.Mock.Server.delegate(
      Patch.Mock.Server.For.Example,
      :private_function,
      [argument_1, argument_2]
    )
  end
end

original-module

original module

The original module takes on a name based off the provided module, see Patch.Mock.Naming.original/1.

The code is transformed in the following ways.

  • All local calls are converted into remote calls to the delegate module.
  • All functions are exported
defmodule Patch.Mock.Original.For.Example do
  def public_function(argument_1, argument_2) do
    {:public, Patch.Mock.Delegate.For.Example.private_function(argument_1, argument_2)}
  end

  def private_function(argument_1, argument_2) do
    {:private, argument_1, argument_2}
  end
end

external-function-calls

External Function Calls

First, let's examine how calls from outside the module are treated.

public-function-calls

Public Function Calls

Code calling Example.public_function/2 has the following call flow.

[Caller] -> facade -> delegate -> server -> mocked? -> yes   (Intercepted)
         [Mock Value] <----------------------------|----'
                                                    -> no -> original   (Run Original Code)
         [Original Value] <--------------------------------------'

Calling a public function will either return the mocked value if it exists, or fall back to calling the original function.

private-function-calls

Private Function Calls

Code calling Example.private_function/2 has the following call flow.

[Caller] --------------------------> facade
         [UndefinedFunctionError] <-----'

Calling a private function continues to be an error from the external caller's point of view.

The expose option does allow the facade to expose private functions, in those cases the call flow just follows the public call flow.

internal-calls

Internal Calls

Next, let's examine how calls from outside the module are treated.

public-function-calls-1

Public Function Calls

Code in the original module calling public functions have their code transformed to call the delegate module.

original -> delegate -> server -> mocked? -> yes   (Intercepted)
         [Mock Value] <------------------|----'
                                          -> no -> original   (Run Original Code)
         [Original Value] <----------------------------'

Since the call is redirected to the delegate, calling a public function will either return the mocked value if it exists, or fall back to calling the original function.

private-function-call-flow

Private Function Call Flow

Code in the original module calling private functions have their code transformed to call the delegate module

original -> delegate -> server -> mocked? -> yes   (Intercepted)
         [Mock Value] <------------------|----'
                                          -> no -> original   (Run Original Code)
         [Original Value] <----------------------------'

Since the call is redirected to the delegate, calling a private function will either return the mocked value if it exists, or fall back to calling the original function.

code-generation

Code Generation

For additional details on how Code Generation works, see the Patch.Mock.Code.Generate module.

Link to this section Summary

Functions

Extracts the abstract_forms from a module

Extracts the attribtues from a module

Classifies an exported mfa into one of the following classifications

Compiles the provided abstract_form with the given compiler_options

Extracts the compiler options from a module.

Extracts the exports from the provided abstract_forms for the module.

Given a module and a list of exports filters the list of exports to those that have the given classification.

Freezes a module by generating a copy of it under a frozen name with all remote calls to the target module re-routed to the frozen module.

Mocks a module by generating a set of modules based on the target module.

Purges a module from the code server

Marks a module a sticky

Unsticks a module

Link to this section Types

@type binary_error() ::
  :badfile | :nofile | :not_purged | :on_load_failure | :sticky_directory
@type chunk_error() ::
  :chunk_too_big
  | :file_error
  | :invalid_beam_file
  | :key_missing_or_invalid
  | :missing_backend
  | :missing_chunk
  | :not_a_beam_file
  | :unknown_chunk
@type compiler_option() :: term()
Link to this type

export_classification()

View Source
@type export_classification() :: :builtin | :generated | :normal
@type exports() :: Keyword.t(arity())
@type form() :: term()
@type load_error() :: :embedded | :badfile | :nofile | :on_load_failure
@type option() :: Patch.Mock.exposes_option()

Sum-type of all valid options

Link to this section Functions

@spec abstract_forms(module :: module()) ::
  {:ok, [form()]}
  | {:error, :abstract_forms_unavailable}
  | {:error, chunk_error()}
  | {:error, load_error()}

Extracts the abstract_forms from a module

@spec attributes(module :: module()) ::
  {:ok, Keyword.t()} | {:error, :attributes_unavailable}

Extracts the attribtues from a module

Link to this function

classify_export(module, function, arity)

View Source
@spec classify_export(module :: module(), function :: atom(), arity :: arity()) ::
  export_classification()

Classifies an exported mfa into one of the following classifications

  • :builtin - Export is a BIF.
  • :generated - Export is a generated function.
  • :normal - Export is a user defined function.
Link to this function

compile(abstract_forms, compiler_options \\ [])

View Source
@spec compile(abstract_forms :: [form()], compiler_options :: [compiler_option()]) ::
  :ok
  | {:error, binary_error()}
  | {:error, {:abstract_forms_invalid, [form()], term()}}

Compiles the provided abstract_form with the given compiler_options

In addition to compiling, the module will be loaded.

Link to this function

compiler_options(module)

View Source
@spec compiler_options(module :: module()) ::
  {:ok, [compiler_option()]}
  | {:error, :compiler_options_unavailable}
  | {:error, chunk_error()}
  | {:error, load_error()}

Extracts the compiler options from a module.

Link to this function

exports(abstract_forms, module, exposes)

View Source
@spec exports(
  abstract_forms :: [form()],
  module :: module(),
  exposes :: Patch.Mock.exposes()
) ::
  exports()

Extracts the exports from the provided abstract_forms for the module.

The exports returned can be controlled by the exposes argument.

Link to this function

filter_exports(module, exports, classification)

View Source
@spec filter_exports(
  module :: module(),
  exports :: exports(),
  classification :: export_classification()
) :: exports()

Given a module and a list of exports filters the list of exports to those that have the given classification.

See classify_export/3 for information about export classification

@spec freeze(module :: module()) :: :ok | {:error, term()}

Freezes a module by generating a copy of it under a frozen name with all remote calls to the target module re-routed to the frozen module.

Link to this function

module(module, options \\ [])

View Source
@spec module(module :: module(), options :: [option()]) ::
  {:ok, Patch.Mock.Code.Unit.t()} | {:error, term()}

Mocks a module by generating a set of modules based on the target module.

The target module's Unit is returned on success.

@spec purge(module :: module()) :: boolean()

Purges a module from the code server

@spec stick_module(module :: module()) :: :ok | {:error, load_error()}

Marks a module a sticky

@spec unstick_module(module :: module()) :: {:ok, boolean()} | {:error, load_error()}

Unsticks a module

Returns {:ok, was_sticky?} on success, {:error, reason} otherwise