Setting Up the App
Tool versions and new Phoenix app
Throughout this application, I will be using Elixir 1.11, Erlang 23.0, and
Phoenix 1.5. We'll first create a new phoenix application called chatter
.
$ mix phx.new chatter
Install all the dependencies when prompted. Once the installation is done, cd
into chatter
and run mix test
.
mix test
is the most basic command to run our tests. Without any arguments, it
will run all the tests in our mix project. But we can also run all tests in a
single file with mix test test/path-to/file.exs
, and a single test within a
file by specifying the line number, mix test test/path-to/file.exs:9
. There
are many other
options
when running tests, but those three options will be our bread and butter.
Wallaby
Now that we have a basic Phoenix app, let's introduce an essential tool for outside-in testing: a web driver. To test from the outermost layer of our web application, we'll want to drive interactions through a browser. That's where tools like Wallaby and Hound come in. We'll use Wallaby in this book because I like how its functions compose to create very legible tests, but you could just as well use Hound.
Set up Wallaby
Let's set up Wallaby. Then we'll add a test to ensure that everything is working correctly — a smoke test.
First, let's add Wallaby to our dependencies. We'll add version 0.28.0
, which
is the latest as of this writing, and set runtime: false
and only: :test
.
Fetch Wallaby with mix deps.get
.
# mix.exs
def deps do
# other deps
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"},
{:wallaby, "~> 0.28.0", [runtime: false, only: :test]} ]
end
When we run our tests with mix test
, mix will run the script found in
test/test_helper.exs
. If you look at that, you'll see we have things like
ExUnit.start()
, which starts the test runner itself. As part of running this
script, we want to make sure the Wallaby application has started, so add the
following at the end of the file:
# test/test_helpers.exs
{:ok, _} = Application.ensure_all_started(:wallaby)
Wallaby also needs a base URL to resolve relative paths. So add one more line:
# test/test_helpers.exs
Application.put_env(:wallaby, :base_url, ChatterWeb.Endpoint.url())
Since we'll be using Wallaby with Phoenix and Ecto, we need to set up some
configuration, so that (a) Phoenix runs a server during tests, and (b) Phoenix
and Ecto use Ecto's sandbox for concurrent tests. Head to config/test.exs
and
change the server
option for ChatterWeb.Endpoint
to true
:
# config/test.exs
config :chatter, ChatterWeb.Endpoint,
http: [port: 4002],
server: true
We'll only want to use Ecto's sandbox in tests, so set the following configuration that we'll use in the next step:
# config/test.exs
config :chatter, :sql_sandbox, true
With that configuration set, head over to your ChatterWeb.Endpoint
module and
add the following at the top of your module:
# lib/chatter_web/endpoint.ex
defmodule ChatterWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :chatter
if Application.get_env(:chatter, :sql_sandbox) do plug Phoenix.Ecto.SQL.Sandbox end
Ecto's sandbox allows us to run tests concurrently without mutating shared
database state across different tests. The Phoenix.Ecto.SQL.Sandbox
plug
creates some allowances so that Phoenix requests can use the pool of
connections. And note we're only using that plug if
Application.get_env(:chatter, :sql_sandbox)
returns true
, which is the
configuration option we just set in config/test.exs
. So we'll only use Ecto's
sandbox during tests.
Wallaby uses ChromeDriver as its default web driver. If you don't have it installed, please install it in your machine. Depending on your operating system, you may have an easy way to install it via the command line. For example, on a Mac you can use Homebrew. Otherwise, you can download it.
You could use another web driver like Selenium, but I recommend sticking with ChromeDriver for this book to avoid running into errors that might differ from the ones we encounter. And if you run into errors you do not see in the book, take a look at the troubleshooting appendix. You may be running into a known error with Wallaby and ChromeDriver.
Now, configure Wallaby to use Chrome in config/test.exs
:
# conf/test.exs
config :wallaby, driver: Wallaby.Chrome
Creating a FeatureCase
helper module
There's one last thing I'd like to set up before we write a smoke test. Feature
and controller tests often have a lot of common code to set up. In controller
tests, for example, we always need a connection struct during the tests. So
Phoenix ships with a convenient module to use in controller tests. Let's take a
look at it. Navigate to test/support/conn_case.ex
. You should see the
following module:
# test/support/conn_case.ex
defmodule ChatterWeb.ConnCase do
use ExUnit.CaseTemplate
using do
quote do
import Plug.Conn
import Phoenix.ConnTest
import ChatterWeb.ConnCase
alias ChatterWeb.Router.Helpers, as: Routes
@endpoint ChatterWeb.Endpoint
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Chatter.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(Chatter.Repo, {:shared, self()})
end
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end
This helper module is used in all controller tests. It's doing several things
for us. First, it brings in a lot of functionality via use Phoenix.ConnTest
.
It's aliasing the route helpers as Routes
so we can use those in tests, and it
creates a module attribute @endpoint
that we can use those with route helpers
(e.g. Routes.user_path(@endpoint, :index)
).
More importantly, this module sets up the connection struct we'll need for
every single controller test. In the body of setup
, we first check out a
connection from Ecto's SQL sandbox. If we need a database connection to be
shared by many processes, we cannot run that test concurrently, so we set
async
to false
. Finally, the setup function returns an ok tuple with a
Phoenix conn
struct built. In our controller tests, we'll have access to the
conn struct as an argument in the test. For example, we might see something like
this:
defmodule ChatterWeb.SomeControllerTest do
use ChatterWeb.ConnCase, async: true
test "this is a test description", %{conn: conn} do
# use conn in test
I mention all of this because we want to do something very similar for feature
tests. Every feature test will need some common setup: setting up some Wallaby
helpers, and we'll want to
initiate a browser session that we can use in tests. Let's go ahead and
do that. Create a new file test/support/feature_case.ex
, and copy the
following module:
# test/support/feature_case.ex
defmodule ChatterWeb.FeatureCase do
use ExUnit.CaseTemplate
using do
quote do
use Wallaby.DSL
alias ChatterWeb.Router.Helpers, as: Routes
@endpoint ChatterWeb.Endpoint
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Chatter.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(Chatter.Repo, {:shared, self()})
end
metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Chatter.Repo, self())
{:ok, session} = Wallaby.start_session(metadata: metadata)
{:ok, session: session}
end
end
Let's walk through the module that we have just created.
use ExUnit.CaseTemplate
: conveniences that allow us to use this module for ExUnit tests. In our tests, we'll be able to declareuse ChatterWeb.FeatureCase
and pass the optionasync: true
orasync: false
. It also adds theusing/2
function, which we use next. Everything in the body ofusing
will be included in our test module (same as if we were using the__using__
macro).- Within
using
, we declareuse Wallaby.DSL
. This imports functions fromWallaby.Browser
and aliasesWallaby.Query
functions to use in our feature tests.Wallaby.Browser
functions usually take asession
struct as their first argument (which we create at the end of this setup), and some take a%Wallaby.Query{}
created byWallaby.Query
functions as their second argument. Thus Wallaby composes very nicely with the pipe operator. - We alias the route helpers (as
Routes
) and define the@endpoint
to use path helpers in feature tests easily. - Everything within the
setup/2
is run before each test. There we set up Ecto's SQL sandbox in the same way Phoenix does for controller tests: checkout a connection and set the mode to:shared
if the test is not asynchronous. - Finally, we create a Wallaby session (taking some metadata), which we'll use
in every feature test we write (similar to the
conn
struct for controller tests).
With this, we're now ready to write our smoke test.
Smoke test
A smoke test is a basic test that ensures all of our setup is working correctly. Let's write a test that will go to the root path in our application and assert that we can see the text "Welcome to Chatter!".
Create a new test file
test/chatter_web/features/user_visits_homepage_test.exs
. Note that the file
extension is exs
and not ex
since tests are scripts. We do not want Elixir
to compile them when it is compiling the application.
In that file, add the following module and test:
defmodule ChatterWeb.UserVisitsHomepageTest do
use ChatterWeb.FeatureCase, async: true
test "user can visit homepage", %{session: session} do
session
|> visit("/")
|> assert_has(Query.css(".title", text: "Welcome to Chatter!"))
end
end
By now, you probably recognize the ChatterWeb.FeatureCase
module we created
earlier. Using it gives us the %{session: session}
as a parameter so we can
use the session
in our feature test.
Now let's talk about the other parts of the test:
- The
visit/2
function comes fromWallaby.Browser
which was imported viause Wallaby.DSL
in ourChatterWeb.FeatureCase
. The second argument passed tovisit/2
is the path we will visit. We can use Phoenix path helpers here — and we will in the future — but for this test, we'll stick to a simple string for the root path. assert_has/2
is also aWallaby.Browser
function. It takes a session as its first argument and a Wallaby query as the second argument, which brings us to that second argument.Query.css/2
is an alias forWallaby.Query.css/2
, which was aliased inuse Wallaby.DSL
. The Query module has many functions to interact with an HTML page. It can create queries by css, data attributes, and xpaths. The first argument passed tocss/2
is the css selector we are targeting. The second argument is a set of options that refine the query. In this case, we are looking for an HTML element with a class "title" and text "Welcome to Chatter!"
If you notice, the test above is relatively easy to read. In the future, we'll modify our feature tests to express them more in the domain of our application. But I think it's worth noting how well Wallaby composes, and how easy it is for us to read feature tests and understand what we're trying to accomplish.
Now that we have our test written, let's go ahead and run it:
mix test test/chatter_web/features/user_visits_homepage_test.exs
You should see the following failure:
1) test user can visit homepage (ChatterWeb.UserVisitsHomepageTest)
** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element that
matched css '.title' but 0, visible elements were found.
code: |> assert_has(Query.css(".title", text: "Welcome to Chatter!"))
stacktrace:
test/chatter_web/features/user_visits_homepage_test.exs:7: (test)
And we have our first test failure! Wallaby is successfully making a request and getting an HTML page back. But it cannot find that particular element on the page.
We're able to make a request and get an HTML page back because a brand new
Phoenix app comes with the root path pointing to a PageController
that renders
a "Welcome to Phoenix" page. But we don't want that controller, its view, or its
templates. So let's change that.
Open up lib/chatter_web/router.ex
. The route we want to change is get "/",
PageController, :index
. But to what do we change it?
Since we're going to be building a chat application, make the root page a page
that shows all the rooms a user can join. So change the get "/"
route to point
to a ChatRoomController
.
- get "/", PageController, :index
+ get "/", ChatRoomController, :index
Note that the ChatRoomController
does not yet exist. We will do this time and
time again as we do test-driven development. We'll write the code we wish
existed and as though it existed, and we'll let the test failures guide us. Go
ahead and rerun our test:
mix test test/chatter_web/features/user_visits_homepage_test.exs
We should now see a much larger error. Part of the error shows that Wallaby cannot find an element that it expected on the page. But if you look towards the top of the error, you will see the root cause — you should see the following:
Request: GET /
** (exit) an exception was raised:
** (UndefinedFunctionError) function ChatterWeb.ChatRoomController.init/1 is
undefined (module ChatterWeb.ChatRoomController is not available)
That is exactly the error we expect to see. We've added a ChatRoomController
invocation in the router, but we have not yet created that module. So go ahead
and create that module. Note that the error also showed that the function
ChatterWeb.ChatRoomController.init/1
was undefined. That means, our
application expects ChatRoomController
to be a plug and define the init/1
function. Let's also fix that by adding use ChatterWeb, :controller
to our
empty controller, which turns our module into a plug. So we end up with the
following:
# lib/chatter_web/controllers/chat_room_controller.ex
defmodule ChatterWeb.ChatRoomController do
use ChatterWeb, :controller
end
This is all we need to write for now to get us past the previous error.
Rerun the test:
mix test test/chatter_web/features/user_visits_homepage_test.exs
Now you should get the following error:
Request: GET /
** (exit) an exception was raised:
** (UndefinedFunctionError) function ChatterWeb.ChatRoomController.index/2 is
undefined or private
This is once again exactly what we'd expect. In the router, we are routing get
"/"
to the ChatRoomController
's index action, but we do not have that action
defined in our controller. Let's add code to get the test past this failure:
# lib/chatter_web/controllers/chat_room_controller.ex
defmodule ChatterWeb.ChatRoomController do
use ChatterWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end
If you're familiar with Phoenix, you would expect the next error to say something related to the fact that we'll try to render an index page but we do not have a view to do so. And that's indeed that case. Run the test again:
mix test test/chatter_web/features/user_visits_homepage_test.exs
We get the error:
Request: GET /
** (exit) an exception was raised:
** (UndefinedFunctionError) function ChatterWeb.ChatRoomView.render/2 is
undefined (module ChatterWeb.ChatRoomView is not available)
Excellent! Let's define the view module now:
# lib/chatter_web/views/chat_room_view.ex
defmodule ChatterWeb.ChatRoomView do
use ChatterWeb, :view
end
Run the test again:
mix test test/chatter_web/features/user_visits_homepage_test.exs
Now we get an error because we do not have a matching template or function that renders "index.html":
Request: GET /
** (exit) an exception was raised:
** (Phoenix.Template.UndefinedError) Could not render "index.html" for
ChatterWeb.ChatRoomView, please define a matching clause for render/2 or define a
template at "lib/chatter_web/templates/chat_room": No templates were compiled for
this module.
That is a long but clear error. Let's go ahead and define a template. I will just create a file without any HTML in it:
mkdir lib/chatter_web/templates/chat_room
touch lib/chatter_web/templates/chat_room/index.html.eex
Now we should be back to a place where the only error is Wallaby's error of not finding an HTML element on the page. Run the test:
mix test test/chatter_web/features/user_visits_homepage_test.exs
Hooray! We see the following failure:
1) test user can visit homepage (ChatterWeb.UserVisitsHomepageTest)
** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element that
matched css '.title' but 0, visible elements were found.
code: |> assert_has(Query.css(".title", text: "Welcome to Chatter!"))
stacktrace:
test/chatter_web/features/user_visits_homepage_test.exs:7: (test)
Wallaby is able to make a request and get an HTML page back, but it cannot find an element with a CSS class "title" and text "Welcome to Chatter!".
Let's fix that! Open up lib/chatter_web/templates/chat_room/index.html.eex
,
and copy the following h1
element:
<h1 class="title">Welcome to Chatter!</h1>
Run the test again to see some green!
mix test test/chatter_web/features/user_visits_homepage_test.exs
.
Finished in 0.3 seconds
1 test, 0 failures
Perfect! This satisfies our smoke test. It ensures that our application is set up correctly for feature tests.
Clean up
The last thing we want to do is to make sure we haven't broken any other tests in the process (which we have). A broken test means that we've changed the behavior of our application. If that behavior change is desired (as is our case now), we can remove the tests. But if the change in behavior is undesired, we would need to go back and fix the issue.
If we run all of our tests with mix test
, you should see a controller test
failing because it expects the root path to have the text "Welcome to Phoenix".
We no longer want that behavior. We want our root page to show something else.
In fact, we no longer need the PageController
or PageView
modules at all. So
go ahead and delete those unused files and their corresponding tests. We'll also
delete the layout_view_test
for good measure since it tests nothing.
rm test/chatter_web/controllers/page_controller_test.exs
rm lib/chatter_web/controllers/page_controller.ex
rm test/chatter_web/views/page_view_test.exs
rm test/chatter_web/views/layout_view_test.exs
rm lib/chatter_web/views/page_view.ex
rm lib/chatter_web/templates/page/index.html.eex
Now, running mix test
should give us 3 tests, 0 failures
. One of those tests
is our feature test. But what are the other two? To get a more detailed
description of the tests, run mix test --trace
:
mix test --trace
ChatterWeb.ErrorViewTest
* test renders 500.html (15.5ms)
* test renders 404.html (0.6ms)
ChatterWeb.UserVisitsHomepageTest
* test user can visit homepage (467.6ms)
Finished in 0.5 seconds
3 tests, 0 failures
The other two tests are testing the 404 and 500 pages in
ChatterWeb.ErrorViewTest
. Since we'll use those views and templates, let's
leave those tests for now.
We're at a good stopping point. Go ahead and commit those changes. And welcome to test-driven development!
This is a work in progress.
To find out more, see TDD Phoenix - a work in progress.