In WyeWorks we maintain a library that sends a notification every time an exception is raised in a Plug application. Besides adding unit and integration tests for every new feature, we used to run manual regression tests on every release to check we didn’t break previous functionality and the new ones are actually working.
However, as the number of features increased, doing manual testing started to be very time-consuming so we decided it was about time to start automating this process.
We ended up using Wallaby for the testing but any other tool like Hound would work just fine. The scope of this article is not to explain how Wallaby works but to show how it can be used to test a library.
Creating a testing application
The problem when trying to E2E test a library is that you need an actual application that does something with it. In our case we need a Phoenix application so let’s just create one.
Since this is also a test artifact it would be nice to create this app inside
/test
. However, we’ll have to make a couple of changes to have it working.
First, let’s move our current test files to /test/unit
.
mkdir test/unit
mv test/*.exs test/unit
Now, for the tests to work, we have to tell mix
we changed its default test
path.
def project do
[
...
test_paths: ["test/unit"]
]
end
We should check our tests are still working running mix test
.
Finally, let’s create our Phoenix app.
cd test
mix phx.new example_app --no-ecto --no-dashboard --no-live
cd example_app
Update: running static code tools
If we run mix format
or mix credo
we’ll get some nasty errors because those
tools will check everything under test/**/*.{ex,exs}
which includes the newly
created app.
We need to exclude that folder updating their settings in the .formatter.exs
and .credo.exs
files.
# .formatter.exs
[
inputs:
Enum.flat_map(
["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
&Path.wildcard(&1, match_dot: true)
) -- Path.wildcard("test/example_app/**/*.*", match_dot: true)
]
# .credo.exs
%{
configs: [
files: %{
...
excluded: [..., ~r"test/example_app/"]
},
],
...
}
Our first E2E test
Following the official guides we know we need to
install chromedriver
, include the testing library as a dependency, and
configure it.
npm install chromedriver
# test/example_app/mix.exs
defp deps do
[
...
{:wallaby, "~> 0.29.0", runtime: false, only: :test}
]
end
# test/example_app/test/test_helper.exs
Application.put_env(:wallaby, :base_url, ExampleAppWeb.Endpoint.url())
{:ok, _} = Application.ensure_all_started(:wallaby)
# test/example_app/config/test.exs
# make sure ExampleAppWeb is set up to serve endpoints in tests
config :example_app, ExampleAppWeb.Endpoint,
...
server: true
The easiest way to check everything is working is to add a test that validates the default landing page.
# test/example_app/test/landing_page_test.exs
defmodule ExampleAppWeb.LandingPageTest do
use ExUnit.Case, async: true
use Wallaby.Feature
feature "Shows a welcome message", %{session: session} do
session
|> visit("/")
|> find(Query.css("section.phx-hero"))
|> assert_has(Query.css("h1", text: "Welcome to Phoenix!"))
end
end
> mix test
.
Finished in 1.0 seconds (1.0s async, 0.00s sync)
1 feature, 0 failures
Randomized with seed 41741
Start testing our library
Now we have everything in place it’s time to include our library in the example app.
Let’s crack open the mix.exs
file and include the library as a dependency
using a relative path so we are always testing against the latest version of
the code. Remember we are under test/example_app
so the library code is a
couple of folders up.
defp deps do
[
...
{:boom_notifier, path: "./../.."}
]
end
The next thing is to configure the example application so it uses the library we want to test. For this example, let’s configure Boom as if we were a user of this library.
For sending emails we are using the mailer that is included in Phoenix. It also provides a plug that allows us to preview the emails in an in-memory mailbox. This is particularly convenient because we can check what our emails will look like without actually sending them.
# test/example_app/lib/example_app_web/router.ex
use BoomNotifier,
notifier: BoomNotifier.MailNotifier.Swoosh,
options: [
mailer: ExampleApp.Mailer,
from: "me@example.com",
to: "foo@example.com",
subject: "BOOM error caught"
]
# this provides access to the fake email inbox
forward "/mailbox", Plug.Swoosh.MailboxPreview
Finally, we need to add a route that raises an error.
# test/example_app/lib/example_app_web/controllers/page_controller.ex
defmodule ExampleAppWeb.PageController do
use ExampleAppWeb, :controller
def index(conn, _params) do
# an exception is raised and an email
# should be sent
raise "Boom"
render(conn, "index.html")
end
end
Writing an actual test
After having the setup ready, it is time to think about what flows we want to test. To keep it simple, we’re providing the smallest scenario we can test in Boom:
- Visit
/
. - Check an error was raised.
- Navigate to
/mailbox
. - Check the email was sent.
- Check the email contains the error information.
Error page in /
Fake inbox in /mailbox
Send notification test
defmodule ExampleAppWeb.SendNotificationTest do
use ExUnit.Case, async: true
use Wallaby.Feature
alias Wallaby.{Browser, Element, Query}
# helpers
def get_latest_from_inbox(page) do
page
|> find(Query.css(".list-group"))
|> find(Query.css(".list-group-item"))
end
def click_on_email(inbox_item, session),
do: visit(session, Element.attr(inbox_item, "href"))
feature "sends email when an exception happens", %{session: session} do
# raise the exception
session
|> visit("/")
|> assert_text("RuntimeError at GET /")
# got to the inbox and select the latest email
session
|> visit("/mailbox")
|> get_latest_from_inbox()
|> click_on_email(session)
# check email metadata
session
|> Browser.find(Query.css(".header-content"))
|> Browser.assert_text("BOOM error caught: Boom")
|> Browser.assert_text("me@example.com")
# check email content
session
|> Browser.find(Query.css(".body"))
|> Browser.assert_text(
"RuntimeError occurred while the request was processed by PageController#index"
)
end
end
Running the tests
At this point you can only execute the E2E tests if you run mix test
inside
the example_app
directory. We can create a mix task
so we can run this
tests from the root directory of our library.
# be sure to run this in the root folder (not inside the example_app directory)
mkdir -p lib/mix/tasks
touch lib/mix/tasks/end_to_end_test.ex
# lib/mix/tasks/end_to_end_test.ex
defmodule Mix.Tasks.EndToEndTest do
@moduledoc "Runs e2e tests"
use Mix.Task
@impl Mix.Task
def run(_) do
exit_status = Mix.shell().cmd("cd test/example_app && mix deps.get && mix test")
exit({:shutdown, exit_status})
end
end
And now we can add this task as an alias in our mix file.
defmodule BoomNotifier.MixProject do
...
def project do
[
...
aliases: [
...
e2e: ["cmd mix end_to_end_test"]
],
...
]
end
...
end
> mix e2e
.
Finished in 1.0 seconds (1.0s async, 0.00s sync)
1 feature, 0 failures
Randomized with seed 41741
Conclusions
Not only doing manual testing was taking a lot of time but also it was a matter of time until a scenario was missed and a broken version of the library got released.
Now we can run the main scenarios in a couple of seconds and we can even configure our CI to run the end-to-end testing in every pull request.
Here is the PR with the complete code: https://github.com/wyeworks/boom/pull/78. If you have any suggestions of how this can be improved we’d love you tell us in the comments.