Hammox Documentation

repository·master·Indexed 20 days ago

https://github.com/msz/hammox

A library for rigorous unit testing in Elixir that extends Mox by automatically enforcing behaviour typespecs on mocks and real implementations. It ensures contract consistency by raising Hammox.TypeMatchError when return values or arguments deviate from defined callbacks. Key features include Hammox.protect/2 for validating real implementations, protocol type enforcement, and integrated telemetry for performance tracing.

Tokens
6.6K
Snippets
14
Records
23
Agent score
69%

What's inside Hammox

  1. How Hammox enforces contract consistency

    master

    Hammox extends the philosophy of Mox by using Elixir typespecs to ensure mocks conform to the defined behaviour.

    When a behaviour's @callback is updated (e.g., changing a return type from [binary()] to {:ok, [binary()]} | {:error, term()}), existing mocks that return the old type will trigger a Hammox.TypeMatchError during test execution. This prevents tests from passing with invalid mock data that would fail in production with the real implementation.

  2. Protocol type enforcement in Hammox

    master

    When a typespec uses a protocol type (e.g., Enumerable.t()), Hammox interprets this as "a struct implementing the given protocol". If a mock returns a value that does not implement the protocol (like an :atom for an Enumerable.t()), Hammox will raise a Hammox.TypeMatchError.

    Example error:

    ** (Hammox.TypeMatchError)
    Returned value :atom does not match type Enumerable.t().
      Value :atom does not implement the Enumerable protocol.
  3. Install Hammox

    master

    To install Hammox, add it to your mix.exs dependencies. It is recommended to include it only for the :test environment. If you are migrating from Mox, remove :mox from your dependencies first.

    def deps do
      [
        {:hammox, "~> 1.0", only: :test}
      ]
    end
  4. Migrate from Mox to Hammox

    master
    Migrating from Mox to Hammox is straightforward. Replace all occurrences of Mox with Hammox (e.g., import Mox becomes import Hammox). Once replaced, all your mock calls in tests will automatically be checked against the behaviour's typespecs.
  5. Disable Hammox protection for specific mocks

    master
    Because Hammox includes Mox as a dependency, they are interoperable. If you need to bypass Hammox's typespec enforcement for a specific mock, you can simply use vanilla Mox for that instance.
  6. Use Hammox for mocking and contract testing

    master

    Hammox is a library for rigorous unit testing using mocks, explicit behaviours, and contract tests. It provides a way to ensure that your mocks not only respond to calls but also adhere to the typespecs defined in your Elixir behaviours.

    Most core functions are delegated from Mox for backwards compatibility, but Hammox wraps expect/4 and stub/3 to automatically inject type-checking logic based on the behaviour's typespecs.

  7. Handle `Hammox.TypeMatchError`

    master

    When using Hammox.protect/2, Hammox.protect/3, Hammox.expect/4, or Hammox.stub/3, Hammox will raise Hammox.TypeMatchError if:

    1. The arguments passed to the function do not match the typespec defined in the behaviour.
    2. The value returned by the function does not match the return type defined in the behaviour.

    This error is used to enforce contract testing between your code and its dependencies.

  8. Enable telemetry for Hammox

    master

    Hammox includes telemetry instrumentation to monitor operations, but it is disabled by default to minimize overhead. To enable telemetry, add the following configuration to your application's configuration file (e.g., config/config.exs):

    config :hammox, enable_telemetry?: true

    When enabled, Hammox uses the :telemetry library to emit spans. When disabled (the default), it uses a NoOp implementation that performs no instrumentation.

  9. Protect modules and functions using `use Hammox.Protect`

    master

    Instead of manually calling Hammox.protect/3 to generate protected anonymous functions, you can use the Hammox.Protect module within your test modules. This macro-based approach automatically defines functions in your test module that correspond to the functions in the target module, but with the added benefit of being protected by Hammox.

    This is similar to import-ing the module you are testing, but the resulting functions are protected versions that enforce contract testing.

    Configuration Options

    When calling use Hammox.Protect, you can provide the following options:

    • :module (required): The module you want to protect (typically the module under test).
    • :behaviour: The behaviour module you want to protect the implementation module with. If :module and :behaviour are the same, this can be omitted.
    • :funs: An optional list of specific functions to protect for the preceding :behaviour.

    Handling Multiple Behaviours

    You can provide multiple :behaviour and :funs options to protect modules that implement multiple behaviours. Note that :funs is specific to the :behaviour that immediately precedes it.

    • If you provide a :behaviour without a following :funs option, all callbacks defined in that behaviour will be protected.
    • If you provide only the :module option, all callbacks from the module's own behaviour (if any) will be protected.
    use Hammox.Protect,
      module: Hammox.Test.MultiBehaviourImplementation,
      behaviour: Hammox.Test.SmallBehaviour,
      # the `funs` opt below effects the funs protected from `SmallBehaviour`
      funs: [foo: 0, other_foo: 1],
      behaviour: Hammox.Test.AdditionalBehaviour
      # with no `funs` pt provided after `AdditionalBehaviour`, all callbacks
      # will be protected
  10. Implement a Telemetry Handler for Tracing

    master

    To use Hammox telemetry for performance tracing (e.g., with Spandex), implement a handle_event/4 function. You can capture start events to begin a span, stop events to finish it, and exception events to record errors in the trace.

    Note that [:hammox, :expect, :start] provides metadata like :mock and :function_name, while measurements typically contains timing information like :system_time or :duration.

    defmodule HammoxTelemetryHandler do
      alias Spandex.Tracer
    
      def handle_event([:hammox, :expect, :start], measurements, metadata, _config) do
        when is_map(measurements)
        mock_name = Map.get(metadata, :mock)
        func_name = Map.get(metadata, :name)
        expect_count = Map.get(metadata, :count) |> to_string()
    
        tags = 
          []
          |> tags_put(:mock, mock_name)
          |> tags_put(:func_name, func_name)
          |> tags_put(:expect_count, expect_count)
    
        system_time = get_time(measurements, :system_time)
    
        if Tracer.current_trace_id() do
          span_string = "#{mock_name}.#{func_name}" |> String.trim_leading("Elixir.")
          span_string = "expect #{span_string}"
          _span_context = Tracer.start_span(span_string, service: :hammox, tags: tags)
          Tracer.update_span(start: system_time)
        end
      end
    
      def handle_event([:hammox, :expect, :stop], measurements, _metadata, _config) do
        handle_exception(measurements)
      end
    
      def handle_event([:hammox, :expect, :exception], measurements, _metadata, _config) do
        handle_exception(measurements)
      end
    
      defp handle_exception(measurements) do
        error_message = "Exception occurred during hammox execution"
        Logger.error(error_message)
    
        if Tracer.current_trace_id() do
          current_span = Tracer.current_span([])
          Tracer.update_span_with_error(error_message, current_span)
        end
    
        handle_stop(measurements)
      end
    
      defp handle_stop(measurements, tags \ []) do
        duration_time = get_time(measurements, :duration)
    
        case Tracer.current_span([]) do
          %{start: start_time} ->
            completion_time = start_time + duration_time
            Tracer.update_span(tags: tags, completion_time: completion_time)
            Tracer.finish_span()
    
          _no_current_span ->
            :ok
        end
      end
    
      defp get_time(log_entry, key) do
        Map.get(log_entry, key)
      end
    end
  11. Attach Hammox Telemetry Handlers

    master

    You can attach handlers for all supported Hammox events using :telemetry.attach_many/4. The following pattern demonstrates how to generate event lists for start, stop, and exception phases and attach them to a handler function.

    def build_events(event_atom) do
      event_list = [
        :expect,
        :allow,
        :run_expect,
        :check_call,
        :match_args,
        :match_return_value,
        :fetch_typespecs,
        :cache_put,
        :stub,
        :verify_on_exit!,
        :deny
      ]
    
      Enum.map(event_list, fn event ->
        [:hammox, event, event_atom]
      end)
    end
    
    # In your application startup:
    start_events = build_events(:start)
    :ok = :telemetry.attach_many("hammox-start", start_events, &handle_event/4, nil)
    
    stop_events = build_events(:stop)
    :ok = :telemetry.attach_many("hammox-stop", stop_events, &handle_event/4, nil)
    
    exception_events = build_events(:exception)
    :ok = :telemetry.attach_many("hammox-exception", exception_events, &handle_event/4, nil)