Overview
Since our application is a social media platform, let’s start with a Profile
. Our profiles will be different than a User.
Users will just be used for authentication, plus, this pattern will allow us to scale our application. For instance, when a “Profile” grows to millions of followers, the owner of the profile might want to add moderators, and, moderators might manage several profiles. Having our users and profiles the same would make this shift difficult. The amount of effort to split this out up front is minimal compared to the changes required down the road.
Let’s generate our Profile
$ docker-compose run --rm server \
mix phx.gen.json Profiles Profile profiles \
username:string:unique \
display_name:string \
about_me:string \
status:string
Before we can continue, we need to add the Profile
route like the generator tells us, otherwise we will get a compilation error because of undefined function profile_path/3
.
Since we are building an API, we can exclude the new
and edit
actions from the route with except: [:new, :edit]
.
# server/src/lib/get_social_web/router.ex
defmodule ScreencastsWeb.Router do
# ...
scope "/api", ScreencastsWeb do
# ...
resources "/profiles", ProfileController, except: [:new, :edit]
end
end
Then we can migrate…
$ docker-compose run --rm server \
mix ecto.migrate
and finally run our tests…
$ docker-compose run --rm server \
mix test.watch
If you’ve already setup JSONAPI then you should have a list of failures when you run mix test
again. Otherwise, your tests will be all green. For this tutorial, we expect the tests to fail since we expect a JSONAPI payload.
If your tests are passing, go follow the JSONAPI tutorial to break everything.
Setup
The first thing we need to do is update our tests to send application/vnd.api+json
instead of application/json
. To do this, we need to change our setup
block to:
defmodule GetSocialWeb.ProfileControllerTest do
# ...
setup %{conn: conn} do
conn = conn
|> put_req_header("accept", "application/vnd.api+json")
|> put_req_header("content-type", "application/vnd.api+json")
{:ok, conn: conn}
end
# ...
end
After fixing our request headers, we should recieve a new error in our tests:
** (Phoenix.ActionClauseError) no function clause matching in GetSocialWeb.ProfileController.update/2
This is because our controller is pattern matching on a normal json payload %{"id" => id, "profile" => profile_params}
. To fix this, we just need to replace all instances of "profile" => profile_params
with "data" => profile_params
in server/src/lib/get_social_web/controllers/profile_controller.ex
.
Next, let’s get rid of the warnings:
warning: Passing data via `:model`, `:profiles` or `:profile`
atoms to JaSerializer.PhoenixView has be deprecated. Please use
`:data` instead. This will stop working in a future version.
To fix this, we need to update server/src/lib/get_social_web/controllers/profile_controller.ex
again. We need to replace all instances of profiles: profiles
with data: profiles
and profile: profile
with data: profile
.
warning: Please use show.json-api instead. This will stop working in a future version.
This one is just replacing all .json
with .json-api
in server/src/lib/get_social_web/controllers/profile_controller.ex
.
With that, we can move on to cleaning up our controller tests.
Request Helpers
The generated controller tests have a lot of duplicate code, and, in my opinion, it gets difficult to read. Let’s clean them up a bit by refactoring the requests.
Let’s start by refactoring our index
requests into a request_index(conn)
method
# server/src/test/get_social_web/controllers/profile_controller_test.exs
defmodule GetSocialWeb.ProfileControllerTest do
# ...
def request_index(conn) do
get conn, Routes.profile_path(conn, :index)
end
end
This allows us to replace conn = get(conn, Routes.profile_path(conn, :index))
with conn = conn |> request_index()
. Not a huge gain on its own, but, let’s keep going.
Let’s add helpers for show
, update
, create
and delete
…
defmodule GetSocialWeb.ProfileControllerTest do
# ...
def request_show(conn, resource_or_id) do
path = conn |> Routes.profile_path(:show, resource_or_id)
conn |> get(path)
end
def request_update(conn, resource_or_id, attrs) do
payload = %{ data: %{ attributes: attrs } }
path = conn |> Routes.profile_path(:update, resource_or_id)
conn |> put(path, payload)
end
def request_create(conn, attrs \\ %{}) do
path = conn |> Routes.profile_path(:create)
payload = %{ data: %{ attributes: attrs } }
conn |> post(path, payload)
end
def request_delete(conn, resource_or_id) do
path = conn |> Routes.profile_path(:delete, resource_or_id)
conn |> delete(path)
end
end
Now, let’s clean up our tests in server/src/test/get_social_web/controllers/profile_controller_test.exs
:
Before: post(conn, Routes.profile_path(conn, :create), profile: @create_attrs)
After: request_create(conn, @create_attrs)
Before: post(conn, Routes.profile_path(conn, :create), profile: @invalid_attrs)
After: request_create(conn, @invalid_attrs)
Before: put(conn, Routes.profile_path(conn, :update, profile), profile: @update_attrs)
After: request_update(conn, profile, @update_attrs)
Before: put(conn, Routes.profile_path(conn, :update, profile), profile: @invalid_attrs)
After: request_update(conn, profile, @invalid_attrs)
Before: delete(conn, Routes.profile_path(conn, :delete, profile))
After: request_delete(conn, profile)
Before: get(conn, Routes.profile_path(conn, :show, id))
After: request_show(conn, id)
With that, we should only have 2 failing tests validating the API response. These are failing because we are now sending a JSON:API payload instead of standard JSON. We will actually be unit testing our views, so, we can reomve that from our controller tests. Instead, we will just test for a proper JSON:API response and status code.
Let’s create a new helper method that will replace the json_response\2
.
# server/src/test/get_social_web/controllers/profile_controller_test.exs
defmodule GetSocialWeb.ProfileControllerTest do
# ...
def jsonapi_response(conn, status, version \\ "1.0") do
response = conn |> json_response(status)
assert %{ "jsonapi" => %{ "version" => ^version } } = response
response
end
end
This method passes the conn
and status
onto the json_response
and returns the result; it also asserts that we have the jsonapi
metadata containing a version. Now, we can do a find and replace in our controller test to change json_response
to jsonapi_response
. If we run the tests again, we now have four failing tests. The two new failures are because our error responses aren’t JSON:API formatted.
Luckily, ja_serializer
gives us JaSerializer.EctoErrorSerializer
. All we have to do is update our ChangesetView#render
method:
# lib/get_social_web/views/changeset_view.ex
defmodule GetSocialWeb.ChangesetView do
use GetSocialWeb, :view
def render("error.json", %{changeset: changeset}) do
JaSerializer.EctoErrorSerializer.format changeset
end
end
With our ChangesetView
update, we should be back to two failing tests.
Now we can clean up the rest of our controller tests so we are only checking for a status code and JSON:API metadata:
# server/src/test/get_social_web/controllers/profile_controller_test.exs
defmodule GetSocialWeb.ProfileControllerTest do
# ...
describe "index" do
test "lists all profiles", %{conn: conn} do
assert conn
|> request_index()
|> jsonapi_response(200)
end
end
describe "create profile" do
test "renders profile when data is valid", %{conn: conn} do
assert conn
|> request_create(@create_attrs)
|> jsonapi_response(201)
end
test "renders errors when data is invalid", %{conn: conn} do
assert conn
|> request_create(@invalid_attrs)
|> jsonapi_response(422)
end
end
describe "update profile" do
# ...
test "renders profile when data is valid", %{conn: conn, profile: %Profile{} = profile} do
assert conn
|> request_update(profile, @update_attrs)
|> jsonapi_response(200)
end
test "renders errors when data is invalid", %{conn: conn, profile: profile} do
assert conn
|> request_update(profile, @invalid_attrs)
|> jsonapi_response(422)
end
end
describe "delete profile" do
# ...
test "deletes chosen profile", %{conn: conn, profile: profile} do
assert conn
|> request_delete(profile)
|> response(204)
end
end
# ...
end
We’ve substantially cleaned up our controller tests, but, we’ve removed the get
tests that were rolled into the delete
and create
tests. Let’s add new tests for retrieving a profile that exists, and a test for a profile that doesn’t exist:
# server/src/test/get_social_web/controllers/profile_controller_test.exs
defmodule GetSocialWeb.ProfileControllerTest do
# ...
describe "show profile" do
setup [:create_profile]
test "renders profile when it exists", %{conn: conn, profile: %Profile{} = profile} do
assert conn
|> request_show(profile)
|> jsonapi_response(200)
end
test "renders errors when it does not exist", %{conn: conn} do
assert_error_sent 404, fn ->
assert conn
|> request_show(Ecto.UUID.generate)
|> jsonapi_response(404)
end
end
end
# ...
end
View Tests
Testing the response body in the controller tests can lead to a lot of code duplication, plus, it’s not really testing the whole story. Instead, we will unit test our views.
First thing we will do is create a new ViewCase based on the ConnCase
# server/src/test/support/view_case.ex
defmodule GetSocialWeb.ViewCase do
@moduledoc """
This module defines the test case to be used by
tests for views defined in the application.
"""
use ExUnit.CaseTemplate
using do
quote do
import Phoenix.View, only: [render: 3]
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(GetSocial.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(GetSocial.Repo, {:shared, self()})
end
:ok
end
end
Our ViewCase
is almost an exact duplicate of ConnCase
, except, it only brings in the render\3
method from Phoenix.View
.
Now we can write out tests for out ProfileView
. Previously, we were using the controller tests to verify our response body. Let’s go back and see what those looked like:
defmodule GetSocialWeb.ProfileControllerTest do
# ...
describe "create profile" do
# ...
test "renders profile when data is valid", %{conn: conn} do
# ...
assert %{
"id" => id,
"about_me" => "some about_me",
"display_name" => "some display_name",
"status" => "some status",
"username" => "some username"
} = jsonapi_response(conn, 200)["data"]
end
# ...
end
# ...
end
Let’s create a new view test for our ProfileView
.
# server/src/test/get_social_web/views/profile_view_test.exs
defmodule GetSocialWeb.ProfileViewTest do
use GetSocialWeb.ViewCase
alias GetSocial.Profiles
@create_attrs %{
about_me: "some about_me",
display_name: "some display_name",
status: "some status",
username: "some username"
}
test "renders all attributes" do
{:ok, data} = Profiles.create_profile(@create_attrs)
rendered_json = render(GetSocialWeb.ProfileView, "show.json-api", %{data: data})
expected_json = %{
"data" => %{
"id" => data.id,
"type" => "profile",
"attributes" => %{
"about-me" => "some updated about_me",
"display-name" => "some updated display_name",
"status" => "some updated status",
"username" => "some updated username"
}
},
"jsonapi" => %{
"version" => "1.0"
}
}
assert rendered_json == expected_json
end
end
Notice the changes in attribute keys from about_me
to about-me
, this is because we’ve configured our api to use the JaSerializer.Deserializer
plug. Dasherized keys is what ember data expects to recieve.
When we run our tests again, we get a failure. It appears our attributes are not being output by our view. Let’s go update our ProfileView
to output the correct attributes.
We can remove all of the render
methods and replace them with a single attributes
macro.
# server/src/lib/get_social_web/views/profile_view.ex
defmodule GetSocialWeb.ProfileView do
use GetSocialWeb, :view
attributes [
:about_me,
:display_name,
:status,
:username
]
end
With our happy path tests fixed, let’s add some validation tests.
# server/src/test/get_social_web/views/profile_view_test.exs
defmodule GetSocialWeb.ProfileViewTest do
# ...
test "renders all errors properly" do
{:error, data} = Profiles.create_profile(%{})
rendered_json = render(GetSocialWeb.ProfileView, "errors.json-api", %{data: data})
expected_json = %{
"errors" => [
%{
detail: "Username can't be blank",
source: %{ pointer: "/data/attributes/username"},
title: "can't be blank"
},
%{
detail: "Display name can't be blank",
source: %{ pointer: "/data/attributes/display-name"},
title: "can't be blank"
},
%{
detail: "About me can't be blank",
source: %{ pointer: "/data/attributes/about-me"},
title: "can't be blank"
},
%{
detail: "Status can't be blank",
source: %{ pointer: "/data/attributes/status"},
title: "can't be blank"
}
],
"jsonapi" => %{
"version" => "1.0"
}
}
assert rendered_json == expected_json
end
end
Now that we have all of our tests passing and cleaned up, let’s take one more pass at refactoring our new helper methods to something that can be reused in all of our controller tests.
Enhancing ConnCase
Let’s refactor those helpers into a new test case that builds on the default ConnCase
adding more tailored helpers for our JSONAPI. Let’s copy test/support/conn_case.ex
to test/support/api_case.ex
. Then in our controller test, we replace use GetSocialWeb.ConnCase
with use GetSocialWeb.ApiCase
.
First thing we will do in our new ApiCase
is set the request headers.
defmodule GetSocialWeb.ApiCase do
# ...
use Phoenix.ConnTest
setup tags do
# ...
conn = build_conn()
|> put_req_header("accept", "application/vnd.api+json")
|> put_req_header("content-type", "application/vnd.api+json")
{:ok, conn: conn}
end
end
This means we can remove the setup
block on our controller test.
Now, we can move our request_*
helpers into the ApiCase
using a macro.
defmodule GetSocialWeb.ApiCase do
# ...
alias GetSocialWeb.Router.Helpers, as: Routes
using do
quote do
# ...
GetSocialWeb.ApiCase.define_request_helper_methods()
end
end
# ...
defmacro define_request_helper_methods(), do: do_add_request_helper_methods()
defp do_add_request_helper_methods() do
quote do
def request_index(conn) do
# ...
end
def request_show(conn, resource_or_id) do
# ...
end
def request_update(conn, resource_or_id, attrs) do
# ...
end
def request_create(conn, attrs \\ %{}) do
# ...
end
def request_delete(conn, resource_or_id) do
# ...
end
def jsonapi_response(conn, status, version \\ "1.0") do
# ...
end
end
end
end
However, we are still referencing “profile” in our request helpers with profile_path
. Our ApiCase
should be generic enough that we can use it for any controller test.
Let replace any reference to Routes.profile_path
with a new path_for
method. For now, we will just wrap profile_path
.
# server/src/test/support/api_case.ex
defmodule GetSocialWeb.ApiCase do
# ...
defp do_add_request_helper_methods() do
quote do
# ...
defp path_for(conn, action, resource_or_id) do
conn |> Routes.profile_path(action, resource_or_id)
end
defp path_for(conn, action) do
conn |> Routes.profile_path(action)
end
end
end
end
Next, in order to replace profile_path
we need to know what type of resource we are working with. To get this information, we will pass an argument along with our use GetSocialWeb.ApiCase
.
# server/src/test/get_social_web/controllers/profile_controller_test.exs
defmodule GetSocialWeb.ProfileControllerTest do
use GetSocialWeb.ApiCase, resource_name: :profile
# ...
end
This will give us enough to dynamically generate some helpers in out ApiCase
. Now we just need to pass resource_name
down into our macro using using/1
to capture the options passed. Then we will unquote
and pass these options onto our GetSocialWeb.ApiCase.define_request_helper_methods
method.
defmodule GetSocialWeb.ApiCase do
# ...
using(opts) do
quote do
# Import conveniences for testing with connections
use Phoenix.ConnTest
import GetSocialWeb.Router.Helpers
import GetSocial.Factory
# The default endpoint for testing
@endpoint GetSocialWeb.Endpoint
GetSocialWeb.ApiCase.define_request_helper_methods(unquote(opts))
end
end
# ...
defmacro define_request_helper_methods(), do: do_add_request_helper_methods()
# ...
end
Now, we only want to run do_add_request_helper_methods
if we have a resource_name
. Let’s update our macro to use pattern matching:
defmacro define_request_helper_methods(resource_name: resource_name), do: do_add_request_helper_methods(resource_name)
defmacro define_request_helper_methods(_), do: nil
And accept resource_name
as an argument to do_add_request_helper_methods
defp do_add_request_helper_methods(resource_name) do
# ...
end
With resource_name
we can define a new path_helper_method
defp do_add_request_helper_methods(resource_name) do
quote do
defp path_helper_method, do: "#{unquote(resource_name)}_path" |> String.to_atom
# ...
end
end
Now we can update our path_for
methods. We will use apply/3 to call a method on our Router.Helpers
module by the atom returned by our new path_helper_method
.
defp path_for(conn, action, resource_or_id) do
apply(GetSocialWeb.Router.Helpers, path_helper_method(), [conn, action, resource_or_id])
end
defp path_for(conn, action) do
apply(GetSocialWeb.Router.Helpers, path_helper_method(), [conn, action])
end
The last thing to do is create a replacement for the create_profile\1
in our controller tests. In our ApiCase
we are going to use our resource_name
parameter to generate a create_#{resource_name}
method using unquote
. We’re also going to use the resource_name
as the key in our map that we return that is passed into the test case.
defp do_add_request_helper_methods(resource_name) do
quote do
def unquote(:"create_#{resource_name}")(_) do
key = unquote(resource_name)
resource = unquote(resource_name) |> fixture()
%{ key => resource }
end
# ...
end
end
Now we can delete the create_profile\1
from our controller test. With that, our tests are still passing and we’ve separated our view concerns from our controller tests.
Fixtures
Another place we can clean up our tests is the fixture data. Currently we manually build an maintain each fixture. To eliminate this headache, we will use ex_machina to generate our test data for us.
To start, we need to add ex_machina to our mix file…
# server/src/mix.exs
defp deps do
[
# ...
{:ex_machina, "~> 2.2", only: :test}
]
end
and install…
$ docker-compose run --rm server \
mix deps.get
Next we will setup the factory for our Profile
.
# server/src/test/support/factory.ex
defmodule GetSocial.Factory do
use ExMachina.Ecto, repo: GetSocial.Repo
def profile_factory do
%GetSocial.Profiles.Profile{
about_me: sequence("about_me"),
display_name: sequence("display_name"),
status: "some status",
username: sequence("username")
}
end
def profile_invalid_factory do
%GetSocial.Profiles.Profile{
about_me: "",
display_name: "",
status: "",
username: ""
}
end
end
We now need to update our controller tests to use our factory. To do this, we just need to tweak our ApiCase
:
# server/src/test/support/api_case.ex
defmodule GetSocialWeb.ApiCase do
# ...
using(opts) do
quote do
# ...
import GetSocial.Factory
# ...
end
end
# ...
defp do_add_request_helper_methods(resource_name) do
quote do
def fixture(resource_name), do: insert(resource_name)
# ...
end
end
Then we can replace the @create_attrs
with params_for(:profile)
, @update_attrs
with params_for(:profile)
, and @invalid_attrs
with params_for(:profile_invalid)
.
We can also remove the def fixture(:profile)
; which means we can also remove the GetSocial.Profiles
alias.
# server/src/test/get_social_web/controllers/profile_controller_test.exs
defmodule GetSocialWeb.ProfileControllerTest do
use GetSocialWeb.ApiCase, resource_name: :profile
alias GetSocial.Profiles.Profile
@create_attrs params_for(:profile)
@update_attrs params_for(:profile)
@invalid_attrs params_for(:profile_invalid)
# ...
end