Current section

Files

Jump to
walkman lib walkman.ex
Raw

lib/walkman.ex

defmodule Walkman do
@moduledoc """
Walkman helps you isolate your tests from the outside world.
## Getting started
```elixir
# test/support/my_module_wrapper.ex
require Walkman
Walkman.def_stub(MyModuleWrapper, for: MyModule)
```
```elixir
# config/config.exs
config :my_app, my_module: MyModule
```
```elixir
# config/test.exs
config :my_app, my_module: MyModuleWrapper
```
Now you can replace `MyModule` with `Application.get_env(:my_app, :my_module)` everywhere in your application code.
```elixir
# test/my_module_test.exs
test "MyModule" do
Walkman.use_tape "MyModule1" do
assert :ok = calls_my_module()
end
end
```
## Recording tapes
The first time you run the tests, Walkman will record test fixtures. You should commit these to your git repository. To re-record your fixtures, delete them and run the tests again. Every time you run your tests after this, Walkman will use the pre-recorded tapes.
If the implementation of a mocked module changes, any affected tests will automatically be re-recorded.
## Integration mode
To disable stubs globally, use `Walkman.set_mode(:integration)`. For example:
```elixir
# test/test_helper.ex
if System.get_env("INTEGRATION_MODE"), do: Walkman.set_mode(:integration)
```
## Concurrent tests
Concurrent tests are supported out of the box. When you use `Walkman.use_tape/3`, the tape is registered to the test process pid.
To access the tape from another process, call `Walkman.share_tape/2`.
Example:
```elixir
test "can share tape with another process" do
Walkman.use_tape "share_tape" do
test_pid = self()
spawn_link(fn ->
Walkman.share_tape(test_pid)
assert call_stub()
end)
end
end
```
If this is impractical, then making the tape global is another option. This can **not** be used with concurrent tests.
Example:
```elixir
test "can access tape globally" do
Walkman.use_tape("global tape", global: true) do
spawn_link(fn ->
assert call_stub()
end)
end
end
```
"""
@type test_options :: [
preserve_order: boolean(),
global: boolean()
]
defmacro def_stub(wrapper_module, for: module) do
quote bind_quoted: [module: module, wrapper_module: wrapper_module] do
defmodule wrapper_module do
Enum.each(module.__info__(:functions), fn {fun, arity} ->
args = Macro.generate_arguments(arity, module)
def unquote(fun)(unquote_splicing(args)) do
Walkman.call_function(unquote(module), unquote(fun), unquote(args))
end
end)
end
end
end
@doc """
Load a tape for use in a test.
```elixir
test "MyModule" do
Walkman.use_tape "MyModule" do
assert {:ok, _} = MyModule.my_function
end
end
```
"""
@spec use_tape(tape_id :: String.t(), test_options(), do: term()) :: :ok
defmacro use_tape(tape_id, test_options \\ [], do: block) do
quote do
options =
Keyword.merge(unquote(test_options),
mode: Walkman.mode(),
tape_id: unquote(tape_id),
test_pid: self()
)
{:ok, pid} =
DynamicSupervisor.start_child(
Walkman.TapeSupervisor,
Walkman.Tape.child_spec(options)
)
try do
unquote(block)
rescue
e in RuntimeError ->
:ok = GenServer.call(pid, :cancel)
else
_ ->
:ok = GenServer.call(pid, :finish)
end
end
end
@doc """
Set Walkman's mode. The default is `:normal`.
Walkman has two modes:
- `:normal` - if a tape is found, then the tape is replayed. If there is no tape, then a new one is made. This is the closest to how Ruby's VCR works.
- `:integration` - calls are passed through to the implementation but no new tapes are made. Useful for running integration tests on the CI.
"""
@spec set_mode(mode :: :normal | :integration) :: :ok
def set_mode(mode) when mode in [:normal, :integration] do
:ok = Registry.put_meta(Walkman.TapeRegistry, :mode, mode)
end
@doc """
Share the tape with another process.
Example:
```elixir
test "can access the stub from another process" do
Walkman.use_tape("share tape") do
test_pid = self()
spawn_link(fn ->
Walkman.share_tape(test_pid, self())
assert call_mock()
end)
end
end
```
"""
def share_tape(test_pid, other_pid \\ self()) do
{:ok, walkman_tape} = fetch_walkman_tape(test_pid)
:ok = GenServer.call(walkman_tape, {:share_tape, other_pid})
end
@doc false
def mode() do
case Registry.meta(Walkman.TapeRegistry, :mode) do
{:ok, :integration} ->
:integration
_ ->
case walkman_tape() do
nil ->
:normal
walkman_tape ->
GenServer.call(walkman_tape, :get_replay_mode)
end
end
end
@doc false
def call_function(mod, fun, args) do
case mode() do
:record ->
output = apply(mod, fun, args)
record({mod, fun, args}, output)
output
:replay ->
replay({mod, fun, args})
:integration ->
apply(mod, fun, args)
:normal ->
apply(mod, fun, args)
end
|> maybe_raise_error()
end
defmodule WalkmanError do
defexception [:message]
end
defp maybe_raise_error({:walkman_error, _error} = msg) do
raise %WalkmanError{message: msg}
end
defp maybe_raise_error(not_error), do: not_error
defp record({mod, fun, args}, output) do
:ok = GenServer.call(walkman_tape!(), {:record, {mod, fun, args}, output})
end
defp replay(args) do
GenServer.call(walkman_tape!(), {:replay, args})
end
defp walkman_tape() do
# Process.get("$callers") |> IO.inspect()
[self(), :global]
|> Enum.find_value(fn walkman_tape ->
case fetch_walkman_tape(walkman_tape) do
{:ok, walkman_tape} -> walkman_tape
{:error, _err} -> false
end
end)
end
defp walkman_tape!() do
case walkman_tape() do
nil -> raise RuntimeError, "could not find Walkman Tape"
walkman_tape -> walkman_tape
end
end
defp fetch_walkman_tape(test_pid) do
case Registry.lookup(Walkman.TapeRegistry, test_pid) do
[{walkman_tape, _value}] ->
{:ok, walkman_tape}
[] ->
{:error, :not_found}
end
end
end