Bex - set of mix tasks to help dealing with behaviours and mocks

The goal of this library is to make planting better testing into a source code
as smoothly and fluently as possible. It’s barely needed for experienced developers,
but I always find myself struggling to recall all the places in the source code
I have to amend to convert a bare call into a behaviour-baked implementation.

The main task Mix.Tasks.Bex.Generate would do the following things:

  • generate behaviour code for the function(s) given as an argument
  • generate the default implementation for it, wrapping the call to the original code and [optionally] adding :telemetry events in the recommended telemetry.span/3 flavored manner
  • find all the occurrences of the behaviourized call(s) in the source code and patch them in-place (unless --no-patch flag is given)
  • generate test(s) for the aforementioned functions, with proper Mox allowances (unless --no-test flag is given)
  • prompt to amend config/config.exs file to use correct implementations in different environments

More at bex v0.2.0 — Documentation

3 Likes

I now it could be lengthy, but it would be nice to see an example of changes that will be introduced.

2 Likes
git clone git@github.com:am-kantox/bex
cd bex
mix deps.get
mix compile

This is the only file in the sources having Process.send_after/4 calls.

cat test/support/source.ex
defmodule Bex.Test.Process do
  @moduledoc false

  alias Elixir.Process, as: P1
  alias Process, as: P2
  alias Task.Supervisor

  def schedule_without_as do
    Supervisor.start_link([])
  end

  def schedule(interval) do
    Process.send_after(self(), :schedule, interval, [])
  end

  def schedule_with_fq_alias do
    P1.send_after(self(), :schedule, 1_000, [])
  end

  def schedule_with_alias do
    P2.send_after(self(), :schedule, 1_000, [])
  end
end

Task invocation with all the defaults.

mix bex.generate --function Process.send_after/4

* creating lib/bex/process.ex
✓ Process has been created in lib/bex/process.ex
✓ Suggested change: test/support/source.ex:15 in Bex.Test.Process.schedule(interval)
- Process.send_after(self(), :schedule, interval, [])
+ Bex.Behaviours.Process.send_after(self(), :schedule, interval, [], __ENV__)
✓ Suggested change: test/support/source.ex:19 in Bex.Test.Process.schedule_with_fq_alias()
- P1.send_after(self(), :schedule, 1_000, [])
+ Bex.Behaviours.Process.send_after(self(), :schedule, 1_000, [], __ENV__)
✓ Suggested change: test/support/source.ex:23 in Bex.Test.Process.schedule_with_alias()
- P2.send_after(self(), :schedule, 1_000, [])
+ Bex.Behaviours.Process.send_after(self(), :schedule, 1_000, [], __ENV__)
Apply changes to ‹test/support/source.ex›? [Yn] 
✓ File test/support/source.ex amended successfully
* creating test/bex/process_test.exs
✓ Bex.Behaviours.Process.Mox.Test has been created in test/bex/process_test.exs
Patch ‹config/config.exs›? [Yn] 
✓ config/config.exs has been altered
git diff
diff --git a/test/support/source.ex b/test/support/source.ex
index 620aab9..c3d0829 100644
--- a/test/support/source.ex
+++ b/test/support/source.ex
@@ -12,14 +12,14 @@ defmodule Bex.Test.Process do
   end
 
   def schedule(interval) do
-    Process.send_after(self(), :schedule, interval, [])
+    Bex.Behaviours.Process.send_after(self(), :schedule, interval, [], __ENV__)
   end
 
   def schedule_with_fq_alias do
-    P1.send_after(self(), :schedule, 1_000, [])
+    Bex.Behaviours.Process.send_after(self(), :schedule, 1_000, [], __ENV__)
   end
 
   def schedule_with_alias do
-    P2.send_after(self(), :schedule, 1_000, [])
+    Bex.Behaviours.Process.send_after(self(), :schedule, 1_000, [], __ENV__)
   end
-end
+end

The above is the only diff. Generated:

# lib/bex/process.ex
defmodule Bex.Behaviours.Process do
  @moduledoc false

  _ = """
  Behaviour wrapping Process
  """

  @doc false
  @callback send_after(arg_1 :: any(), arg_2 :: any(), arg_3 :: any(), arg_4 :: any()) :: any()

  # default implementation
  @actual_impls Application.compile_env(:bex, :impls, %{})
  @actual_impl Map.get(@actual_impls, Process, Bex.Behaviours.Impls.Process)

  @doc false
  defdelegate send_after(arg_1, arg_2, arg_3, arg_4), to: @actual_impl

  @doc false
  def send_after(arg_1, arg_2, arg_3, arg_4, %Macro.Env{} = env) do
    event_prefix = Bex.telemetry_event_base(env)

    :telemetry.execute(
      event_prefix ++ [:start],
      Bex.telemetry_measurements_base(%{}),
      %{
        arg_1: arg_1,
        arg_2: arg_2,
        arg_3: arg_3,
        arg_4: arg_4
      }
    )

    result =
      send_after(arg_1, arg_2, arg_3, arg_4)

    :telemetry.execute(
      event_prefix ++ [:stop],
      Bex.telemetry_measurements_base(%{}),
      %{result: result}
    )

    result
  end
end

defmodule Bex.Behaviours.Impls.Process do
  @moduledoc false

  _ = """
  Default implementation for the behaviour wrapping Process
  """

  @behaviour Bex.Behaviours.Process

  @impl Bex.Behaviours.Process
  def send_after(arg_1, arg_2, arg_3, arg_4) do
    Process.send_after(arg_1, arg_2, arg_3, arg_4)
  end
end

case {Code.ensure_compiled(Mox), Mix.env()} do
  {{:module, Mox}, :test} ->
    Bex.Behaviours.Process
    |> Module.concat(Mox)
    |> Mox.defmock(for: Bex.Behaviours.Process)

  {_, :test} ->
    IO.warn("Please add `mox` to deps in `test` env")

  _ ->
    :ok
end
# test/bex/process_test.ex
defmodule Bex.Behaviours.Process.Mox.Test do
  use ExUnit.Case, async: true

  import Mox

  test "Bex.Test.Process.schedule/1" do
    test_process = self()

    Bex.Behaviours.Process.Mox
    |> expect(:send_after, 1, fn _arg_1, _arg_2, _arg_3, _arg_4 ->
      send(test_process, {:schedule, 4})
    end)

    Bex.Test.Process.schedule(nil)
    assert_receive({:schedule, 4})
  end

  test "Bex.Test.Process.schedule_with_fq_alias/0" do
    test_process = self()

    Bex.Behaviours.Process.Mox
    |> expect(:send_after, 1, fn _arg_1, _arg_2, _arg_3, _arg_4 ->
      send(test_process, {:schedule_with_fq_alias, 4})
    end)

    Bex.Test.Process.schedule_with_fq_alias()
    assert_receive({:schedule_with_fq_alias, 4})
  end

  test "Bex.Test.Process.schedule_with_alias/0" do
    test_process = self()

    Bex.Behaviours.Process.Mox
    |> expect(:send_after, 1, fn _arg_1, _arg_2, _arg_3, _arg_4 ->
      send(test_process, {:schedule_with_alias, 4})
    end)

    Bex.Test.Process.schedule_with_alias()
    assert_receive({:schedule_with_alias, 4})
  end
end

Patched/generated:

# config/config.exs
import Config

config :bex,
       :impls,
       (if Mix.env() == :test do
          %{Process => Bex.Behaviours.Process.Mox}
        else
          %{Process => Bex.Behaviours.Impls.Process}
        end)
2 Likes

Pretty nice, and impressive, but is there no way to name the function arguments by extracting them from the function definition somehow (sorry, not a pro in Erlang/Elixir meta-programming)?

I for one wouldn’t want my telemetry to have arg_1 and arg_2 etc. as parameters in the spans.

1 Like

There are some pitfalls.

  • the function might have no named arguments
  • app source code is on hand, deps are too, to some extent, but functions defined in elixir core would require downloading and parsing the elixir sources
  • Erlang modules should also be treated differently.

In most cases parsing beam files should do though, and this feature is on my short-list.

1 Like

Meanwhile you are welcome to edit the generated code :slight_smile:

1 Like

Actually yeah, the most obvious solution eluded me. :003:

Thanks for pointing it out!

defmodule Bex.Behaviours.Process do
  […]
  @callback send_after(dest :: any(), msg :: any(), time :: any(), opts :: any()) :: any()

  […]
  defdelegate send_after(dest, msg, time, opts), to: @actual_impl

  def send_after(dest, msg, time, opts, %Macro.Env{} = env) do
    event_prefix = Bex.telemetry_event_base(env)

    :telemetry.execute(
      event_prefix ++ [:start],
      Bex.telemetry_measurements_base(%{}),
      %{
        dest: dest,
        msg: msg,
        time: time,
        opts: opts
      }
    )

    result = send_after(dest, msg, time, opts)

    :telemetry.execute(
      event_prefix ++ [:stop],
      Bex.telemetry_measurements_base(%{}),
      %{result: result}
    )

    result
  end
end

I have released v0.3.0 which addresses naming in generated functions and/or :telemetry via Code.fetch_docs/1. Spec is what still bugs me, because I don’t yet see a legit non-hacky way to get to specs (although dialyzer does that and hence that’s possible :slight_smile:

2 Likes

✓ proper typespec inherited from the original function

Package published to bex | Hex
(786bc96c074d4f06b10861610c26114e4f0fbdf33a6e4bd4223e8fced3f37676)

1 Like