Trouble Testing named processes

This is going to a little long winded, since I’m not exactly sure how to generalize it.

basically i have a problem that has been documented before. It relates to stubbing IO and testing leaf nodes of a supervision tree. but i can’t quite find a solution that fits my needs.

so i will try to create some simplified examples here’s, then link to the actual code giving me problems to hopefully give a good gist of what’s going on.

so lets say i have an application structure as such:

defmodule SomeRootSupervisor do
   @moduledoc "The root of the application"
   use Supervisor
   
   def start_link() do
      Supervisor.start_link(__MODULE__, [], name: __MODULE__)
   end

   def init([]) do
      children = [
          supervisor(SomeChildSupervisor, [[name: SomeChildSupervisor]]
      ]
      supervise(children [strategy: :one_for_one]
   end
end

defmodule SomeChildSupervisor do
   @moduledoc "supervises some other stuff"
   
   def start_link(opts \\ []) do
      Supervisor.start_link(__MODULE__, [], opts)
   end

      def init([]) do
      children = [
          worker(SomeChildWorkerA, [[name: SomeChildWorkerA]]),
          worker(SomeChildWorkerB, [SomeChildWorkerA, [name: SomeChildWorkerB]])
      ]
      supervise(children [strategy: :one_for_one]
   end
end

defmodule SomeChildWorkerA do
   @moduledoc "Some IO server. Maybe HTTP?"
   use GenServer
   
   @doc "Do some thing"
   def some_method(worker_a), do: GenServer.call(worker_a, :some_method)

   def start_link(opts \\ []) do
      GenServer.start_link(__MODULE__, [], opts)
   end

   def handle_call(:some_method, _from, state) do
       {:reply, 1234, state}
   end
end

defmodule SomeChildWorkerB do
   @moduledoc "Consumes some behaviour of SomeWorkerA."
   use GenServer

   def some_thing(worker_a), do: GenServer.call(worker_a, :some_thing)

   def start_link(some_worker_b, opts \\ []) do
      GenServer.start_link(__MODULE__, some_worker_b, opts)
   end

   def handle_call(:some_thing, _from, some_worker_b) do
       reply = SomeWorkerB.some_method(some_worker_b) + 1
      {:reply, reply, some_worker_b}
   end
end

That is all pretty simple and easy. SomeChildWorkerB can call SomeChildWorkerA, and if A is really resource intensive, we can mock it in test environment as such:

defmodule SomeChildWorkerBTest do
   use ExUnit.Case

   test "some functionality" do
       # This process implements the same behaviour that the original does.
      {:ok, some_stub_process} = TestHelperStubThing.start_link()
      {:ok, worker} = SomeChildWorkerB.start_link(some_stub_process, [])
      assert SomeChildWorkerB.some_thing(worker) == 2
   end
end

so in the test env the main application starts. SomeRootSupervisor.start_link() which fires off all the initialization, and everything is registered by name.
then i can still test as i did above by starting it with out a name. and therefor in isolation from the rest of the system.

but then when i want to add some other module that consumes SomeChildWorkerA (say in the case of HTTP) i have to pass in its name there. It doesn’t seem that bad when its only one thing, but then when you are having to pass in 3+ names or pids because a module requires 3 other utilities it gets really messy, just to be able to test things independently.

the other problem ive found is more interesting.

in my application we have a transport system with a set of RPC’s (among other things). the actual transport layer doesn’t matter much (its mqtt for those who are interested)

but essentially what happens is i have a Transport.Supervisor it can start any transports that will communicate with the outside world. (only mqtt is relevant for simplicity but there are technically others)
Here is a link to how the transports are started.
https://github.com/ConnorRigby/farmbot_os/blob/purge-context/lib/farmbot/bot_state/transport/supervisor.ex

It starts them with a login token (from our rest api) and a pid of the instance of applications state server.
this state server is easily stubbed out, (since its only one process) but a problem arises where the transport consumes data that acts on the entire system.

so a message will come in (over the mqtt transport) as json: {kind: "do_some_functionality", args: {some_arg: 1}}

then i have handlers for each functionality so it will get called as such:
DoSomeFunctionality.eval(some_arg: 1)

and that is no problem.

but things that require other stuff like this fake message: {kind: "act_on_database_entry", args: {id: 123}}

where it will execute ActOnDatabaseEntry.eval(id: 123)

now lets get that implemented

defmodule ActOnDatabaseEntry do
@behaviour RPC

   def eval(args) do
      id = Keyword.fetch!(args, :id)
      db_entry = SomeDatabaseImplementation.lookup(some_running_db_proc_name_or_pid, id)
      res = SomeAction.do_work(some_running_action_procc_name_or_pid, db_entry)
     {:reply, res}
   end
end

now there is a problem where i need a name or pid for SomeDatabaseImplementation and SomeAction to magically show up in this code without having to track them from the transport layer.

of course i could just use the global implementation by calling SomeDatabaseImplementation.lookup(SomeDatabaseImplementation, id)

But then i can’t test this RPC module in isolation without doing something that seems a little hacky as such:

defmodule ActOnDatabaseEntryTest do
   @moduledoc "Tests the ActOnDatabaseEntry RPC"
   use ExUnit.Case

   test "does testy stuff" do
      {:ok, db} = TestUtil.SomeMockDBImpl.start_link()
      Process.register(db, SomeDatabaseImplementation)
      {:ok, som_ac} = TestUtil.SomeMockAction.start_link()
      Process.register(some_ac, SomeAction)
      res = ActOnDatabaseEntry.eval(id: 1)
      assert match?({:reply, 1234}, res)
   end
   
end

but that all seems really tedious.
And in this particular instance, it only seems like im testing wiring, not implementation.

Maybe i am just going about handling this problem in a strange way and am missing something.
I know of nameing modules as {:via, SomeRegistry} (that may not be the correct syntax) but i don’t know if that is what i want either. Any help would be appreciated.

1 Like

I apologize if I’m misunderstanding you but it seems to me two things might help you:

IMO most of the time you should test wiring. Implementation as well but in other test files. Using configuration to vary implementations as outlined in Jose’s article should help.

1 Like