How to test a behaviour with function macros?

Background

I have a behaviour that has some functions inside it, so that modules that implement it can use them:

defmodule MyBehaviour do

  #some callback for other modules to implement
  @callback match(data :: any) :: boolean

  defmacro __using__(_opts) do

    quote do
      @behaviour MyBehaviour
      
      def build_query(some_str) do
        String.slice(some_str, 1..-1) #random op on string
      end

    end
  end
end

Problem

However, I need to test if build_query is working properly. I can’t invoke it and test it normally via MyBehaviour.build_query/1 because technically it doesn’t belong to the behaviour, it is instead inside the macro.

Question

How do I access MyBehaviour.build_query/1 in order to test it?

If the function does not need data from within the macro just move the function in your behaviour and let the macro import it. If if does need data from the macro, then you need to call the macro from some module to test it on.

defmodule FooTest do
  defmodule FakeImplementor do
    use MyBehaviour
  end

  test "build the query" do
    # whatever is necessary to check `build_query/1` by calling it through `FakeImplementor`
  end
end

This is one way.

The other way (which I’d prefer) were to not inject it, but import it and also make it unimportable through options.

1 Like

If the function isn’t some default implementation for the required callbacks for your behaviour, I would contend that it probably shouldn’t be a part of the __using__ macro at all. No need to even import it in the __using__ macro, as that will duplicate the implementation of that function across all modules that use that behaviour.

I would (personally) do this:

defmodule MyBehaviour do

  #some callback for other modules to implement
  @callback match(data :: any) :: boolean
      
  def build_query(some_str) do
    String.slice(some_str, 1..-1) #random op on string
  end
end

defmodule MyImplementation do
  @behaviour MyBehaviour

  def match(data) do
    data
    |> MyBehaviour.build_query()
    |> is_valid?()
  end
end

Then you test that function directly as any other function, and you don’t have duplicate implementations of the same functionality (which is basically what’s happening when you import anything). If your implementations of that behaviour need variations on that function, you can replace MyBehaviour.build_query/1 with a locally defined variation on that function.

@NobbZ
This is an approach I also took. However I do have the feeling that instead of manually making a FakeImplementor ( which is in reality a mock ), I should be using real mocks, in specific, I should be using the library mox with contracts and all. However I am not yet up to that level and so for the time being I need an intermediate solution.

@devonestes
This was actually my original approach. Since every module that implements this behaviour will likely use this function, I saw it as a good idea to simply put it inside the macro.


My original idea here is to follow something among the lines of use GenServer. You can access and use a ton of their functions, but you don’t do it via GenServer.some_function, you just call some_function and you are done with it.

My idea would be to do the same with a simple module, but I don’t quite know how to test it. How did the guys from Elixir team test their implementations? By using FakeImplementors? Mox? There has to be a way.

Elixir also uses a module created just for testing: https://github.com/elixir-lang/elixir/blob/7f4756d401a4e59f531484f96990f710d41be540/lib/elixir/test/elixir/agent_test.exs#L16-L36

I mean that’s in the end exactly what you want to make sure works: Using it in another module. It’s easy to do so why not test it that way?

What ton is this? I’m not aware of a single one…


edit

Took a look at the source code, GenServer does not put anything into your module, except for the child_spec and the default callback implementations:

They don’t test behaviour definitions in Elixir, they only test the actual implementations. For example, they have a Calendar behaviour, but they only test the different calendars themselves, not the behaviour on its own using a fake implemenation or something. IMHO this is the right thing to do. Test the implementations, not the behaviour.

How do you know if handle_call is correctly implemented)? Child_spec? ( how would you test them? )
You automatically include them in your code when you use the GenServer. These functions are tested, right?

This is what I am trying to understand. Is it only possible to test functions inside macros by using a FakeImplementor? If so, Wouldn’t it be better to use moxx ?


PS: I do agree with the overall notion that one should extract the function out of the macro and into the behaviour, but I am trying to understand how one would test functions inside macros.

The default implementations are not tested at all, they just logg if they were called to avoid a spilling message queue, as soon as you see such a message in production, you should implement a proper handler that deals with those messages. If you have at least one handle_X, the default one isn’t used any more and your GenServer would crash on an unknown message for that handler.

child_spec is probably not tested as well, it just returns a constant map and doesn’t change.

If at all you’d test the __using__ macro if it creates the child_spec function correctly, as you need to specify some things that child_spec shall return as an argument to the use.

1 Like

Alright. So what I get from this discussion is the following:

  1. One should avoid placing functions inside macros as much as possible
  2. Such functions can only be tested via FakeImplementators

My last question, is putting functions inside the use macro considered an anti-pattern in this community?
What are the cases one should and should not do it?

I am merely asking for online resources to read, since I failed to encounter any.

I’m not aware of the community opinion, but I hate everything that pollutes my namespace without giving me the option to surpress this polution.

I can cope with overridable default implementations, but for helper functions I prefer if they are in their own namespace.

But that is my personal opinion, the community might see it differently.

1 Like