EDIT: I came up with a even better way to debug Elixir code than using iex
, check it here.
Interactive Driven Development, or REPL Driven Development is a really good way to approach programming, specially for developers that get distracted easily with time being expend out the main development loop or flow.
A way to do it in Elixir is just to use the fswatch
as presented in Elixir Mix Test documentation, or using the excellent mix.test-watch library, but them don't work well with iex
, the Elixir REPL, so why not do something else.
So I created some modules that can be imported on your iex
through the ~/.iex.exs
file and get the same benefits.
Just insert it on your ~/.iex.exs
file:
Code.compiler_options(ignore_module_conflict: true) | |
Code.compile_file("~/.iex/iex_watch_tests.exs", File.cwd!()) | |
unless GenServer.whereis(IExWatchTests) do | |
{:ok, pid} = IExWatchTests.start_link() | |
# Process will not exit when the iex goes out | |
Process.unlink(pid) | |
end | |
import IExWatchTests.Helpers |
And create a ~/.iex
directory, placing this file on it:
defmodule IExWatchTests do | |
use GenServer | |
@moduledoc """ | |
This utility allows to get the same effect of using the | |
`fcwatch | mix test --stale --listen-on-stdin`, but with | |
more controll and without the starting time penalty. | |
The best way to use it is to place it on a directory under | |
the `~/.iex/` directory and in the `~/.iex` file itself add: | |
``` | |
Code.compiler_options(ignore_module_conflict: true) | |
Code.compile_file("~/.iex/iex_watch_tests.exs", File.cwd!()) | |
unless GenServer.whereis(IExWatchTests) do | |
{:ok, pid} = IExWatchTests.start_link() | |
# Process will not exit when the iex goes out | |
Process.unlink(pid) | |
end | |
import IExWatchTests.Helpers | |
``` | |
To call `iex` just do: | |
``` | |
ELIXIR_ERL_OPTIONS="-pa $HOME/.iex" MIX_ENV=test iex -S mix | |
``` | |
The `IExWatchTests.Helpers` allows to call `f` and `s` and `a` | |
to run failed, stale and all tests respectively. | |
You can call `w` to watch tests and `uw` to unwatch. | |
Theres is a really simple throttle mecanism that disallow run the suite concurrently. | |
""" | |
defmodule Helpers do | |
defdelegate a, to: IExWatchTests, as: :run_all_tests | |
defdelegate f, to: IExWatchTests, as: :run_failed_tests | |
defdelegate s, to: IExWatchTests, as: :run_stale_tests | |
defdelegate w, to: IExWatchTests, as: :watch_tests | |
defdelegate uw, to: IExWatchTests, as: :unwatch_tests | |
end | |
defmodule Observer do | |
use GenServer | |
@impl true | |
def init(opts) do | |
{:ok, opts} | |
end | |
@impl true | |
def handle_cast({:suite_finished, _times_us}, config) do | |
IExWatchTests.unlock() | |
{:noreply, config} | |
end | |
@impl true | |
def handle_cast(_, config) do | |
{:noreply, config} | |
end | |
end | |
def start_link do | |
GenServer.start_link(__MODULE__, %{watcher: nil, lock: false}, name: __MODULE__) | |
end | |
def watch_tests do | |
GenServer.cast(__MODULE__, :watch_tests) | |
end | |
def unwatch_tests do | |
GenServer.cast(__MODULE__, :unwatch_tests) | |
end | |
def run_all_tests do | |
GenServer.call(__MODULE__, {:run, :all}, :infinity) | |
end | |
def run_failed_tests do | |
GenServer.call(__MODULE__, {:run, :failed}, :infinity) | |
end | |
def run_stale_tests do | |
GenServer.call(__MODULE__, {:run, :stale}, :infinity) | |
end | |
def unlock do | |
GenServer.cast(__MODULE__, :unlock) | |
end | |
@impl true | |
def init(state) do | |
Process.flag(:trap_exit, true) | |
ExUnit.start(autorun: false, formatters: [ExUnit.CLIFormatter, IExWatchTests.Observer]) | |
{:ok, state} | |
end | |
@impl true | |
def handle_cast(:watch_tests, state) do | |
{:ok, pid} = | |
Task.start(fn -> | |
cmd = "fswatch lib test" | |
port = Port.open({:spawn, cmd}, [:binary, :exit_status]) | |
watch_loop(port) | |
end) | |
{:noreply, %{state | watcher: pid}} | |
end | |
@impl true | |
def handle_cast(:unwatch_tests, %{watcher: pid} = state) do | |
if is_nil(pid) or not Process.alive?(pid) do | |
IO.puts("Watcher not running!") | |
else | |
Process.exit(pid, :kill) | |
end | |
{:noreply, %{state | watcher: nil}} | |
end | |
@impl true | |
def handle_cast({:run, mode}, %{lock: false} = state) do | |
do_run_tests(mode) | |
{:noreply, %{state | lock: true}} | |
end | |
@impl true | |
def handle_cast({:run, _mode}, %{lock: true} = state) do | |
{:noreply, state} | |
end | |
@impl true | |
def handle_cast(:unlock, state) do | |
{:noreply, %{state | lock: false}} | |
end | |
@impl true | |
def handle_call({:run, _mode}, _from, %{lock: true} = state) do | |
{:reply, :locked, state} | |
end | |
@impl true | |
def handle_call({:run, mode}, _from, %{lock: false} = state) do | |
do_run_tests(mode) | |
{:reply, :ok, %{state | lock: true}} | |
end | |
@impl true | |
def handle_info(_msg, state) do | |
{:noreply, state} | |
end | |
defp watch_loop(port) do | |
receive do | |
{^port, {:data, _msg}} -> | |
GenServer.cast(__MODULE__, {:run, :stale}) | |
watch_loop(port) | |
end | |
end | |
defp do_run_tests(mode) do | |
IEx.Helpers.recompile() | |
# Reset config | |
ExUnit.configure( | |
exclude: [], | |
include: [], | |
only_test_ids: nil | |
) | |
Code.required_files() | |
|> Enum.filter(&String.ends_with?(&1, "_test.exs")) | |
|> Code.unrequire_files() | |
args = | |
case mode do | |
:all -> | |
[] | |
:failed -> | |
["--failed"] | |
:stale -> | |
["--stale"] | |
end | |
result = | |
ExUnit.CaptureIO.capture_io(fn -> | |
Mix.Tasks.Test.run(args) | |
end) | |
if result =~ ~r/No stale tests/ or | |
result =~ ~r/There are no tests to run/ do | |
IExWatchTests.unlock() | |
end | |
IO.puts(result) | |
end | |
end |
There is an explanition about how to use it on the @moduledoc
, but briefly you just need to call iex
prepending the ~/.iex
directory on the Elixir path:
ELIXIR_ERL_OPTIONS="-pa $HOME/.iex" MIX_ENV=test iex -S mix
The IExWatchTests.Helpers
that is imported on ~/.iex.exs
allows to call f
and s
and a
to run failed, stale and all tests respectively.
You can call w
to watch tests and uw
to unwatch.
There is a really simple throttle mechanism that disallow run the suite concurrently.
That is all. Enjoy!
Top comments (4)
Nice tip! Any idea on how to make
Mix.Tasks.Test.run([])
to work on umbrella projects? Mine always outputThere are no tests to run
I need to figure out, but I think that must be simple.
For some reason, I also can't
import IExWatchTests.Helpers
from.iex.exs
But if I comment this line and run it from iex, it works!
Yes, I wrote another post explaining why I concluded that this approach is less than ideal, because there is a lot of limitation on what you can do in
.iex.exs
. For example, there is a IEx.Helpers.import_if_available/2 to deal with it.