BDD/TDD with Phoenix
Setting up White bread
White bread is a BDD tool for Elixir. The tool takes as input a specification written in Gherkin, which is in turn the most popular language for BDD, and which is used by other tools including cucumber. Unfortunately, the tool is under development and lacks for some features to make it comparable with tools such as cucumber.
Let us start by adding White bread to our project. To that end, we have to add the following two lines (which are not contiguous though) within the file mix.exs
.
def project do
[
app: :tacsi,
...
preferred_cli_env: ["white_bread.run": :test],
...
]
end
defp deps do
[
...
{:white_bread, "~> 4.5", only: [:test]}
]
end
As we are adding a new dependency, we would need run the command mix deps.get
.
We are going to start working with the following user story. Create a folder features
in your project, add a new file called features/taxi_booking.feature
and copy there the following snippet.
Feature: Taxi booking
As a customer
Such that I go to destination
I want to arrange a taxi ride
Scenario: Booking via STRS' web page (with confirmation)
Given the following taxis are on duty
| username | location | status |
| juhan85 | Angelópolis | busy |
| peeter88 | Angelópolis | available |
And I want to go from "Atlixcáyotl 5718" to "Plaza Dorada"
And I open STRS' web page
And I enter the booking information
When I summit the booking request
Then I should receive a confirmation message
You may find interesting to add a plugin to your code with the support for gherkin. In my case, I chose the extension Cucumber (Gherkin) Syntax and Snippets by Steve Purves that you can easily find via the extension manager of Visual Studio Code.
The specification above is written using the Gherkin language. As you can see, Gherkin is a nice specification language, because it allow us to use plain English. In fact, most of the sentences are just used for matching using regular expressions in elixir code. What is important though is to follow the conventions:
- The specification starts with the
Feature:
, which is followed by a title is remains opaque to the tool - The text the user story is not considered directly by the tool: it is mostly to encorage the use of the so-called well formed user story (the one that includes the role, the goal and the underlying benefits)
- The specification then includes one or more scenario, each one prefixed by
Scenario:
which is followed by its corresponding title. Each scenario, in turn, is composed of three parts:- The antecedents, preceded by the keyword
Given
- The actions, preceded by the keyword
When
- The consequences, preceded by the keyword
Then
- The antecedents, preceded by the keyword
Note that there might be several antecedents, actions or consequences. In those cases, the prefix keyword can be replaced with a keyword
And
with the aim of improving the readability of the specification.
With every thing in place, we can already start the BDD/TDD cycle. In your terminal window, run the command mix white_bread.run
. Of course, the tool will report a problem because we only have the spec and we need to add the code to implement an acceptance test. You will notice that White bread would find out that you do not have any code connected with the acceptance test and, hence, it will generate the default files to hold the code. Accept the proposed code generation and proceed.
Pay attention to the last part of the feedback provided by White bread. You will notice that it includes a snippet as the one shown below:
given_ ~r/^the following taxis are on duty$/, fn state ->
{:ok, state}
end
This is the code that we will use to wrap the acceptance test. Copy the code above into the file features/contexts/white_bread_context.exs
. White bread provides a DSL with functions such as given_/2
that correspond to Gherkin’s keywords. In this way, it will be very easy to track back and forth the steps in the specification and its corresponding implementation. As I told you before, the code produced by White bread uses regular expressions to select, at a given moment, the next step to execute. In fact, when we run an acceptance test, White bread will open the feature file and execute each one of the steps specified by a scenario, one scenario at a time.
Well, we have only one step. Henceforth, we will need to run White bread several times to get the full set of step skeletons.
Setting up Hound
Let us now add a second library. In this case, I am speaking about Hound, which provides a DSL for specifying the interactions that an end user would have with our Web-based application. You might probably have heard about Selenium, which is a library that captures the interaction of a “user” with a web browser, in a way that it is possible to repeat the same interactions automatically. Basically, Selenium captures mouse and keyword events on a browser and saves log with the history of interactions in a log. Then, Selenium reads such log and replays the same set of mouse and keyword events on the application. This library is thus a very useful tool for browser-based automated testing. Well, Hound (and several other tools) leverage Selenium components to implement automated testing. Different from selenium, the interactions are specified using a DSL instead of using a log.
Well, let us get started with Hound. First, we have to dd the following dependency to the project’s mix.exs
file.
{:hound, "~> 1.0"}
As usual, you have to run mix deps.get
to download and install Hound in your application.
Then you have to complete the configuration. Open the file config/test.exs
of your project and change the following:
config :tacsi, Tacsi.Endpoint,
http: [port: 4001],
server: false # Change the `false` to `true`
# Add the following lines at the end of the file
config :hound, driver: "chrome_driver"
config :tacsi, sql_sandbox: true
The changes should be self explanatory. First, we are asking Phoenix to start the web server when we are running the tests. Well, if we are simulating user interactions via the browser we need somebody to be behind the browser. Then, we are specifying that we want to use hound
connected with chrome_driver
. That means that Hound will be interacting with a Chrome instance. Well, we will first need to download chromedriver, which you can download here. In a nutshell, chromedriver is a middleware that copies the protocols defined by Selenium to implement automated testing over Chrome. Hound supports other drivers, including one to interact with firefox and one, called phantomjs, which can be used for testing without an actual browser (i.e. headless testing). I personally advice you to use phantomjs.
Now, let us connect White bread and Hound in our acceptance test. Please update the file features/contexts/white_bread_context.exs
to include the following:
defmodule WhiteBreadContext do
use WhiteBread.Context
use Hound.Helpers
feature_starting_state fn ->
Application.ensure_all_started(:hound)
%{}
end
scenario_starting_state fn _state ->
Hound.start_session
%{}
end
scenario_finalize fn _status, _state ->
Hound.end_session
end
# The skeleton of the steps would be here
end
Please note that the function feature_starting_state/1
is called whenever a new feature file is considered by White bread for execution. In this function, we have added a line of code to ensure that Hound has been properly loaded by the testing platform before proceeding. The second line in this function, initializes a global test state. Remember that in elixir, as with other functional languages, there is not notion of state. To overcome this limitation, White bread will initialize a variable state
that will be passed over to every step in the test. Such variable is received by each step and that each step is free to return a different value for the state. That is how all the steps can “change” a global state. In the code I am proposing you above, we will discard the state variable set at the scope of the feature and use instead a state variable that is reset (to empty map) at the beginning of every scenario. You will see how such variable is used later on.
From red to green, and the way back
Now that we have completed the configuration, let us move to the real work. For the time being, let us skip the first two steps in the specification. Instead, we will start with the following step:
and_ ~r/^I open STRS' web page$/, fn state ->
navigate_to "/bookings/new"
{:ok, state}
end
You will notice that I have added the call to the function navigate_to/1
, which is one of Hound’s provided functions. As you can already guess, the goal of this function is to simulate the user’s action of opening the web page http://localhost:4001/booking/new in the browser. For demonstration purposes, I will propose you to change the function scenario_finalize/1
as follows:
scenario_finalize fn _status, _state ->
# Hound.end_session
nil
end
In a terminal window, run the command chromedriver
. (Of course, I am assuming that you downloaded such library and that you are running the command either within the folder where the library is located or at any place if you configured your PATH such as to include such folder). Now, let us run White bread with the command mix white_bread.run
in another terminal window. You will notice that one window with a Chrome tab will pop up and that the URL http://localhost:4001/bookings/new will be selected in the corresponding text input. In fact, I changed the function scenario_finalize/1
to ensure that the browser stays open after the test. That would give us the opportunity to analyze what happens on the browser as we progress with the test.
You will see on the browser a message notifying you that our application was trying to access a non existing page. Of course, our application does not know anything aobut such web page. We got our first RED.
To fix this problem (to move to GREEN), we will proceed as we did in the previous lecture/lab session.
- Fix the routes. That means that we have to add the line
resources "/bookings", BookingController
to our filelib/tacsi_web/router.ex
. - Add a controller and its action “new”. Create the file
lib/tacsi_web/controllers/booking_controller.ex
and copy there the following snippet:defmodule TacsiWeb.BookingController do use TacsiWeb, :controller def new(conn, _params) do render conn, "new.html" end end
- Add a view component. Copy the following snippet into file
lib/tacsi_web/views/booking_view.ex
:defmodule TacsiWeb.BookingView do use TacsiWeb, :view end
- Add a template. Well, at this point in time, we only need to create an empty file called
lib/tacsi_web/templates/booking/new.html.eex
.
Well, we are ready. Please execute mix white_bread.run
one more time. You will see that now the browser shows an empty web page with the Phoenix header and we do not have the error reported anymore. We’ve got a GREEN!
BTW close the browser window before you end up with a bunch of needless windows on your desktop.
Now, let us backtrack a little bit. In the step that preceeds the one opening the web page, we have another step where we state that the customer wants to go from one location (i.e. pick up address) to another one (i.e. drop off address). From the technical point of view, we would argue that this step should go after openning the web browser, because that information is the one we would like to enter on a web form. However, it is not necessarily the best order from the business point of view: we start with the aim of the customer, who later opens the browser to get the things done. Anyway, it is here where I find interesting to use the variable state
. If you remember, I initialized such variable with an empty map. So, in this step I will copy the input parameters into such map and passed them over to subsequent steps. To that end, I will ask you to replace the step the following snippet:
and_ ~r/^I want to go from "(?<pickup_address>[^"]+)" to "(?<dropoff_address>[^"]+)"$/,
fn state, %{pickup_address: pickup_address, dropoff_address: dropoff_address} ->
{:ok, state |> Map.put(:pickup_address, pickup_address) |> Map.put(:dropoff_address, dropoff_address)}
end
Note that by the same toke, I changed the name of the parameters with something more meaningful.
Now, let us think what an end user would do. You would agree with me that, with a very simple web interface, the end user would expect to see a web form and enter there the pickup and drop-off addresses. Hence, let us simulate that with Hound. The code below does exactly that.
and_ ~r/^I enter the booking information$/, fn state ->
fill_field({:id, "pickup_address"}, state[:pickup_address])
fill_field({:id, "dropoff_address"}, state[:dropoff_address])
{:ok, state}
end
The use of the tuple {:id, "pickup_address"}
implies that we are expecting that the web form includes a text field idenfied by pickup_address
and that we would enter there the value of pickup_address
, which has been copied into the variable state
before. With this in mind, we will now complete a very simple template that would allow us to pass this step and one additional that we will used later.
Hence, copy the following snippet in the file lib/tacsi_web/templates/booking/new.html.eex
.
<input id="pickup_address">
<input id="dropoff_address">
<button id="submit_button">Submit</button>
Let me now complete the implementation of the steps for our acceptance test.
when_ ~r/^I summit the booking request$/, fn state ->
click({:id, "submit_button"})
{:ok, state}
end
then_ ~r/^I should receive a confirmation message$/, fn state ->
assert visible_in_page? ~r/Your taxi will arrive in \d+ minutes/
{:ok, state}
end
As you can see, we added an action to click the submit button and finally, in the very last step, we are adding an assertion: we would like to see on the web page a message notifying that the taxi would arrive some minutes later.
At this point, we have finished the BDD part. We cannot proceed further here without the need of going deeper in the implementation. Such changes need to be guided by TDD.