TDD Phoenix

Creating Rooms

For our next feature, we could give our user the ability to create a chat room, join a chat room, or we could focus on the core of our application — being able to chat. If we were trying to build a minimum viable product, I would focus on users chatting. But since we're learning TDD, I want to focus on a user's ability to create a chat room. That will exercise our TDD muscles for CRUD applications, which is both more straightforward and common.

Writing the feature test

Let's start with a user story:

As a user, I want to visit the chat room index page and create a chat room by name.

Now let's create a feature test that will satisfy that user story. Create a new feature test file test/chatter_web/features/user_creates_new_chat_room_test.exs:

# test/chatter_web/features/user_creates_new_chat_room_test.exs

defmodule ChatterWeb.UserCreatesNewChatRoomTest do
  use ChatterWeb.FeatureCase, async: true

  test "user creates a new chat room successfully", %{session: session} do
    session
    |> visit(Routes.chat_room_path(@endpoint, :index))
    |> click(Query.link("New chat room"))
    |> fill_in(Query.text_field("Name"), with: "elixir")
    |> click(Query.button("Submit"))
    |> assert_has(Query.data("role", "room-title", text: "elixir"))
  end
end

Let's break down what we're doing in the body of the test since there are new things:

Hopefully, Wallaby's DSL is starting to look familiar.

Let's now run our test and have the failures guide us:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs


  1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)

    ** (Wallaby.QueryError) Expected to find 1, visible link 'New chat room' but 0, visible links were found.

    code: |> click(Query.link("New chat room"))
    stacktrace:
      (wallaby) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
      (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
      test/chatter_web/features/user_creates_new_chat_room_test.exs:7: (test)

Finished in 3.6 seconds
1 test, 1 failure

The test expects to find a "New chat room" link. Add that link in our chat rooms index page:

# lib/chatter_web/templates/chat_room/index.html.eex

    <li data-role="room"><%= room.name %></li>
  <% end %>
</ul>

<div>  <%= link "New chat room", to: Routes.chat_room_path(@conn, :new) %></div>

Run the test again:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

Request: GET /
** (exit) an exception was raised:
    ** (ArgumentError) no function clause for
    ChatterWeb.Router.Helpers.chat_room_path/2 and action :new. The following
    actions/clauses are supported:

        chat_room_path(conn_or_endpoint, :index, params \\ [])



  1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)

    ** (Wallaby.QueryError) Expected to find 1, visible link 'New chat room' but
    0, visible links were found.

        code: |> click(Query.link("New chat room"))
        stacktrace:
          (wallaby) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
          (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
          test/chatter_web/features/user_creates_new_chat_room_test.exs:7: (test)

Finished in 3.6 seconds
1 test, 1 failure

We made progress. Though the output seems similar to the previous failure, if we focus at the top of the error message, we see that the route for a new chat room is not defined.

** (ArgumentError) no function clause for ChatterWeb.Router.Helpers.chat_room_path/2 and action :new.

Let's add that route:

# lib/chatter_web/router.ex

   scope "/", ChatterWeb do
     pipe_through :browser

     resources "/chat_rooms", ChatRoomController, only: [:new]     get "/", ChatRoomController, :index
   end

Rerun the test:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

Server: localhost:4002 (http)
Request: GET /chat_rooms/new
  ** (exit) an exception was raised:
    ** (UndefinedFunctionError) function ChatterWeb.ChatRoomController.new/2 is undefined or private

The route is now defined, but the controller does not have the function new/2 defined. Let's define that next:

# lib/chatter_web/controllers/chat_room_controller.ex

  def new(conn, _params) do    render(conn, "new.html")  endend

Run the test again:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

Server: localhost:4002 (http)
Request: GET /chat_rooms/new
  ** (exit) an exception was raised:
    ** (Phoenix.Template.UndefinedError) Could not render "new.html" for
    ChatterWeb.ChatRoomView, please define a matching clause for render/2 or
    define a template at "lib/chatter_web/templates/chat_room". The following
    templates were compiled:

    * index.html

Good! We now get an undefined template error. Define an empty "new.html.eex" template for now: $ touch lib/chatter_web/templates/chat_room/new.html.eex.

Now run the test again:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

  1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)

      ** (Wallaby.QueryError) Expected to find 1, visible text input or
      textarea 'Name' but 0, visible text inputs or textareas were found.

      code: |> fill_in(Query.text_field("Name"), with: "elixir")
      stacktrace:
          (wallaby) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
          (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
          test/chatter_web/features/user_creates_new_chat_room_test.exs:8: (test)


Finished in 3.6 seconds
1 test, 1 failure

Very good! Our test now clicks on the "Add new room" link, and it takes us to the new room page. But since we do not have a form on that page, Wallaby cannot find a text field to fill out. Let's add the form to "new.html.eex" that we wish existed. Copy the following:

# lib/chatter_web/templates/chat_room/new.html.eex

<div>
  <%= form_for @changeset, Routes.chat_room_path(@conn, :create), fn f -> %>
    <label>
      Name:
      <%= error_tag f, :name %>
      <%= text_input f, :name %>
    </label>

    <%= submit "Submit" %>
  <% end %>
</div>

We don't have a @changeset in this template, and we don't have a chat_rooms#create path for Routes.chat_room_path(@conn, :create). So expect our test to have a failure related to one of those. Run the test now:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

Server: localhost:4002 (http)
Request: GET /chat_rooms/new
** (exit) an exception was raised:
    ** (ArgumentError) assign @changeset not available in eex template.

    Please make sure all proper assigns have been set. If this
    is a child template, ensure assigns are given explicitly by
    the parent template as they are not automatically forwarded.

    Available assigns: [:conn, :view_module, :view_template]

That output is large! I have only included the top portion above because that is the important part. We care about this error: ** (ArgumentError) assign @changeset not available in eex template. Let's assign that changeset in the controller. As before, we'll write the code we wish existed:

# lib/chatter_web/controllers/chat_room_controller.ex

  def new(conn, _params) do
    changeset = Chatter.Chat.new_chat_room()    render(conn, "new.html", changeset: changeset)  end
end

Now rerun the test:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

warning: function Chatter.Chat.new_chat_room/0 is undefined or private
  lib/chatter_web/controllers/chat_room_controller.ex:11

Server: localhost:4002 (http)
Request: GET /chat_rooms/new
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function Chatter.Chat.new_chat_room/0 is undefined or private

That's a lot of output again! If we focus near the top, we'll see we have an error and a warning about the same issue:

  • ** (UndefinedFunctionError) function Chatter.Chat.new_chat_room/0 is undefined or private, and
  • warning: function Chatter.Chat.new_chat_room/0 is undefined or private

The logic in Chatter.Chat.new_chat_room/0 should be part of our core business logic. So let's "step in" from the outside testing circle to the inside.

Stepping in to Chatter.ChatTest

As we did with the tests for Chatter.Chat.all_rooms/0, we'll write a failing test for Chatter.Chat.new_chat_room/0 that fails with the same error as our feature test. Add the following test to test/chatter/chat_test.exs:

# test/chatter/chat_test.exs

describe "new_chat_room/0" do
  test "prepares a changeset for a new chat room" do
    assert %Ecto.Changeset{} = Chat.new_chat_room()
  end
end

If we run that test now, we should see the same error that we got from our feature test. I will only run that specific test in chat_test.exs by adding the line number:

$ mix test test/chatter/chat_test.exs:19
Excluding tags: [:test]
Including tags: [line: "19"]


  1) test new_chat_room/0 prepares a changeset for a new chat room (Chatter.ChatTest)

    ** (UndefinedFunctionError) function Chatter.Chat.new_chat_room/0 is undefined or private
    code: assert %Ecto.Changeset{} = Chat.new_chat_room()
    stacktrace:
      (chatter) Chatter.Chat.new_chat_room()
      test/chatter/chat_test.exs:21: (test)


Finished in 0.05 seconds
2 tests, 1 failure, 1 excluded

Good. We have the same failure. I will point out a few things since this is the first time we're running a single test by line number. Note that ExUnit is excluding all other tests and only including [line: "19"]. And note how at the end we see 2 tests, 1 failure, 1 excluded which tells us there were two tests in the file, we excluded one, and we ran one which failed: ** (UndefinedFunctionError) function Chatter.Chat.new_chat_room/0 is undefined or private.

With that aside, let's now use the failure to guide the implementation of the function. First, define an empty function to get past the first error:

# lib/chatter/chat.ex

  def all_rooms do
    Chat.Room |> Repo.all()
  end

  def new_chat_room do  endend

Now run the test again:

$ mix test test/chatter/chat_test.exs:19

Excluding tags: [:test]
Including tags: [line: "19"]


  1) test new_chat_room/0 prepares a changeset for a new chat room
  (Chatter.ChatTest)

    match (=) failed
    code:  assert %Ecto.Changeset{} = Chat.new_chat_room()
    right: nil
    stacktrace:
    test/chatter/chat_test.exs:21: (test)


Finished in 0.05 seconds
2 tests, 1 failure, 1 excluded

Our test still fails, but the function is now defined. The failure is telling us that we got nil instead of the changeset we expected. Let's add a simple implementation to get our test to pass:

# lib/chatter/chat.ex

  def new_chat_room do
    %Chat.Room{}    |> Chat.Room.changeset(%{})  end

Recall we deleted the Chat.changeset/2 function in the last chapter because we were not using it at that point. We'll reintroduce it in this chapter, but we'll do it following our tests. So let's rerun our test:

$ mix test test/chatter/chat_test.exs:19

warning: function Chatter.Chat.Room.changeset/2 is undefined or private
  lib/chatter/chat.ex:10

  Excluding tags: [:test]
  Including tags: [line: "19"]


    1) test new_chat_room/0 prepares a changeset for a new chat room (Chatter.ChatTest)

      ** (UndefinedFunctionError) function Chatter.Chat.Room.changeset/2 is undefined
      or private
      code: assert %Ecto.Changeset{} = Chat.new_chat_room()
      stacktrace:
      (chatter) Chatter.Chat.Room.changeset(%Chatter.Chat.Room{__meta__:
      #Ecto.Schema.Metadata<:built, "chat_rooms">, id: nil, inserted_at: nil,
      name: nil, updated_at: nil}, %{})
      test/chatter/chat_test.exs:21: (test)


Finished in 0.05 seconds
2 tests, 1 failure, 1 excluded

We have an undefined function Chatter.Chat.Room.changeset/2 — as we might have expected — and we have a compilation warning notifying us of the same problem. So go ahead and define an empty changeset/2 function:

# lib/chatter/chat/room.ex

  schema "chat_rooms" do
    field :name, :string

    timestamps()
  end

  def changeset(struct, attrs) do  end

Running our test again:

$ mix test test/chatter/chat_test.exs:19

Excluding tags: [:test]
Including tags: [line: "19"]


    1) test new_chat_room/0 prepares a changeset for a new chat room (Chatter.ChatTest)

      match (=) failed
      code:  assert %Ecto.Changeset{} = Chat.new_chat_room()
      right: nil
      stacktrace:
        test/chatter/chat_test.exs:21: (test)


Finished in 0.04 seconds
2 tests, 1 failure, 1 excluded

Chat.new_chat_room/0 is now returning nil because our Chatter.Chat.Room.changeset/2 function returns nil. Let's add a basic implementation that casts the :name attribute with Ecto.Changeset.cast/3:

# lib/chatter/chat/room.ex

  use Ecto.Schema
  import Ecto.Changeset
  schema "chat_rooms" do
    field :name, :string

    timestamps()
  end

  def changeset(struct, attrs) do
    struct    |> cast(attrs, [:name])  end

If you recall, the auto-generated changeset/3 function we removed did more than just cast a value; we will add that functionality but through TDD. For now, our implementation should get our test to pass:

$ mix test test/chatter/chat_test.exs:19

Excluding tags: [:test]
Including tags: [line: "19"]

.

Finished in 0.04 seconds
2 tests, 0 failures, 1 excluded

Good! Our test is passing.

Next, we want our new changeset to validate the presence and uniqueness of a name. Let's write each of those requirements as tests for Chat.Room.changeset/2.

Stepping in to Chatter.Chat.RoomTest

At this point, we should ask ourselves, at what level should we be testing this? Should the tests for validating presence and uniqueness of the chat room's name be in test/chatter/chat_test.exs, or should we add them to a new test file: test/chatter/chat/room_test.exs?

In the same way that our feature tests are the outermost tests of our application — when considering the web as the delivery mechanism — our tests for the Chatter.Chat module are the outermost tests for our business logic. As such, they are integration tests that ensure our business logic as a whole works correctly. But the precise requirements of Chatter.Chat.Room.changeset/2 are a specific concern of the Chatter.Chat.Room module; thus, it is better that tests for the precise logic live in test/chatter/chat/room_test.exs.

So let's add some tests for Chatter.Chat.Room in test/chatter/chat/room_test.exs:

# test/chatter/chat/room_test.exs

defmodule Chatter.Chat.RoomTest do
  use Chatter.DataCase, async: true

  alias Chatter.Chat.Room

  describe "changeset/2" do
    test "validates that a name is provided" do
      changeset = Room.changeset(%Room{}, %{})

      assert "can't be blank" in errors_on(changeset).name
    end
  end
end

By now, we have seen similar test setups, but the errors_on/1 function is new. It is a helper defined in Chatter.DataCase that traverses through the changeset errors and makes them more accessible. Here we only care about the error on the name field.

Let's run our new test:

$ mix test test/chatter/chat/room_test.exs


  1) test changeset/2 validates that a name is provided (Chatter.Chat.RoomTest)

    ** (KeyError) key :name not found in: %{}
    code: assert "can't be blank" in errors_on(changeset).name
    stacktrace:
      test/chatter/chat/room_test.exs:10: (test)


Finished in 0.03 seconds
1 test, 1 failure

So we're back to a changeset that does not have an error for the name field. Let's add the validation:

# lib/chatter/chat/room.ex

  def changeset(struct, attrs) do
    struct
    |> cast(attrs, [:name])
    |> validate_required([:name])  end

Rerun the test:

$ mix test test/chatter/chat/room_test.exs

.

Finished in 0.03 seconds
1 test, 0 failures

Great! Now add a test that ensures rooms have unique names:

# test/chatter/chat/room_test.exs

  import Chatter.Factory

  describe "changeset/2" do
    #  test "validates that a name is provided"  in here

    test "validates that name is unique" do
      insert(:chat_room, name: "elixir")
      params = params_for(:chat_room, name: "elixir")

      {:error, changeset} =
        %Room{}
        |> Room.changeset(params)
        |> Repo.insert()

      assert "has already been taken" in errors_on(changeset).name
    end
  end

Before we run our test, let's talk about what we're doing in it:

  • We add import Chatter.Factory so we have access to our factory functions. Note that this is the second time we're importing our Chatter.Factory module when using Chater.DataCase, so it might be worth including it in there. We'll look into that later when we refactor.
  • We've seen the insert/1 function from our factory before, but we now see we can override specific attributes with insert/2. In this case, we want to make sure we create a factory with a particular name (instead of the auto-generated one). That way, we can try to insert a second record with the same name and get a uniqueness error.
  • We use params_for/2, another function from ExMachina. This function gives us the chat room factory as a map (not a struct) without being inserted into the database. For more, see params_for/1. We also set the name to "elixir" (matching the existing chat room) to violate the uniqueness constraint when trying to insert the record into the database.
  • In this test, we try to Repo.insert/1 the changeset because uniqueness constraints in Ecto work with the database. So, unlike our first test, we need to attempt to insert the record to receive the error.
  • Finally, we use the errors_on/1 helper in the test assertion.

Let's run the test and see what the failure tells us. I will only target that test:

% mix test test/chatter/chat/room_test.exs:15
Excluding tags: [:test]
Including tags: [line: "15"]



  1) test changeset/2 validates that name is unique (Chatter.Chat.RoomTest)

    ** (Ecto.ConstraintError) constraint error when attempting to insert struct:

      * chat_rooms_name_index (unique_constraint)

    If you would like to stop this constraint violation from raising an
    exception and instead add it as an error to your changeset, please call
    `unique_constraint/3` on your changeset with the constraint `:name` as an
    option.

    The changeset has not defined any constraint.

    code: |> Repo.insert()
    stacktrace:
      (ecto) lib/ecto/repo/schema.ex:687: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
      (elixir) lib/enum.ex:1327: Enum."-map/2-lists^map/1-0-"/2
      (ecto) lib/ecto/repo/schema.ex:672: Ecto.Repo.Schema.constraints_to_errors/3
      (ecto) lib/ecto/repo/schema.ex:274: anonymous fn/15 in Ecto.Repo.Schema.do_insert/4
      test/chatter/chat/room_test.exs:22: (test)


Finished in 0.07 seconds
2 tests, 1 failure, 1 excluded

Great. We get a very nice error from Ecto:

** (Ecto.ConstraintError) constraint error when attempting to insert struct:

  * chat_rooms_name_index (unique_constraint)

If you would like to stop this constraint violation from raising an
exception and instead add it as an error to your changeset, please call
`unique_constraint/3` on your changeset with the constraint `:name` as an
option.

We're getting the database constraint error, but since we do not declare a unique_constraint/3 in our schema, Ecto is not transforming that into a useful error in our changeset. Let's do what Ecto says: use unique_constraint/3 on our changeset with the constraint :name as an option:

# lib/chatter/chat/room.ex

  def changeset(struct, attrs) do
    struct
    |> cast(attrs, [:name])
    |> validate_required([:name])
    |> unique_constraint(:name)  end

Rerun the test:

$ mix test test/chatter/chat/room_test.exs:15

Excluding tags: [:test]
Including tags: [line: "15"]

.

Finished in 0.06 seconds
2 tests, 0 failures, 1 excluded

Great! Now let's run the complete room_test file to ensure all tests for Chatter.Chat.Room are passing:

$ mix test test/chatter/chat/room_test.exs
..

Finished in 0.07 seconds
2 tests, 0 failures

Excellent!

Since we've now implemented all the logic we wanted for Chatter.Chat.Room.changeset/2, let's begin stepping out. Run the Chatter.Chat tests in chat_test:

% mix test test/chatter/chat_test.exs
..

Finished in 0.08 seconds
2 tests, 0 failures

Good. Our core business logic tests are passing. Now let's step out one more level and run our feature test. The original error that brought us down this path was that a @changeset was undefined in our template. Since we have just finished implementing that code, I expect our feature test to get one step further and fail with a different error.

Creating the chat room

Run the feature test:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

Server: localhost:4002 (http)
Request: GET /chat_rooms/new
** (exit) an exception was raised:
    ** (ArgumentError) no function clause for
    ChatterWeb.Router.Helpers.chat_room_path/2 and action :create. The following
    actions/clauses are supported:

      chat_room_path(conn_or_endpoint, :index, params \\ [])
      chat_room_path(conn_or_endpoint, :new, params \\ [])

Once again the test output is large. But we now have a @changeset defined. Our feature test now fails because we do not have a :create route. We have routes for :index and :new but not for :create. Let's define it. Add the :create route to the resources "/chat_rooms" declaration:

# lib/chatter_web/router.ex

  scope "/", ChatterWeb do
    pipe_through :browser

    resources "/chat_rooms", ChatRoomController, only: [:new, :create]    get "/", ChatRoomController, :index
  end

Let's run our test again.

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

Server: localhost:4002 (http)
Request: POST /chat_rooms
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function ChatterWeb.ChatRoomController.create/2
    is undefined or private

The route is now defined, but our controller does not have the create/2 function. As before, we'll write the code as we want it to exist. For chat room creation, we want to know if the creation succeeded or failed. And if it failed, we want to know why it failed. So we expect to have two return values from our creation function: {:ok, room} or {:error, changeset}.

Let's start by writing code that goes through a successful creation, though I'll use a case/2 statement in anticipation of the error case and as a reminder to add error handling:

# lib/chatter_web/controllers/chat_room_controller.ex

def create(conn, %{"room" => room_params}) do
  case Chatter.Chat.create_chat_room(room_params) do
    {:ok, room} ->
      redirect(conn, to: Routes.chat_room_path(conn, :show, room))
  end
end

Let's run our feature test.

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

warning: function Chatter.Chat.create_chat_room/1 is undefined or private
  lib/chatter_web/controllers/chat_room_controller.ex:17

  Server: localhost:4002 (http)
  Request: POST /chat_rooms
  ** (exit) an exception was raised:
      ** (UndefinedFunctionError) function Chatter.Chat.create_chat_room/1 is
      undefined or private

We immediately see an error (and compiler warning) that Chatter.Chat.create_chat_room/1 is undefined. Since we're moving away from the web layer into our business logic, let's "step in" and write a test for Chatter.Chat that fails with the same error.

Open up test/chatter/chat_test.exs, and add the following test:

# test/chatter/chat_test.exs

describe "create_chat_room/1" do
  test "creates a room with valid params" do
    params = string_params_for(:chat_room)

    {:ok, room} = Chat.create_chat_room(params)

    assert %Chat.Room{} = room
    assert room.name == params["name"]
  end
end

Most of the test body should be familiar. The only new function is string_params_for/1 that comes from ExMachina. string_params_for/1 is similar to params_for/1 in that it returns a map instead of a struct. But string_params_for/1 returns a map with string keys instead of atoms. That is useful here because it more closely matches how parameters come through Phoenix controllers.

Let's run all tests in describe/2 block:

% mix test test/chatter/chat_test.exs:25
Excluding tags: [:test]
Including tags: [line: "25"]


  1) test create_chat_room/1 creates a room with valid params (Chatter.ChatTest)

  ** (UndefinedFunctionError) function Chatter.Chat.create_chat_room/1 is undefined or private
  code: {:ok, room} = Chat.create_chat_room(params)
  stacktrace:
    (chatter) Chatter.Chat.create_chat_room(%{"name" => "chat room 0"})
    test/chatter/chat_test.exs:29: (test)


Finished in 0.06 seconds
3 tests, 1 failure, 2 excluded

As expected, the function Chatter.Chat.create_chat_room/1 is undefined. Let's create a basic function with an empty body:

# lib/chatter/chat.ex

  def new_chat_room do
    %Chat.Room{}
    |> Chat.Room.changeset(%{})
  end

  def create_chat_room(params) do  end

Run the test:

$ mix test test/chatter/chat_test.exs:25


  1) test create_chat_room/1 creates a room with valid params (Chatter.ChatTest)

    ** (MatchError) no match of right hand side value: nil
    code: {:ok, room} = Chat.create_chat_room(params)
    stacktrace:
      test/chatter/chat_test.exs:29: (test)


Finished in 0.06 seconds
3 tests, 1 failure, 2 excluded

We get the expected error: nil instead of {:ok, room}, since the function's body is empty. Now add an implementation:

# lib/chatter/chat.ex

  def create_chat_room(params) do
    %Chat.Room{}    |> Chat.Room.changeset(params)    |> Repo.insert()  end

The function now return the {:ok, room} our test expects since that is Repo.insert/1's return value. Run the test to see it pass:

$ mix test test/chatter/chat_test.exs:25

.

Finished in 0.09 seconds
3 tests, 0 failures, 2 excluded

Great. Now let's add a test for when create_chat_room/1 fails. If you've worked with Ecto before, you'll notice that create_chat_room/1 already handles failures since Repo.insert/1 returns an {:error, changeset} on failure. But let's add the test anyway to ensure the failure path is covered and since it documents the desired behavior of the function.

# test/chatter/chat_test.exs

test "returns an error tuple if params are invalid" do
  insert(:chat_room, name: "elixir")
  params = string_params_for(:chat_room, name: "elixir")

  {:error, changeset} = Chat.create_chat_room(params)

  refute changeset.valid?
  assert "has already been taken" in errors_on(changeset).name
end

Let's run both tests in the describe block:

% mix test test/chatter/chat_test.exs:25

..

Finished in 0.1 seconds
4 tests, 0 failures, 2 excluded

Great!

Writing controller tests

Now that create_chat_room/1 is implemented, let's take step out to the controller and test the case when Chatter.Chat.create_chat_room/1 returns the {:error, changeset} tuple. Create the file test/chatter_web/controllers/chat_room_controller_test.exs and add the following test:

# test/chatter_web/controllers/chat_room_controller_test.exs

defmodule ChatterWeb.ChatRoomControllerTest do
  use ChatterWeb.ConnCase, async: true

  import Chatter.Factory

  describe "create/2" do
    test "renders new page with errors when data is invalid", %{conn: conn} do
      insert(:chat_room, name: "elixir")
      params = string_params_for(:chat_room, name: "elixir")

      response =
        conn
        |> post(Routes.chat_room_path(conn, :create), %{"room" => params})
        |> html_response(200)

      assert response =~ "has already been taken"
    end
  end
end

Since this is our first controller test, let's talk about its components:

  • We use ConnCase, another test template that comes with Phoenix. Among other things, it provides the Plug.Conn struct as an argument passed to the test: %{conn: conn}.
  • We use Phoenix test helpers post/3 and html_response/2. post/3 takes a connection, the path we're posting to, and a map of parameters. html_response/2 checks the status code and extracts the body of the response.
  • Finally, we check if the body of response has the string "has already been taken".

Now let's run the test:

$ mix test test/chatter_web/controllers/chat_room_controller_test.exs


  1) test create/2 renders new page with errors when data is invalid (ChatterWeb.ChatRoomControllerTest)

    ** (CaseClauseError) no case clause matching: {:error, #Ecto.Changeset<action: :insert, changes: %{name: "elixir"}, errors: [name: {"has already been taken", [constraint: :unique, constraint_name: "chat_rooms_name_index"]}], data: #Chatter.Chat.Room<>, valid?: false>}
    code: |> post(Routes.chat_room_path(conn, :create), %{"room" => params})
    stacktrace:
      (chatter) lib/chatter_web/controllers/chat_room_controller.ex:17: ChatterWeb.ChatRoomController.create/2

We see the expected error: the function Chatter.Chat.create_chat_room/1 returns an {:error, changeset} but we do not handle that case in the controller. Let's do that now:

# lib/chatter_web/controllers/chat_room_controller.ex

  def create(conn, %{"room" => room_params}) do
    case Chatter.Chat.create_chat_room(room_params) do
      {:ok, room} ->
        redirect(conn, to: Routes.chat_room_path(conn, :show, room))

      {:error, changeset} ->        render(conn, "new.html", changeset: changeset)    end
  end

Now rerun the test:

$ mix test test/chatter_web/controllers/chat_room_controller_test.exs

.

Finished in 0.1 seconds
1 test, 0 failures

Great!

Why not write a controller test for success?

A question may come up here as to why we wrote a controller test for the scenario when we fail to create a chat room but not when we successfully create one.

Test-driven development should give us confidence in our code — confidence that our system works and that we can safely refactor it. But tests are also a liability. They are code that we have to maintain for the life of our application, and they take a portion of our limited time every time we run them. So we should make sure the benefit of having a test is greater than its cost. And that is a subjective decision based on experience.

In my experience, writing a controller test for the successful case brings very little value, since we already test that functionality through the feature test. But testing the failure case is different since I don't usually write a feature test for the failure case. So I tend to write controller tests for failure cases but not for successful ones.

Showing the chat room

Let's step back out to our feature test and see what it tells us to do next:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

Server: localhost:4002 (http)
Request: POST /chat_rooms
** (exit) an exception was raised:
    ** (ArgumentError) no function clause for
    ChatterWeb.Router.Helpers.chat_room_path/3 and action :show. The following
    actions/clauses are supported:

      chat_room_path(conn_or_endpoint, :create, params \\ [])
      chat_room_path(conn_or_endpoint, :index, params \\ [])
      chat_room_path(conn_or_endpoint, :new, params \\ [])

When we successfully create a chat room, we try to redirect to a show page. But we do not have a show page or route. Thanks for letting us know test! Let's get started by adding the show route to the "/chat_rooms" resource.

# lib/chatter_web/router.ex

  scope "/", ChatterWeb do
    pipe_through :browser

    resources "/chat_rooms", ChatRoomController, only: [:new, :create, :show]    get "/", ChatRoomController, :index
  end

Now run the feature test to see our next step:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

Server: localhost:4002 (http)
Request: GET /chat_rooms/61
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function ChatterWeb.ChatRoomController.show/2 is undefined or private

The route is defined, but we still lack a show/2 action in the controller. Let's add that action:

# lib/chatter_web/controllers/chat_room_controller.ex

  def show(conn, %{"id" => id}) do
    room = Chatter.Chat.find_room(id)

    render(conn, "show.html", chat_room: room)
  end

As before, we've added code that does not yet exist. Let's see what our test failure says to do next:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

warning: function Chatter.Chat.find_room/1 is undefined or private
  lib/chatter_web/controllers/chat_room_controller.ex:27

Server: localhost:4002 (http)
Request: GET /chat_rooms/63
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function Chatter.Chat.find_room/1 is undefined or private

The error output is large. At the top we see Chatter.Chat.find_room/1 function is undefined. Since the Chatter.Chat module is where our business logic begins, let's "step in" by adding a test for that find_room/1 function that gives us the same failure.

# test/chatter/chat_test.exs

describe "find_room/1" do
  test "retrieves a room by id" do
    room = insert(:chat_room)

    found_room = Chatter.Chat.find_room(room.id)

    assert room == found_room
  end
end

Run the test:

$ mix test test/chatter/chat_test.exs:46
Excluding tags: [:test]
Including tags: [line: "46"]


  1) test find_room/1 retrieves a room by id (Chatter.ChatTest)

    ** (UndefinedFunctionError) function Chatter.Chat.find_room/1 is undefined or private
    code: found_room = Chatter.Chat.find_room(room.id)
    stacktrace:
      (chatter) Chatter.Chat.find_room(64)
      test/chatter/chat_test.exs:50: (test)


Finished in 0.1 seconds
5 tests, 1 failure, 4 excluded

Good. Let's add an empty function with that name:

# lib/chatter/chat.ex

def find_room(id) do
end

Rerun the test:

$ mix test test/chatter/chat_test.exs:46

warning: variable "id" is unused (if the variable is not meant to be used, prefix it with an underscore)
  lib/chatter/chat.ex:19

Excluding tags: [:test]
Including tags: [line: "46"]


    1) test find_room/1 retrieves a room by id (Chatter.ChatTest)

       Assertion with == failed
       code:  assert room == found_room
       left:  %Chatter.Chat.Room{
                __meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">,
                id: 66,
                inserted_at: ~N[2019-10-16 10:18:40],
                name: "chat room 0",
                updated_at: ~N[2019-10-16 10:18:40]
              }
        right: nil
        stacktrace:
          test/chatter/chat_test.exs:52: (test)


Finished in 0.1 seconds
5 tests, 1 failure, 4 excluded

That is as expected since we're returning nil. Now fetch a chat room by id:

# lib/chatter/chat.ex

  def find_room(id) do
    Chat.Room |> Repo.get!(id)  end

And rerun the test:

$ mix test test/chatter/chat_test.exs:46

Excluding tags: [:test]
Including tags: [line: "46"]

.

Finished in 0.1 seconds
5 tests, 0 failures, 4 excluded

It passes! Excellent.

Now let's step out, and see what the feature test says to do next:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

Server: localhost:4002 (http)
Request: GET /chat_rooms/68
** (exit) an exception was raised:
    ** (Phoenix.Template.UndefinedError) Could not render "show.html" for
    ChatterWeb.ChatRoomView, please define a matching clause for render/2 or
    define a template at "lib/chatter_web/templates/chat_room". The following
    templates were compiled:

    * index.html
    * new.html

So we're able to retrieve the chat room from the database, but we have no "show.html" template to render. Let's create that template, and run our test again:

$ touch lib/chatter_web/templates/chat_room/show.html.eex
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

  1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)

    ** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with
    the attribute 'data-role' with value 'room-title' but 0, visible elements
    with the attribute were found.

    code: |> assert_has(Query.data("role", "room-title", text: "elixir"))
    stacktrace:
      test/chatter_web/features/user_creates_new_chat_room_test.exs:10: (test)


Finished in 3.7 seconds
1 test, 1 failure

Great. We're so close! Wallaby is finally telling us that it cannot find an element with a "room-title" data-role that has the text "elixir" (which is the name of the chat room we just created). There are no other warnings or errors. So go ahead and add an h1 tag with the chat room name.

# lib/chatter_web/templates/chat_room/show.html.eex

<h1 data-role="room-title"><%= @chat_room.name %></h1>

And run the test one more time:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

.

Finished in 0.7 seconds
1 test, 0 failures

Most excellent! Give yourself a round of applause. That was a full feature implemented with TDD. If the process still seems long, hang in there. It'll become second nature, and you'll code so much faster with it.

Since we finished the implementation, let's run our test suite to make sure nothing broke. Then we will refactor and clean up.

$ mix test
.............

Finished in 0.7 seconds
13 tests, 0 failures

Perfect!

Refactoring

As with our previous feature, there might not be much refactoring we need to do. Most of the code is clean and straightforward. But once again, I think we can improve our tests. Let's start with the feature test.

Using the language of stakeholders

Just as with the user_visits_rooms_page_test, let's write our new test in the language of our stakeholders. Start by running it, and remember to run it after each change to ensure our refactoring is working.

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs

.

Finished in 0.8 seconds
1 test, 0 failures

Now we'll extract some private functions like we did last time. Start with Routes.chat_room_path(@endpoint, :index):

# test/chatter_web/features/user_creates_new_chat_room_test.exs

  test "user creates a new chat room successfully", %{session: session} do
    session
-   |> visit(Routes.chat_room_path(@endpoint, :index))
+   |> visit(rooms_index())
    |> click(Query.link("New chat room"))
    |> fill_in(Query.text_field("Name"), with: "elixir")
    |> click(Query.button("Submit"))
    |> assert_has(Query.data("role", "room-title", text: "elixir"))
  end
+
+ defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)

Run the test:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
.

Finished in 0.7 seconds
1 test, 0 failures

Now extract Query.link("New chat room") into a private function:

# test/chatter_web/features/user_creates_new_chat_room_test.exs

  test "user creates a new chat room successfully", %{session: session} do
    session
    |> visit(rooms_index())
-   |> click(Query.link("New chat room"))
+   |> click(new_chat_link())
    |> fill_in(Query.text_field("Name"), with: "elixir")
    |> click(Query.button("Submit"))
    |> assert_has(Query.data("role", "room-title", text: "elixir"))
  end

  defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)
+
+ defp new_chat_link, do: Query.link("New chat room")

And rerun the test:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
.

Finished in 0.7 seconds
1 test, 0 failures

Now let's get more ambitious — extract the whole process of creating a new chat room into a private function:

# test/chatter_web/features/user_creates_new_chat_room_test.exs

  test "user creates a new chat room successfully", %{session: session} do
    session
    |> visit(rooms_index())
    |> click(new_chat_link())
-   |> fill_in(Query.text_field("Name"), with: "elixir")
-   |> click(Query.button("Submit"))
+   |> create_chat_room(name: "elixir")
    |> assert_has(Query.data("role", "room-title", text: "elixir"))
  end

  defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)

  defp new_chat_link, do: Query.link("New chat room")
+
+ defp create_chat_room(session, name: name) do
+   session
+   |> fill_in(Query.text_field("Name"), with: name)
+   |> click(Query.button("Submit"))
+ end

Rerun the test:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
.

Finished in 0.9 seconds
1 test, 0 failures

Let's extract one more private function for the data query:

# test/chatter_web/features/user_creates_new_chat_room_test.exs

  test "user creates a new chat room successfully", %{session: session} do
    session
    |> visit(rooms_index())
    |> click(new_chat_link())
    |> create_chat_room(name: "elixir")
-   |> assert_has(Query.data("role", "room-title", text: "elixir"))
+   |> assert_has(room_title("elixir"))
  end

  defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)

  defp new_chat_link, do: Query.link("New chat room")

  defp create_chat_room(session, name: name) do
    session
    |> fill_in(Query.text_field("Name"), with: name)
    |> click(Query.button("Submit"))
  end
+
+ defp room_title(title) do
+   Query.data("role", "room-title", text: title)
+ end

Run the test one more time:

$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
.

Finished in 0.7 seconds
1 test, 0 failures

Excellent! I think a stakeholder could easily read this final version as a story: "A user visits the chat room's index page, clicks on a new chat room link, creates a chat room with the name "elixir", and they subsequently see the new chat room with "elixir" as its title."

We could continue to refactor our feature tests and consolidate seemingly duplicated logic across tests into a standalone module. But I am not convinced we should do that yet. I prefer waiting a little longer to have the right abstractions. For now, extracting private functions gives the desired legibility while keeping the implementation in the same file.

Importing Chatter.Factory by default

There's one more thing we do to clean our tests: import the factory module.

Importing in Chatter.DataCase

test/chatter/chat_test.exs and test/chatter/chat/room_test.exs both import Chatter.Factory. Both of those tests also use Chatter.DataCase. So I think it makes sense to import Chatter.Factory as part of the Chatter.DataCase. Let's do that.

First, run the two tests (test/chatter/chat_test.exs and test/chatter/chat/room_test.exs) to make sure they are both passing. You can run more than one test at a time by listing all the file names:

$ mix test test/chatter/chat_test.exs test/chatter/chat/room_test.exs
.......

Finished in 0.1 seconds
7 tests, 0 failures

Now go ahead and open Chatter.DataCase and move import Chatter.Factory into the using/1 function:

# test/support/data_case.ex

  using do
    quote do
      alias Chatter.Repo

      import Ecto
      import Ecto.Changeset
      import Ecto.Query
      import Chatter.DataCase
      import Chatter.Factory    end
  end

Now remove the import declaration from both tests:

# test/chatter/chat_test.exs

defmodule Chatter.ChatTest do
  use Chatter.DataCase, async: true
-
- import Chatter.Factory

  alias Chatter.Chat
# test/chatter/chat/room_test.exs

defmodule Chatter.Chat.RoomTest do
  use Chatter.DataCase, async: true
-
- import Chatter.Factory

  alias Chatter.Chat.Room

Finally, rerun both test files. All seven tests should pass:

$ mix test test/chatter/chat_test.exs test/chatter/chat/room_test.exs

.......

Finished in 0.1 seconds
7 tests, 0 failures

Importing in ChatterWeb.ConnCase

Though we have few controller tests, we'll likely import Chatter.Factory in most of them because params_for/2 and string_params/2 help generate test data. So let's move the importing of Chatter.Factory into ChatterWeb.ConnCase.

First, run the chat_room_controller_test so we have a passing test during our changes:

$ mix test test/chatter_web/controllers/chat_room_controller_test.exs

.

Finished in 0.1 seconds
1 test, 0 failures

Now go ahead and move the import declaration into ChatterWeb.ConnCase:

# test/support/conn_case.ex

  using do
    quote do
      use Phoenix.ConnTest
      import Chatter.Factory      alias ChatterWeb.Router.Helpers, as: Routes

      @endpoint ChatterWeb.Endpoint
    end
  end

And remove it from the controller test:

# test/chatter_web/controllers/chat_room_controller_test.exs

defmodule ChatterWeb.ChatRoomControllerTest do
  use ChatterWeb.ConnCase, async: true
-
- import Chatter.Factory

  describe "create/2" do

Now rerun the test:

$ mix test test/chatter_web/controllers/chat_room_controller_test.exs

.

Finished in 0.1 seconds
1 test, 0 failures

Good. Let's now run our test suite to make sure everything is working:

$ mix test
.............

Finished in 0.7 seconds
13 tests, 0 failures

Perfect! This is an excellent place to stop for now. Let's commit this work and move on to the next chapter.