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

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 file lib/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.