Testing Controllers
We’re going to take a look at how we might test drive a controller which has endpoints for a JSON api.
Phoenix has a generator for creating a JSON resource which looks like this:
Set up
mix phoenix.gen.json Thing things some_attr:string another_attr:string
Thing is the Model, things is the table name, and some_attr and another_attr are database columns on table things of type string. Don’t run this command, we’re going to explore test driving out a similar result to what a generator would give us.
Let’s create a User
model. Since model creation is not in scope of this guide, we will use the generator. If you aren’t familiar, read this section of the Mix guide.
$ mix phoenix.gen.model User users name:string email:string
Now run migrations:
$ mix ecto.migrate
Test driving
What we are going for is a controller with the standard CRUD actions. We’ll start with our test since we’re TDDing this. Create a user_controller_test.exs
file in test/controllers
# test/controllers/user_controller_test.exs
defmodule HelloPhoenix.UserControllerTest do
use HelloPhoenix.ConnCase, async: true
end
There are many ways to approach TDD. Here, we will think about each action we want to perform, and handle the “happy path” where things go as planned, and the error case where something goes wrong, if applicable.
# test/controllers/user_controller_test.exs
defmodule HelloPhoenix.UserControllerTest do
use HelloPhoenix.ConnCase, async: true
test "index/2 responds with all Users"
describe "create/2" do
test "Creates, and responds with a newly created user if attributes are valid"
test "Returns an error and does not create a user if attributes are invalid"
end
describe "show/2" do
test "Responds with a newly created user if the user is found"
test "Responds with a message indicating user not found"
end
describe "update/2" do
test "Edits, and responds with the user if attributes are valid"
test "Returns an error and does not edit the user if attributes are invalid"
end
test "delete/2 and responds with :ok if the user was deleted"
end
Here we have tests around the 5 controller CRUD actions we need to implement for a typical JSON API. In 2 cases, index and delete, we are only testing the happy path, because in our case they generally won’t fail because of domain rules (or lack thereof). In practical application, our delete could fail easily once we have associated resources that cannot leave orphaned resources behind, or number of other situations. On index, we could have filtering and searching to test. Also, both could require authorization.
Create, show and update have more typical ways to fail because they need a way to find the resource, which could be non existent, or invalid data was supplied in the params. Since we have multiple tests for each of these endpoints, putting them in a describe
block is good way to organize our tests.
Let’s run the test:
$ mix test test/controllers/user_controller_test.exs
We get 8 failures that say “Not yet implemented” which is good. Our tests don’t have blocks yet.
Let’s add our first test. We’ll start with index/2
.
defmodule HelloPhoenix.UserControllerTest do
use HelloPhoenix.ConnCase, async: true
alias HelloPhoenix.{Repo, User}
test "index/2 responds with all Users" do
users = [ User.changeset(%User{}, %{name: "John", email: "john@example.com"}),
User.changeset(%User{}, %{name: "Jane", email: "jane@example.com"}) ]
Enum.each(users, &Repo.insert!(&1))
response = build_conn
|> get(user_path(build_conn, :index))
|> json_response(200)
expected = %{
"data" => [
%{ "name" => "John", "email" => "john@example.com" },
%{ "name" => "Jane", "email" => "jane@example.com" }
]
}
assert response == expected
end
Let’s take a look at what’s going on here. We build our users, and use the get
function to make a GET
request to our UserController
index action, which is piped into json_response/2
along with the expected HTTP status code. This will return the JSON from the response body, when everything is wired up properly. We represent the JSON we want the controller action to return with the variable expected
, and assert that the response
and expected
are the same.
Our expected data is a JSON response with a top level key of "data"
containing an array of users that have "name"
and "email"
properties.
When we run the test we get an error that we have no user_path
function.
In our router, we’ll add a resource for User
in our API pipe:
defmodule HelloPhoenix.Router do
use HelloPhoenix.Web, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
resources "/users", HelloPhoenix.UserController
end
#...
We should get a new error now. Running the test informs us we don’t have a UserController
. Let’s add it, along with the index/2
action we’re testing. Our test description has us returning all users:
defmodule HelloPhoenix.UserController do
use HelloPhoenix.Web, :controller
alias HelloPhoenix.{User, Repo}
def index(conn, _params) do
users = Repo.all(User)
render conn, "index.json", users: users
end
end
When we run the test again, our failing test tells us we have no view. Let’s add it. Our test specifies a JSON format with a top key of "data"
, containing an array of users with attributes "name"
and "email"
.
defmodule HelloPhoenix.UserView do
use HelloPhoenix.Web, :view
def render("index.json", %{users: users}) do
%{data: render_many(users, HelloPhoenix.UserView, "user.json")}
end
def render("user.json", %{user: user}) do
%{name: user.name, email: user.email}
end
end
And with that, our test passes when we run it.
We’ll also cover the show/2
action here so we can see how to handle an error case.
Our show tests currently look like this:
describe "show/2" do
test "Responds with a newly created user if the user is found"
test "Responds with a message indicating user not found"
end
Run this test only by running the following command: (if your show tests don’t start on line 32, change the line number accordingly)
$ mix test test/controllers/user_controller_test.exs:32
Our first show/2
test result is, as expected, not yet implemented.
Let’s build a test around what we think a successful show/2
should look like.
test "Responds with a newly created user if the user is found" do
user = User.changeset(%User{}, %{name: "John", email: "john@example.com"})
|> Repo.insert!
response = build_conn
|> get(user_path(build_conn, :show, user.id))
|> json_response(200)
expected = %{ "data" => %{ "name" => "John", "email" => "john@example.com" } }
assert response == expected
end
This is very similar to our index/2
test, except show/2
requires a user id, and our data is a single JSON object instead of an array.
When we run our test tells us we need a show/2
action.
defmodule HelloPhoenix.UserController do
use HelloPhoenix.Web, :controller
alias HelloPhoenix.{User, Repo}
def index(conn, _params) do
users = Repo.all(User)
render conn, "index.json", users: users
end
def show(conn, %{"id" => id}) do
case Repo.get(User, id) do
user -> render conn, "show.json", user: user
end
end
end
You’ll notice we only handle the case where we successfully find a user. When we TDD we only want to write enough code to make the test pass. We’ll add more code when we get to the error handling test for show/2
.
Running the test tells us we need a render/2
function that can pattern match on "show.json"
:
defmodule HelloPhoenix.UserView do
use HelloPhoenix.Web, :view
def render("index.json", %{users: users}) do
%{data: render_many(users, HelloPhoenix.UserView, "user.json")}
end
def render("show.json", %{user: user}) do
%{data: render_one(user, HelloPhoenix.UserView, "user.json")}
end
def render("user.json", %{user: user}) do
%{name: user.name, email: user.email}
end
end
When we run the test again, it passes.
The last item we’ll cover is the case where we don’t find a user in show/2
.
Try this one on your own and see what you come up with. One possible solution will be given below.
Walking through our TDD steps, we add a test that supplies a non existent user id to user_path
which returns a 404 status and an error message:
test "Responds with a message indicating user not found" do
response = build_conn
|> get(user_path(build_conn, :show, 300))
|> json_response(404)
expected = %{ "error" => "User not found." }
assert response == expected
end
We want a HTTP code of 404 to notify the requester that this resource was not found, as well as an accompanying error message.
Our controller action:
def show(conn, %{"id" => id}) do
case Repo.get(User, id) do
nil -> conn |> put_status(404) |> render("error.json")
user -> render conn, "show.json", user: user
end
end
And our view:
def render("error.json", _assigns) do
%{error: "User not found."}
end
With those implemented, our tests pass.
The rest of the controller is left to you to implement as practice. Happy testing!