mudasobwa
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
:telemetryevents in the recommendedtelemetry.span/3flavored manner - find all the occurrences of the behaviourized call(s) in the source code and patch them in-place (unless
--no-patchflag is given) - generate test(s) for the aforementioned functions, with proper
Moxallowances (unless--no-testflag is given) - prompt to amend
config/config.exsfile to use correct implementations in different environments
More at bex v0.2.0 — Documentation
Most Liked
astery
I now it could be lengthy, but it would be nice to see an example of changes that will be introduced.
mudasobwa
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)
mudasobwa
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 ![]()
dimitarvp
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.
mudasobwa
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.







