Current section
Files
Jump to
Current section
Files
lib/clustered_case.ex
defmodule ExUnit.ClusteredCase do
@moduledoc """
Helpers for defining clustered test cases.
Use this in place of `ExUnit.Case` when defining test modules where
you will be defining clustered tests. `#{__MODULE__}` extends
`ExUnit.Case` to provide additional helpers for those tests.
Since `#{__MODULE__}` is an extension of `ExUnit.Case`, it takes the
same options, and imports the same test helpers and callbacks. It adds
new helpers, `scenario/3`, `node_setup/1`, and `node_setup/2`, and aliases
the `#{__MODULE__.Cluster}` module for convenience.
## Examples
defmodule KVStoreTests do
# Use the module
use ExUnit.ClusteredCase
# Define a clustered scenario
scenario "given a healthy cluster", [cluster_size: 3] do
# Set up each node in the cluster prior to each test
node_setup do
{:ok, _} = Application.ensure_all_started(:kv_store)
end
# Define a test to run in this scenario
test "always pass" do
assert true
end
end
end
## Context
All tests receive a context as an argument, just like with `ExUnit.Case`, the
primary difference to be aware of is that the context contains a key, `:cluster`,
which is the pid of the cluster manager, and is used to invoke functions in the
`#{__MODULE__}.Cluster` module during tests.
defmodule KVStoreTests do
use ExUnit.ClusteredCase
scenario "given a healthy cluster", [cluster_size: 3] do
# You can use normal setup functions to setup context for the
# test, this is run once prior to each test
setup do
{:ok, foo: :bar}
end
# Like `setup`, but is run on all nodes prior to each test
node_setup do
{:ok, _} = Application.ensure_all_started(:kv_store)
end
test "cluster has three nodes", %{cluster: c} = context do
assert length(Cluster.members(c)) == 3
end
end
end
See the `ExUnit.Case` documentation for information on tags, filters, and more.
"""
@doc false
defmacro __using__(opts \\ []) do
quote do
@__clustered_case_scenario nil
use ExUnit.Case, unquote(opts)
alias unquote(__MODULE__).Cluster
import unquote(__MODULE__), only: [scenario: 3, node_setup: 1, node_setup: 2]
setup_all do
on_exit(fn ->
unquote(__MODULE__).Cluster.Supervisor.cleanup_clusters_for_test_module(__MODULE__)
end)
end
end
end
@doc """
Creates a new clustered test scenario.
Usage of this macro is similar to that of `ExUnit.Case.describe/2`,
but has some differences. While `describe/2` simply groups tests under a
common description, `scenario/3` both describes the group of tests, and
initializes a cluster which will be made available for each test in that scenario.
NOTE: It is important to be aware that each scenario is a distinct cluster,
and that all tests within a single scenario are running against the same
cluster. If tests within a scenario may conflict with one another - perhaps by
modifying shared state, or triggering crashes which may bring down shared
processes, etc., then you have a couple options:
- Disable async testing for the module
- Modify your tests to prevent conflict, e.g. writing to different keys
in a k/v store, rather than the same key
- Split the scenario into many, where the tests can run in isolation.
## Options
You can configure a scenario with the following options:
- `cluster_size: integer`, will create a cluster of the given size, this option is mutually
exclusive with `:nodes`, if the latter is used, this option will be ignored.
- `nodes: [[node_opt]]`, a list of node specifications to use when creating the cluster,
see `t:#{__MODULE__}.Node.node_opt/0` for specific options available. If used,
`:cluster_size` is ignored.
- `env: [{String.t, String.t}]`, will set the given key/values in the environment
when creating nodes. If you need different values for each node, you will need to use `:nodes`
- `erl_flags: [String.t]`, additional arguments to pass to `erl` when creating nodes, like `:env`,
if you need different args for each node, you will need to use `:nodes`
- `config: Keyword.t`, configuration overrides to apply to all nodes in the cluster
- `boot_timeout: integer`, the amount of time to allow for nodes to boot, in milliseconds
- `init_timeout: integer`, the amount of time to allow for nodes to be initialized, in milliseconds
## Examples
defmodule KVStoreTest do
use ExUnit.ClusteredCase
@scenario_opts [cluster_size: 3]
scenario "given a healthy cluster", @scenario_opts do
node_setup do
{:ok, _} = Application.ensure_all_started(:kv_store)
end
test "writes are replicated to all nodes", %{cluster: cluster} do
writer = Cluster.random_member(cluster)
key = self()
value = key
assert Cluster.call(writer, KVStore, :put, [key, value]) == :ok
results = Cluster.map(cluster, KVStore, :get, [key])
assert Enum.all?(results, fn val -> val == value end)
end
end
end
Since all scenarios are also describes, you can run all the tests for a
scenario by it's description:
mix test --only describe:"given a healthy cluster"
or by passing the exact line the scenario starts on:
mix test path/to/file:123
Like `describe/2`, you cannot nest `scenario/3`. Use the same technique
of named setups recommended in the `describe/2` documentation for composition.
"""
defmacro scenario(message, options, do: block) do
quote do
if @__clustered_case_scenario do
raise "cannot call scenario/2 inside another scenario. See the documentation " <>
"for scenario/2 on named setups and how to handle hierarchies"
end
message = unquote(message)
options = unquote(options)
@__clustered_case_scenario message
@__clustered_case_scenario_config options
try do
describe message do
setup context do
alias unquote(__MODULE__).Cluster.Supervisor, as: CS
# Start cluster if not started
{:ok, cluster} =
CS.init_cluster_for_scenario!(
__MODULE__,
@__clustered_case_scenario,
@__clustered_case_scenario_config
)
Map.put(context, :cluster, cluster)
end
unquote(block)
end
after
@__clustered_case_scenario nil
@__clustered_case_scenario_config nil
end
end
end
@doc """
Like `ExUnit.Callbacks.setup/1`, but is executed on every node in the cluster.
You can pass a block, a unary function as an atom, or a list of such atoms.
If you pass a unary function, it receives the test setup context, however unlike
`setup/1`, the value returned from this function does not modify the context. Use
`setup/1` or `setup/2` for that.
NOTE: This callback is invoked _on_ each node in the cluster for the given scenario.
## Examples
def start_apps(_context) do
{:ok, _} = Application.ensure_all_started(:kv_store)
:ok
end
scenario "given a healthy cluster", [cluster_size: 3] do
node_setup :start_apps
node_setup do
# This form is also acceptable
{:ok, _} = Application.ensure_all_started(:kv_store)
end
end
"""
defmacro node_setup(do: block) do
quote do
setup %{cluster: cluster} = context do
results =
unquote(__MODULE__).Cluster.map(cluster, fn ->
unquote(block)
end)
case results do
{:error, _} = err ->
exit(err)
_ ->
:ok
end
context
end
end
end
defmacro node_setup(callback) when is_atom(callback) do
quote do
setup %{cluster: cluster} = context do
results =
unquote(__MODULE__).Cluster.map(cluster, __MODULE__, unquote(callback), [context])
case results do
{:error, _} = err ->
exit(err)
_ ->
:ok
end
context
end
end
end
defmacro node_setup(callbacks) when is_list(callbacks) do
quote bind_quoted: [callbacks: callbacks] do
for cb <- callbacks do
unless is_atom(cb) do
raise ArgumentError, "expected list of callbacks as atoms, but got: #{callbacks}"
end
node_setup(cb)
end
end
end
@doc """
Same as `node_setup/1`, but receives the test setup context as a parameter.
## Examples
scenario "given a healthy cluster", [cluster_size: 3] do
node_setup _context do
# Do something on each node
end
end
"""
defmacro node_setup(var, do: block) do
quote do
setup %{cluster: cluster} = context do
result =
unquote(__MODULE__).Cluster.each(cluster, fn ->
case context do
unquote(var) ->
unquote(block)
end
end)
case result do
{:error, _} = err ->
exit(err)
_ ->
:ok
end
context
end
end
end
end