Mocks in ExUnit Tests

Posted August 26th, 2016

One of the first products we built in Elixir was built very rapidly in the early days of our understanding of the language.

One thing I'm sure many developers are familiar with is that when you come to a new language you often try to find as many crutches as you can from your past understanding to hold you up and keep you "productive". I think I was pragmatic about that approach this time around. When building our application I was aware that I'd have to come back later and refactor code smells as my understanding deepened.

The first stumbling block for me with Elixir was in the testing space. Our product had several external API integrations and I needed to mock them to test chunks of the application. Relying on the crutch of mocks I'd used in languages like JavaScript and Ruby in the past, using something like Mock seemed like the ideal approach.

My biggest day to day issue with Mock was that the code to use it was quite repetitive. I could of course pass inline functions to it, but with a growing amount of external integration points, I knew that my test code base would become harder to manage over time, growing verbose and out of control.

The second issue came when I realised that Mock would not allow me to run my test suites asynchronously (I probably should have been aware of this as it is stated in the README). Mock has to mock modules Globally so async testing just doesn't work.

The final blow came when I'd been thinking about this a bit and read this brilliant article by José Valim. There are lots of great bits of insight in that article including some patterns for actually implementing good mocks.

We decided to refactor our application tests based on the insights from this article, we opted to use a configuration approach for most of our external services. One thing we found was that this could be error prone, you'd be testing a sandbox module in test but could easily forget to configure the real entity in production. We solved this simply by using Application.get_env/3 which lets you provide a default value as the 3rd argument. This was much less error prone. By providing our production modules as defaults we only needed one line in config/test.exs to override it and use the sandbox version.

We also used the approach listed in the article for providing "spies". Spies were useful in cases where it made sense to check that our service had been called with a certain argument (e.g. in a complex workflow). The approach given in the article to do this uses Kernel.self/1 and Kernel.send/1 to send a message to the current process. Then you can use assert_received from ExUnit to check that the message was received.

Here is a full example from our code base with just the names changed:

 1  #sandbox module
               2  defmodule MyApp.MyModule.Test do
               3    def my_method(some_arg, some_other_arg) do
               4      send self(), {:my_method, some_arg, some_other_arg}
               5    end
               6  end
               7
               8  #consumer of the module
               9  defmodule MyApp.AnotherModule do
              10    @service Application.get_env(:my_app, :service, MyApp.MyModule.Prod)
              11
              12    def do_some_work do
              13      ...
              14      @service.my_method(...)
              15    end
              16  end
              17
              18  # test
              19  test "MyModule should have been called with specific args" do
              20    MyApp.AnotherModule.do_some_work(...)
              21
              22    # Add more specific pattern matches here to args sent
              23    assert_received {:send, _, _}
              24  end
              

What are the outcomes of this approach? Well firstly our tests run asynchronously now so they can and do run a lot faster. Secondly we found this mocking approach much easier to maintain. It was usually very simple to create a Sandbox module. I actually think I would put my foot in the sand from now on and say global mocks are a terrible idea. I would go as far as to say that using something like Mock is not just controversial but all out just a terrible idea, especially as your test suite grows.