Is this a better way to test Elixir?

This is a followup to my earlier attempt here

The difference I am experimenting with here is to see if swapping out entire modules rather than individual functions is less intrusive on the code under test.

The problem of separating determinism from nondeterminism has been solved for decades with dependency injection,
and until someone is able to point out a better way,
I am sticking with the theory of dependency injection for now.

The idea is to pass modules into the parameter list in production,
and make stubs and fakes look like modules for test.

This turned out to be much more difficult than I expected, but I am not sure if that is because it is not easy in Elixir, or if I am not aware of the proper language constructs.
I ended up dynamically naming my fake and stub modules to keep their state isolated from each other.
I refactored duplication with a macro.
I pasted the IOFake module to the end of test/test_helper.exs because I couldn’t figure out another way to get the other tests to see it.

Although I would prefer a solution that is more simple and less clever,
this does still seem, to me, to be an objectively superior way to test non-determinism in Elixir code than what I have seen so far in the ecosystem.
I can run my tests in parallel without worrying about colliding state.
I have taken complete control of all non-determinism.
The code under test does not need to change much (I could even leave the parameters capitalized, making them look like modules instead of parameters).
I don’t have to make excuses regarding why this or that is too hard to test, I just test everything.

I only did IO.puts here, but you can see how this can be extended to any non-determinism.

For those of you that are interested in this kind of thing, do you see any flaws in my logic?
Or perhaps I missed a better way because I don’t know elixir that well yet?

defmodule ConcurrentA do
  def send_line(line, io) do
    io.puts(line)
  end
end
defmodule ConcurrentB do
  def send_line(line, io) do
    io.puts(line)
  end
end
defmodule ConcurrentATest do
  use ExUnit.Case, async: true

  test "send line" do
    Fake.with_io fn io ->
      ConcurrentA.send_line("hello", io)
      ConcurrentA.send_line("world", io)
      actual = io.get_lines()
      expected = ["hello", "world"]
      assert expected == actual
    end
  end
end
defmodule ConcurrentBTest do
  use ExUnit.Case, async: true

  test "send line" do
    Fake.with_io fn io ->
      ConcurrentB.send_line("hello", io)
      ConcurrentB.send_line("world", io)
      actual = io.get_lines()
      expected = ["hello", "world"]
      assert expected == actual
    end
  end
end
# Pasted at the end of test_helper.exs
defmodule IOFake do
  defmacro __using__(_) do
    quote do
      def loop(lines) do
        receive do
          {:get_lines, caller} ->
            send(caller, Enum.reverse(lines))
            loop(lines)
          {:puts, line} ->
            new_lines = [line | lines]
            loop(new_lines)
          {:stop, caller} ->
            IO.puts("stopped #{__MODULE__}")
            send(caller, :stopped)
          x -> raise "unmatched pattern #{inspect x}"
        end
      end
      def puts(line) do
        send(__MODULE__, {:puts, line})
      end
      def get_lines() do
        send(__MODULE__, {:get_lines, self()})
        receive do x -> x end
      end
      def start() do
        process = spawn_link(fn -> __MODULE__.loop([]) end)
        Process.register(process, __MODULE__)
        IO.puts("started #{__MODULE__}")
      end
      def stop() do
        send(__MODULE__, {:stop, self()})
        receive do x -> x end
      end
    end
  end
end

defmodule Fake do
  def with_io(f) do
    id = System.unique_integer([:positive])

    {_, io, _, _} = defmodule String.to_atom("IOFake#{id}") do
      use IOFake
    end

    io.start()
    result = f.(io)
    io.stop()
    result
  end
end

If this is what you want to do, I think you should read:

Doing something like:

def my_io do
  Application.get_env(:io, :io_impl)
end

then in your app config you can:

# config/prod.exs
config :my_app, :io_impl, IO

# config/test.exs
config :my_app, :io_impl, MockIO

And then in your app code you can:

# some_business_logic.ex
my_io().puts("some text")

If you’re leaning towards passing around side effecting modules to all of your code - I would reconsider

Unless you are talking about testing the actual implementations of IO, File then I can’t be of more help. But if you want to make your tests have deterministic side effects then I think the article will be helpful for you

1 Like

I can see how it could be extended, but I can also see how it could get out of hand as “bigger” functions end up having to shuffle around a bunch of arguments as their dependencies need different things.

One major challenge with this style: there’s no way for the compiler to verify calls like io.puts(line) have the right arity (or even refer to a function that exists) so you’re converting a lot of compile-time errors into runtime ones.

2 Likes

The conclusion of that thread was to try Mox, which also operates at the Module level as you propose. Can you elaborate as to what you tried with Mox and what issues you found with it?

3 Likes

My impression of Mox was that it was overly intrusive on the design of the code, and its reliance on global state made it unclear how I could run tests in parallel. If you look at their example here:
https://hexdocs.pm/mox/Mox.html
You can see the implementation is doing things like “Application.get_env(:my_app, :weather, MyApp.ExternalWeatherAPI)”. While my example does some weird things as well to make the module name dynamic, that weirdness is at least relegated to the test. The code under test, barely needs to change at all. If I really wanted to I could actually only change the signatures, so that IO comes from the parameter list rather than an import. With Mox I felt like I was doing most of the test infrastructure in the code rather than the test.

1 Like

The only “global state” it relies on is which implementation of a module to load. Modules themselves have no state so it has no effect on concurrent tests.

1 Like

Mox lets you do concurrent testing. Each expect is bound to the lifetime of the test, and processes can figure out which test they map to to get where they need to go.

Instead of passing modules around, a better choice is to assign the module to a module attribute so it will be set at compiletime. This is extremely unobtrusive and basically replaces Modulename for @modulename. If you need to use a default impl aways except for when an expect is explicit, you can use stub_with to set it.

If you are wondering about the mechanics of how concurrency works with mox, here:

4 Likes

The weirdness in your case is passing in all of the dependencies as arguments, which intrudes into not only ever function in that module, but also every function that calls those functions.

In Mox, the immediate caller of the behavior has to do a lookup of the which behavior is currently in place, but callers thereof are not affected. The “global” bit is simply which behavior implementation is configured. As @ityonemo notes, the specific functions are test specific, they are not global at all.

2 Likes

Not to pile on, but as someone who is also passionate about not allowing test specific concerns to leak into the architecture of an app, this seems like the crux of the issue. If the entire goal is to push test specific logic into the tests, then introducing the requirement that each implementation needs to be passed in the arguments seems like a step backward. The application environment seems like the natural place to store information about which modules the application should use in the current environment, especially since one of the challenges in writing elixir is already the need to pass around lots of references in arguments.

2 Likes

It is true, the approach of injecting every dependency as argument is bad practice here, however I understand where the author comes from. In languages like c# and java using the config approach like in elixir is not possible or at the very least it will make some nasty code, so there you inject dependencies in every function/class you can, then rely on some dependency injection tools to manage all this mess.

A good article about these 2 different ways of managing dependencies (even though it is not exactly 1:1 on how this works in elixir): https://www.baeldung.com/cs/dependency-injection-vs-service-locator

1 Like

The particular problem I am solving for is making sure the shared state among my fakes (analogous to mocks in the article), is sufficiently isolated that the tests can run independently without stepping on each other. If I have one implementation for test, and a different implementation for prod, I still have a problem because the test implementations are going to clobber each other’s state if run concurrently. The article you linked actually points this out “Furthermore, because mocks like the one above change modules globally, they are particularly aggravating in Elixir as changing global values means you can no longer run that part of your test suite concurrently”. Then later in that same article in the “Mocks as locals” section, it suggests instead passing in the dependency as an argument, which is exactly what I am doing in my example. So as far as I can tell, my latest idea of a solution is actually already following this articles advice.

1 Like

That is why I use a factory function, I expect I will be able to create other factory functions or parameterize existing factory functions to consolidate that kind of complexity as necessary.

That certainly is a tradeoff to balance against. Thanks for pointing it out.

No they aren’t. You keep saying this, and we keep trying to tell you that this isn’t how Mox works. Please watch the linked video, or read through the Mox docs. It’s literally bullet point 3 of Mox — Mox v1.1.0

3 Likes

The state I am worried about is things like recording side effects in IO.puts, or specifying external behavior in things like System.monotonic_time. So far this is the simplest solution I have been able to come up with to test these kinds of interactions. If I have one nondeterministic module that two other modules depend on, I don’t know how to get that nondeterminism under test coverage except by creating state to keep track of what is going on, and I don’t know how keep that state from getting clobbered during an async test without dynamically generating a namespace within which to store that state. Using dynamically named modules solved the issue of state collision, while at the same time minimizing my need to change the production code because the fake module appears to be no different from the real one from the perspective of the code that depends on it.

I think you’re missing some fundamentals of how the BEAM works:

State is not stored in namespaces either, it is stored in processes. Though it’s also not clear what you mean by namespace.

Perhaps I’m not the best person to give an eloquent answer here, but if you’re talking about the pattern of naming a GenServer after a module, it is merely a convention and is simply just a name that happens to match the module’s name—it has nothing to do with the module itself. You can call a GenServer whatever you like. If you really want to track state during tests runs then you should could could start an Agent or something for each suite. But again, state is stored in processes, not modules. Each test file runs in its own processes. They can’t touch each others’ state.

I did not know there were process dictionaries that were isolated from each other, I thought my only choice was to use a dynamically generated global name. However, even with a process dictionary, as far as I can tell, I still need state to test nondeterminism, which means I need a process to manage that state, which means I still need a dynamically generated name. Mox appears to help me create a production and test version of a module, but what I think I need is a production version and multiple test versions with their own state.

The video was interesting, as are process dictionaries, thanks for drawing my attention to it.

1 Like

That is a good argument to specify the production implementations as defaults, that way there is no intrusion into calling modules. I had taken them out of the example for simplicity’s sake, but your observation certainly justifies putting the defaults back in. As for the intrusion into functions within the module itself, yes it is more verbose, but it also makes it more obvious where nondeterminism is going on, so I suspect this will actually make it easier to reason about where to place abstraction boundaries.

Using defaults also makes the callers not affected for in the tests, and the tests are necessarily affected as they have to manage the state to keep track of the nondeterminism. It is not the modules or functions that make this troublesome, it is the need to maintain state to keep track of the nondeterminism.

You are conflating functions and state. When you set up Mox |> expect in a test you are attaching functions that will be called for the test in question, as distinct from any other tests. There is no state at all in that. If you want to add state you can choose to do so by say, spawning a genserver for each test, and then calling out to that genserver within the |> expected functions for each test. This would be 100% deterministic and isolated for every test.

I may have time later and I’ll sketch up an example of what this looks like for IO and time. You’re still approaching this from an OO world where the functions and state are bound.

1 Like

Pile on all you like, the feedback is really helping me understand and articulate the problem I am trying to solve better.

I share your passion, with the caveat that this does not apply to tests that are helping you detect that you have drawn an abstraction boundary incorrectly.

I suspect we agree, but I want to be more precise by saying I think the crux of the issue is making sure the tests are empowering us to identify and isolate sensible abstraction boundaries, and that deterministic vs nondeterministic code is the most important of all such boundaries.

Not each implementation, but certainly each implementation containing nondeterminism. I am not so sure it is a step backwards to be more explicit about where the nondeterminism lies, especially if you value “functional” programming.

That would be true if I didn’t have to contend with different tests needing different state, and if I had one implementation for prod and a separate implementation for test, but it is my impression that because of state I actually need multiple test implementations.

You would never have to pass in a reference to something that only contained pure functions.

Not every dependency, just every dependency that contains nondeterminism. I am not so sure it is bad practice to be explicit about where the nondeterminism lies, especially if you want to encourage “functional” style for most of the code. Being more explicit about nondeterminism can actually make it easier to reason about where to draw abstraction boundaries. I would hope to isolate the nondeterminism and modules that depend on it as much as possible, leaving the vast majority of the code base pure functional.

1 Like