Testing complex React applications can be very tricky, so while coding my Phoenix Trello tribute I needed an easy and fast way to test the critical interactions a user could have with the application like registering or adding new stuff like boards and cards.
Hound to the rescue
Hound is an Elixir library for writing integration tests which is very easy to setup and works really great. To add it to a project we have to add the dependency:
# mix.exs
defmodule PhoenixTrello.Mixfile do
use Mix.Project
# ...
defp deps do
[
# ...
{:hound, "~> 1.0.2"},
# ...
]
end
# ...
end
Don't forget to run the necessary mix deps.get
. We also need to tell it to start
before our tests by adding the following line to the test_helper.exs
file:
# test/test_helper.exs
# Add this line!
Application.ensure_all_started(:hound)
# Already existing content...
ExUnit.start
# ...
Next we need to change our test environment configuration and set the server
option
to true:
# config/test.exs
use Mix.Config
config :phoenix_trello, PhoenixTrello.Endpoint,
http: [port: 4001],
server: true
# ...
Now we need to configure Hound specifying the web browser driver it will use to interact with the application. At first I opted for using PhantomJS because it doesn't require opening any browser window while running the test suite, but I suddenly found that it was not able to interact with some DOM elements like text inputs due to this issue. So I switched to ChromeDriver and it worked like a charm.
For using ChromeDriver we first need to download it from its download page, install it and configure Hound to use it:
# config/config.exs
# ...
# Start Hound for ChromeDriver
config :hound, driver: "chrome_driver"
The last step would be to create a IntegrationCase
module which will contain all the
common functionality our integration tests will share:
# test/support/integration_case.ex
defmodule PhoenixTrello.IntegrationCase do
use ExUnit.CaseTemplate
use Hound.Helpers
using do
quote do
use Hound.Helpers
import Ecto, only: [build_assoc: 2]
import Ecto.Model
import Ecto.Query, only: [from: 2]
import PhoenixTrello.Router.Helpers
import PhoenixTrello.Factory
import PhoenixTrello.IntegrationCase
alias PhoenixTrello.Repo
# The default endpoint for testing
@endpoint PhoenixTrello.Endpoint
hound_session
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixTrello.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(PhoenixTrello.Repo, {:shared, self()})
end
:ok
end
end
And that's it! Let's begin with some testing fun.
Writing integration tests
The first thing I wanted to test was that existing users were able to sign into the application and see the home route with their boards:
# test/integration/sign_in_test.exs
defmodule PhoenixTrello.SignInTest do
use PhoenixTrello.IntegrationCase
alias PhoenixTrello.User
setup do
user = %User{first_name: "John", last_name: "Doe", email: "john@phoenix-trello.com"}
|> User.changeset(%{password: "12345678"})
|> Repo.insert!
{:ok, ${user: user}}
end
@tag :integration
test "Sign in with existing email and password", {user: user} do
navigate_to "/"
sign_in_form = find_element(:id, "sign_in_form")
sign_in_form
|> find_within_element(:id, "user_email")
|> fill_field(user.email)
sign_in_form
|> find_within_element(:id, "user_password")
|> fill_field(user.password)
sign_in_form
|> find_within_element(:css, "button")
|> click
assert element_displayed?({:id, "authentication_container"})
assert page_source =~ "#{user.first_name} #{user.last_name}"
assert page_source =~ "My boards"
end
end
Reading the test is very easy to understand what we it does. Before executing the test
it first inserts a new user into the database. The test starts by visiting the root route,
finding the sign in form and filling both the email and password inputs with the previously created user
data. It clicks the form button and it checks that an element with the id authentication_container
is displayed and if it founds in the page the user's full name and the text My boards. Don't forget to
check Hound's official documentation to learn more about its helpers and selectors.
Running our test suite
To run it we first need to launch the ChromeDriver by opening a new terminal window and executing:
$ chromedriver
Starting ChromeDriver 2.20.353124 (035346203162d32c80f1dce587c8154a1efa0c3b) on port 9515
Only local connections are allowed.
Now we can run our test:
$ mix test test/integration/sign_in_test.exs
Excluding tags: [:test]
.
Finished in 3.8 seconds (0.5s on load, 3.3s on tests)
1 test, 0 failures, 0 skipped
Randomized with seed 793757
Even though our application DOM is constantly changing by React, the test passes without any weird hack from our side. This is because Hound's selectors internally perform a fixed number of retries to request the specified element. Therefor we can fine-tune the selector calls to perform more retries and even increase the time between retries, which is very useful if we know that a component will need some more time to render.
Automatically running our tests
If I'm working on a big test I usually like to run it constantly to check the results, but having
to run the test manually is a bit awkward. To avoid this we can use the mix-test-watch
library which automatically runs your tests after every save. Just add it to the dependencies and run mix deps.get
:
# mix.exs
defmodule PhoenixTrello.Mixfile do
use Mix.Project
# ...
defp deps do
[
# ...
{:mix_test_watch, "~> 0.2", only: :dev},
# ...
]
end
# ...
end
Now we only have to run our tests using mix test.watch
and it will start listening for changes,
running the specified tests when a test file is saved.
Sharing common stuff between tests
Imagine for a moment that we want to add a new integration test to check if the board
creation functionality is not broken. To do so we first need the user to sign in, and we
already have this functionality implemented in our previous test, so it would be nice if
we could reuse it on every test we might need it. To do so we only need to add a couple of new methods
to the IntegrationCase
file so they are available in all our integration tests:
# test/support/integration_case.ex
defmodule PhoenixTrello.IntegrationCase do
use ExUnit.CaseTemplate
use Hound.Helpers
# ...
def create_user do
user = %User{first_name: "John", last_name: "Doe", email: "john@phoenix-trello.com"}
|> User.changeset(%{password: "12345678"})
|> Repo.insert!
end
def user_sign_in(%{user: user}) do
navigate_to "/"
sign_in_form = find_element(:id, "sign_in_form")
sign_in_form
|> find_within_element(:id, "user_email")
|> fill_field(user.email)
sign_in_form
|> find_within_element(:id, "user_password")
|> fill_field(user.password)
sign_in_form
|> find_within_element(:css, "button")
|> click
assert element_displayed?({:id, "authentication_container"})
end
end
Now we can refactor our sign_in_test
:
# test/integration/sign_in_test.exs
defmodule PhoenixTrello.SignInTest do
use PhoenixTrello.IntegrationCase
# ...
@tag :integration
test "Sign in with existing email/password" do
user = create_user
user_sign_in(%{user: user})
assert page_source =~ "#{user.first_name} #{user.last_name}"
assert page_source =~ "My boards"
end
end
And it will keep working as before. Using them in the new test would be just the same:
# test/integration/new_board_test.exs
defmodule PhoenixTrello.NewBoardTest do
use PhoenixTrello.IntegrationCase
alias PhoenixTrello.{User}
setup do
user = create_user
{:ok, %{user: user}}
end
@tag :integration
test "GET / with existing user", %{user: user} do
user_sign_in(%{user: user})
click({:id, "add_new_board"})
assert element_displayed?({:id, "new_board_form"})
new_board_form = find_element(:id, "new_board_form")
new_board_form
|> find_within_element(:id, "board_name")
|> fill_field("New board")
new_board_form
|> find_within_element(:css, "button")
|> click
assert element_displayed?({:css, ".view-container.boards.show"})
board = last_board(user)
assert page_title =~ board.name
assert page_source =~ "New board"
assert page_source =~ "Add new list..."
end
def last_board(user) do
user
|> Repo.preload(:boards)
|> Map.get(:boards)
|> Enum.at(0)
end
end
This way we can reorganize our code and be DRY.
Conclusion
Writing integration tests this way is so easy and fun that there's no excuse for not having at least the basic functionality covered. My only concern is that I would prefer using PhantomJS rather than ChromeDriver so the browser window stops poping up every time I make a change, but until I find a solution I don't mind watching ghost users navigating through my application :)
Happy coding!