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.