Behaviour driven development for ExUnit with product management friendly readable scenarios.
- Minimal DSL on top of ExUnit.
- given, act, check instead of given, when, then to avoid naming collisions.
- Api can be used without macros, if necessary
- Steps are just functions, scenarios are just ExUnit tests.
- Not a full gherkin implementation, it just gives you the ability to decompose tests into reusable named steps.
- Separate DSLs for defining scenarios and implementing test steps.
- Dependency Free. Use whatever assertion, mocking and testing libraries you want.
- Html report including all test steps.
Assuming you have a BDD style scenario like this:
Scenario make coffee
Given a coffee machine
And it has 250 ml of water in its tank
And it has java coffee beans in its reservoir
When i press the "make coffee" button
Then it makes coffee
In your ExUnit test, use the Behave
module:
defmodule MyCoffeeMachineTest do
use ExUnit.Case
use Behave, steps: [MyCoffeeMachineTestSteps]
...
end
Notice we're passing :steps
to Behave
, this is the module where you define your test steps.
Then, use the Behave
DSL to implement your scenario:
defmodule MyCoffeeMachineTest do
use ExUnit.Case
use Behave, steps: [MyCoffeeMachineTestSteps]
scenario "make coffee with dsl" do
given "coffee machine"
given "it has water", amount: 250
given "it has coffee", cultivar: :java
act "i press the button"
check "it makes coffee"
end
end
Your TestSteps
modules should be defined in a directory that gets picked up by the compiler, such as test/support
.
In those modules, you need to define a step function for each test step (given, act, check).
defmodule TestSteps do
use Behave.Scenario # for access to the step definition DSL
import ExUnit.Assertions
given "coffee machine" do
{:coffee_machine, CoffeeMachine.new()}
end
...
end
The given
macro expects you to return a tuple of an atom that will be used to refer to the returned value, and the value that should be available to subsequent steps.
You can run arbitrary code before returning the tuple. If you do not need the given
steps to produce any values, any return value that is not wrapped in a tuple will be ignored.
In a given
step, you have access to the values that other givens
have emitted via the second argument. All macros come with several variants with different arities and guard clauses to access values emitted from previous steps and arguments passed in from the scenario. The arguments are always a keyword list and always the last argument.
# in scenario:
given "it has water", amount: 250
# in step definition:
given "it has water", data, amount: amount do
{:coffee_machine, CoffeeMachine.add_water(data.coffee_machine, amount)}
end
Because extracting a value, processing it and then reassigning it to the same kay is a common pattern, there is a shorthand notation for it.
If you pass in a function as the second argument of the tuple, the function will be executed, and the value of the key will be passed in, if it has been set by a prior step.
The value will be overwritten with this function's return value, similar to how update_in
works.
given "it has water", amount: amount do
{:coffee_machine, &CoffeeMachine.add_water(&1, amount)}
end
act
works the same. The main difference is that tuples returned from act
steps will be assigned to a different map than those from givens
. This is to separate the prerequisites of a test from its results, and to make comparisons between what was and what is easier.
act
can accept arguments, just as given
can.
act "i press the button", data do
{:coffee, CoffeeMachine.brew(data.coffee_machine)}
end
Note that it is not possible to overwrite values set in givens
. If you need to modify a value created by a given
, use a given
.
The passed in data is a Map
. You can also use pattern matching to extract values in a concise way:
given "it has water", %{coffee_machine: it}, amount: amount do
{:coffee_machine, CoffeeMachine.add_water(it, amount)}
end
act "i press the button", %{coffee_machine: it} do
{:coffee, CoffeeMachine.brew(it)}
end
Finally, run your assertions in a check
step:
check "it makes coffee", results do
assert results.coffee != :disappointment
end
check
s cannot store any values. Their return values are always discarded. However, they have access to both the data
and results
maps.
The values produced in the act
steps are available in a check
step through its first argument.
If you need to access the values emitted in given
, they are passed in as the second argument.
data
contains all the values set in given
steps, and results
contains the values from the act
steps.
Note that
given
andact
receivesdata
as its first argument, butcheck
receivesresults
as its first argument. This is done to make the common case less verbose to write.
check "it makes coffee, when given water and coffee", results, data do
assert results.coffee != :disappointment
assert data.coffee_machine.water_ml != 0
assert data.coffee_machine.coffee != nil
end
If you need to access data
, but not results
, just assign data
to _
:
check "there's still water and coffee in the machine after brewing", _, data do
assert data.coffee_machine.water_ml != 0
assert data.coffee_machine.coffee != nil
end
check
can also accept arguments passed in from the scenario. These are expected to be keyword lists and will always be available as the last argument.
check "it makes the right kind of coffee", results, coffee_variant: variant do
{:coffee, cup} = results.coffee
assert cup |> String.contains(variant)
end
check "after making coffee, the tank isnt empty", results, data, tolerable_water_remaining: rest_ml do
assert results.coffee != :disappointment
assert data.coffee_machine.water_ml >= rest_ml
end
The tests created by the macros are just ExUnit tests.
To run your tests, just mix test
them 🎉
Behave has a reporter for ExUnit
that generates a html report from your tests.
To use it, add it to your reporters like so
# test/test_helper.exs
Behave.Formatter.Store.start() # add
ExUnit.configure(formatters: [ExUnit.CLIFormatter, Behave.Formatter]) # add
ExUnit.start()
and add a configuration value that tells it what to name the report file:
# config/config.exs
config :behave,
report_name: "my_report_name.html"
- It is no longer necessary to return
:ignore
fromgiven
andact
. Any value returned that is not wrapped in a tuple will be discarded. - The implicitly-available
data
andresults
variables no longer exist. You now have to explicitly state that you want to access them in the arguments to a step. - If you want to access a value from an earlier
given
in agiven
step, you no longer have to use a lambda. You still can, as a shorthand, but it's no longer the only or preferred way. This should reduce the need to wrap the values emitted ingiven
steps in further maps as well as "hacky" solutions making use of lambdas to access one key and emit into another.
- find all usages of
data
andresults
, and explicitly add them to the step arguments. - Any arguments you already have there, passing in values from the scenarios, should be in the form of a keyword list and always in the last position. Passing in anything but a keyword list will not work, but the macros are smart enough to figure out if you want to access any combination of
data
,results
and scenario arguments. - Optionally, get rid of any hacks to work around not having access to
data
ingivens
. Those are still supported, but no longer necessary.
The package is available on hex.pm and can be installed
by adding behave_bdd
to your list of dependencies in mix.exs
:
def deps do
[
{:behave, "~> 0.2.0", hex: :behave_bdd}
]
end
Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/behave_bdd.