SeanShubin

SeanShubin

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

Showing Posts 1 to 10

JohnnyCurran

JohnnyCurran

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

al2o3cr

al2o3cr

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.

benwilson512

benwilson512

Author of Craft GraphQL APIs in Elixir with Absinthe

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?

SeanShubin

SeanShubin OP

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:

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.

sodapopcan

sodapopcan

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.

ityonemo

ityonemo

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:

benwilson512

benwilson512

Author of Craft GraphQL APIs in Elixir with Absinthe

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.

tfwright

tfwright

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.

D4no0

D4no0

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

SeanShubin

SeanShubin OP

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.

Where Next? Top

Trending in Discussions Top

AstonJ
As the title says, please share what you’ve been up to with Elixir. Whether that’s been learning it, looking into it, making stuff with i...
2977 94592 917
New
cblavier
Hey there, It’s been more than a year since we started using LiveView as our main UI library and building a whole library of UI componen...
New
mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
heathen
Quite interesting article Google brought me. Didn’t find any mentions about it here. What do you think in general? Would you use togethe...
New
mhanberg
Hi everyone! The first release candidate for the Expert language server project is now available! We’ve published a press release detai...
New
AstonJ
Since we have deprecated our Erlang sections (as we have dedicated Erlang Forums now) let’s add this thread for those who’d like to post ...
New
maennchen
:warning: Security advisory: Decimal DoS vulnerability A vulnerability has been published for decimal where very large exponents can cau...
New

Other Trending Topics Top

marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New
webofbits
With AI doing more of the implementation work, I’ve been wondering how much coding I should deliberately keep doing myself. My main conc...
#ai
New
webofbits
Aludel - LLM Evaluation Workbench Aludel is an embeddable Phoenix LiveView dashboard for evaluating and comparing LLM prompts across mult...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews