TDD Phoenix

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 declare use ChatterWeb.FeatureCase and pass the option async: true or async: false. It also adds the using/2 function, which we use next. Everything in the body of using will be included in our test module (same as if we were using the __using__ macro).
  • Within using, we declare use Wallaby.DSL. This imports functions from Wallaby.Browser and aliases Wallaby.Query functions to use in our feature tests. Wallaby.Browser functions usually take a session struct as their first argument (which we create at the end of this setup), and some take a %Wallaby.Query{} created by Wallaby.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 from Wallaby.Browser which was imported via use Wallaby.DSL in our ChatterWeb.FeatureCase. The second argument passed to visit/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 a Wallaby.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 for Wallaby.Query.css/2, which was aliased in use 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 to css/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!