How to Test with the CallDirect Strategy

Copy Markdown View Source

When testing code that uses RpcLoadBalancer, you typically don't have a multi-node cluster available. The CallDirect selection algorithm solves this by executing calls locally via apply/3 instead of going through :erpc.

Configure your load balancer for tests

Pass SelectionAlgorithm.CallDirect as the selection algorithm when starting a load balancer in your test setup:

alias RpcLoadBalancer.LoadBalancer.SelectionAlgorithm

{:ok, _pid} =
  RpcLoadBalancer.start_link(
    name: :my_balancer,
    selection_algorithm: SelectionAlgorithm.CallDirect
  )

With CallDirect active:

  • call/5 with load_balancer: :name executes apply(module, fun, args) and returns {:ok, result}
  • cast/5 with load_balancer: :name executes spawn(module, fun, args) and returns :ok
  • No :erpc calls are made
  • No cluster nodes are required

Use it in ExUnit setup

A typical test module that depends on a load balancer:

defmodule MyApp.WorkerTest do
  use ExUnit.Case, async: true

  alias RpcLoadBalancer.LoadBalancer.SelectionAlgorithm

  setup do
    lb_name = :"test_lb_#{System.unique_integer([:positive])}"

    {:ok, _pid} =
      RpcLoadBalancer.start_link(
        name: lb_name,
        selection_algorithm: SelectionAlgorithm.CallDirect
      )

    Process.sleep(50)

    %{lb_name: lb_name}
  end

  test "call executes the function", %{lb_name: lb_name} do
    assert {:ok, 42} ===
             RpcLoadBalancer.call(node(), Kernel, :+, [40, 2], load_balancer: lb_name)
  end

  test "cast fires asynchronously", %{lb_name: lb_name} do
    test_pid = self()

    assert :ok ===
             RpcLoadBalancer.cast(
               node(),
               Kernel,
               :apply,
               [fn -> send(test_pid, :done) end, []],
               load_balancer: lb_name
             )

    assert_receive :done, 1000
  end
end

Application-level configuration

If your application starts a load balancer in its supervision tree, you can switch the algorithm based on the compile-time environment:

defmodule MyApp.Application do
  use Application

  @selection_algorithm if Mix.env() === :test,
                         do: RpcLoadBalancer.LoadBalancer.SelectionAlgorithm.CallDirect,
                         else: RpcLoadBalancer.LoadBalancer.SelectionAlgorithm.RoundRobin

  @impl true
  def start(_type, _args) do
    children = [
      {RpcLoadBalancer,
       name: :my_balancer,
       selection_algorithm: @selection_algorithm}
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end

This uses a module attribute evaluated at compile time, which avoids calling Mix.env() at runtime (where it doesn't exist in releases).

Why CallDirect should always be used in tests

  • No cluster required — tests run on a single node, so :erpc calls to remote nodes will fail with {:error, %ErrorMessage{code: :service_unavailable}}
  • Deterministicapply/3 runs synchronously in the calling process, making assertions straightforward
  • Fast — skips :erpc serialization and the :pg member lookup entirely
  • Isolated — each test can start its own load balancer with a unique name without interfering with other tests