Test Suite Setup

12/18/2018


Foundations
JSON:API Setup

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
Next Level
MessageBus / EventBus

© 2020 ThinkAddict.com. All rights reserved.