TDD Phoenix

Showing a List of Chat Rooms

In the previous chapter, we set up our smoke test, but that wasn't really testing any application functionality. In this chapter, we'll test-drive our first feature.

When thinking about which feature to write first, I like to think of our application's core domain. Many times, we're tempted to write the authentication process first since we usually need users for our application. But authentication is seldom essential to what our application is doing. Instead, I prefer to start with a slice of the core of my application's domain.

I try to put my product hat on and think, what is the most straightforward feature I can write that would contribute to the core of my application? If I had to ship something tomorrow, what could I not live without?

Since we're writing a chat app, core pieces include a user's interactions with chats and chat rooms:

  • Seeing a list of available chat rooms
  • Joining a chat room
  • Sending messages in a chat room
  • Creating a chat room

Of those, the first one seems simplest, and also necessary for the rest of the interactions with our application  Let's start with that.

Feature test

As a user, I would like to see a list of all available chats.

Since we have a clear feature to work on, we can now turn our attention to writing a feature test that, once complete, will ensure we have satisfied that feature's requirements. Let's describe the users visiting the rooms index path and seeing a list of rooms they can join:

# test/chatter_web/features/user_visits_rooms_page_test.exs

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

  test "user visits rooms page to see a list of rooms", %{session: session} do
    room = insert(:chat_room)

    session
    |> visit(Routes.chat_room_path(@endpoint, :index))
    |> assert_has(Query.css(".room", text: room.name))
  end
end

session, visit/2, assert_has/2, and Query.css/2 are old friends we saw in the smoke test. They come from use Wallaby.DSL. But we haven't seen that insert/1 function yet. We will define one soon with a factory library called ExMachina. But first, let's run our test and see if we get a failure related to that.

mix test test/chatter_web/features/user_visits_rooms_page_test.exs

== Compilation error in file test/chatter_web/features/user_visits_rooms_page_test.exs ==
** (CompileError) test/chatter_web/features/user_visits_rooms_page_test.exs:5: undefined function insert/1
    (elixir) src/elixir_locals.erl:107: :elixir_locals."-ensure_no_undefined_local/3-lc$^0/1-0-"/2
    (elixir) src/elixir_locals.erl:108: anonymous fn/3 in :elixir_locals.ensure_no_undefined_local/3
    (stdlib) erl_eval.erl:680: :erl_eval.do_apply/6
    (elixir) lib/code.ex:767: Code.require_file/2
    (elixir) lib/kernel/parallel_compiler.ex:211: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/6

Excellent. Let's now introduce that insert/1 function by adding ExMachina. Open up mix.exs and add ex_machina as a dependency.

# mix.exs

  {:ecto_sql, "~> 3.0"},
  {:ex_machina, "~> 2.3", only: :test},  {:postgrex, ">= 0.0.0"},

Run mix deps.get. Now let's set up the library. First, make sure the application is started when the tests start.

# test/test_helper.exs

{:ok, _} = Application.ensure_all_started(:ex_machina){:ok, _} = Application.ensure_all_started(:wallaby)

Next, define a factory module to host our factory definitions. We will use ExMachina.Ecto's module and provide our Chatter.Repo as an option.

# test/support/factory.ex

defmodule Chatter.Factory do
  use ExMachina.Ecto, repo: Chatter.Repo
end

ExMachina.Ecto defines Chatter.Factory.insert/1 and insert/2 functions for us. Let's import those to all our feature tests by modifying our ChatterWeb.FeatureCase:

# test/support/feature_case.ex

using do
  quote do
    use Wallaby.DSL

    import Chatter.Factory

Run the test again. We see that the insert/1 function being undefined is no longer the problem:

mix test test/chatter_web/features/user_visits_rooms_page_test.exs


1) test user visits rooms page to see a list of rooms (ChatterWeb.UserVisitsRoomsPageTest)

  ** (ExMachina.UndefinedFactoryError) No factory defined for :chat_room.

  Please check for typos or define your factory:

      def chat_room_factory do
        ...
      end

  code: room = insert(:chat_room)

We now get a descriptive error from ExMachina, saying that we have not defined a factory for chat_room. Let's do that next.

Defining a factory

Once again, we'll write the code as we want it to be, even if the underlying functionality does not exist yet. Then the error messages will guide us through.

Open up your factory module and define a chat_room_factory/0 function:

# test/support/factory.ex

defmodule Chatter.Factory do
  use ExMachina.Ecto, repo: Chatter.Repo

  def chat_room_factory do    %Chatter.Chat.Room{      name: sequence(:name, &"chat room #{&1}")    }  endend

Since this is the first time introducing a factory, let's break down what we're doing step by step:

  • ExMachina maps our insert(:chat_room) in a test (and though we don't use it here the build(:chat_room)) to the chat_room_factory/0 function.
  • Any attributes passed to our insert function will be merged with the struct we define in chat_room_factory/0. We don't pass attributes here, but doing so will be crucial for keeping our tests concise and clear.
  • We expect to have a Chatter.Chat.Room Ecto schema with a name field. We have not yet created that.
  • Finally, we use ExMachina's sequence/2 function to generate auto-incrementing values, so that the values of name aren't the same across tests. E.g. our factory will currently generate names such as "chat room 1", "chat room 2", etc.

Now run the test and see what error we get.

mix test test/chatter_web/features/user_visits_rooms_page_test.exs

== Compilation error in file test/support/factory.ex ==
** (CompileError) test/support/factory.ex:5: Chatter.Chat.Room.__struct__/1 is undefined, cannot expand struct Chatter.Chat.Room
    test/support/factory.ex:4: (module)

This error is expected. We wrote the factory as we wanted it to be. But the Chatter.Chat.Room schema has not been created yet, and thus we do not have a %Chatter.Chat.Room{} struct. Let's do that next.

Creating a Chat.Room schema

We'll use a Phoenix generator to create the migration and the schema. Let's call the module Chat.Room, and create a chat_rooms table with a name that is a unique string.

mix phx.gen.schema Chat.Room chat_rooms name:unique

The command should have created the necessary migration to create our chat_rooms table and the Chatter.Chat.Room module with a schema defined. Run the migration with mix ecto.migrate, and rerun the test.

mix test test/chatter_web/features/user_visits_rooms_page_test.exs

  1) test user visits rooms page to see a list of rooms (ChatterWeb.UserVisitsRoomsPageTest)

    ** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element that
    matched the css '.room' but 0, visible elements were found.

    code: |> assert_has(Query.css(".room", text: room.name))
    stacktrace: test/chatter_web/features/user_visits_rooms_page_test.exs:9: (test)

Finished in 3.4 seconds
1 test, 1 failure

Good. We have moved on from errors related to test setup, and we now have a real Wallaby test failure. The test expected to find an HTML tag with class "room" and the room's name, but it could not find it. That's no surprise since we have not yet written the implementation.

Getting the test to pass

With Wallaby and ExMachina set up, and the chat rooms table and schema in place, we can now focus on getting the test to pass — getting to green in the red-green-refactor cycle.

Remember, our test expects to find a list of chat rooms when visiting the chat rooms page. Here's our test failure:

** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element that
matched the css '.room' but 0, visible elements were found.

code: |> assert_has(Query.css(".room", text: room.name))

Let's go ahead and write the code as we want it to exist. Open up the index template for chat rooms. Currently, we have an h1 tag with the title "Welcome to Chatter!":

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

<h1 class="title">Welcome to Chatter!</h1>

Ideally, we'd have a list of chat rooms that we could iterate through and render.

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

<h1 class="title">Welcome to Chatter!</h1>

<ul>  <%= for room <- @chat_rooms do %>    <li class="room"><%= room.name %></li>  <% end %></ul>

Run the test again. We will get a very large error, but scrolling to the top shows root cause:

mix test test/chatter_web/features/user_visits_rooms_page_test.exs

Request: GET /
** (exit) an exception was raised:
    ** (ArgumentError) assign @chat_rooms 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]

The failure tells us that we do not have a @chat_rooms assign in our template. Let's do that from the controller.

In the controller, we'll want to retrieve all the chat rooms available and assign them in our render/3 function:

# lib/chatter_web/controllers/chat_room_controller.ex

def index(conn, _params) do
  chat_rooms = Chatter.Chat.all_rooms()

  render(conn, "index.html", chat_rooms: chat_rooms)
end

Once again, we have written code that does not exist yet. And here we would like the responsibility of fetching all the rooms to live in Chatter.Chat, not in the controller itself. Rerunning the test, we get a compile-time warning and a test error stating the same problem:

mix test test/chatter_web/features/user_visits_rooms_page_test.exs

warning: function Chatter.Chat.all_rooms/0 is undefined (module Chatter.Chat is not available)
  lib/chatter_web/controllers/chat_room_controller.ex:5

Request: GET /
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function Chatter.Chat.all_rooms/0 is undefined (module Chatter.Chat is not available)

At this point, you may be thinking, "let's go ahead and write that function and its implementation to pass the test." But before we do that, it's time to drop to the inner red-green-refactor cycle. So let's write another test.

Outside-in testing: stepping in

So far, we've been doing a lot of outside testing via feature tests. Now is the time to step in. Recall the red-green-refactor BDD cycle:

two concentric red-green-refactor cycles

We have the outside test failing. We now want to get a failing test to start the red-green-refactor cycle for the inner circle.

Why now?

This is an excellent point to switch from the outside to the inside because we are moving away from the delivery mechanism of our application (the web — rendering templates, views, controllers, etc.) to the core business logic of our application. Phoenix helps make that separation clear by having different namespaces for those two layers of our system: ChatterWeb for the web layer and Chatter for our business logic.

This natural seam makes it a great place to have separation of concerns and ensure we have our core business logic well tested without having to deal with unrelated concerns (such as how we display the data on an HTML page).

Adding a test that fails in the same way

Since we are creating a seam, it is helpful to add an inside test that fails with exactly the same failure our outside test had. That way, we can have the "inside" test take over the responsibility of being "The Failing Test" that guides the implementation. Let's do that now by adding the following test test/chatter/chat_test.exs:

# test/chatter/chat_test.exs
defmodule Chatter.ChatTest do
  use Chatter.DataCase, async: true

  import Chatter.Factory

  alias Chatter.Chat

  describe "all_rooms/0" do
    test "returns all rooms available" do
      [room1, room2] = insert_pair(:chat_room)

      rooms = Chat.all_rooms()

      assert room1 in rooms
      assert room2 in rooms
    end
  end
end

Let's break down some of the components of the test we have not yet seen:

  • We use Chatter.DataCase, another helpful case template from Phoenix. It performs a lot of setup that is useful when interacting with the database, such as importing Ecto functions, aliasing our Repo, and checking out a connection from Ecto's SQL sandbox.
  • We import Chatter.Factory to make use of insert_pair/1, another helper function from ExMachina that creates two records for us.
  • We use the describe/2 macro from ExUnit that helps organize our tests — in this case, by the function under test.

Now run the new test:

$ mix test test/chatter/chat_test.exs

warning: function Chatter.Chat.all_rooms/0 is undefined (module Chatter.Chat is not available)
  lib/chatter_web/controllers/chat_room_controller.ex:5


  1) test all_rooms/0 returns all rooms available (Chatter.ChatTest)

    ** (UndefinedFunctionError) function Chatter.Chat.all_rooms/0 is undefined
    (module Chatter.Chat is not available)
    code: rooms = Chat.all_rooms()
    stacktrace:
      Chatter.Chat.all_rooms()
      test/chatter/chat_test.exs:12: (test)

Finished in 0.06 seconds
1 test, 1 failure

Excellent! We have the same failure that we had with our feature test: function Chatter.Chat.all_rooms/0 is undefined or private. (module Chatter.Chat is not available). From now on, we will run this test until it passes. Once it does, we will step back out to see what the feature test says we need to do next.

Create the Chatter.Chat module to get past the first part of this test error. In a new file called lib/chatter/chat.ex, add the most basic module:

# lib/chatter/chat.ex

defmodule Chatter.Chat do
end

Run the test again. We now get a slightly different error. The error no longer says (module Chatter.Chat is not available):

$ mix test test/chatter/chat_test.exs

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


    1) test all_rooms/0 returns all rooms available (Chatter.ChatTest)

      ** (UndefinedFunctionError) function Chatter.Chat.all_rooms/0 is undefined
      or private
      code: rooms = Chat.all_rooms()
      stacktrace:
        (chatter) Chatter.Chat.all_rooms()
        test/chatter/chat_test.exs:12: (test)

Finished in 0.06 seconds
1 test, 1 failure

But the error still complains that the function all_rooms/0 is undefined. Define an all_rooms/0 function with the simplest implementation first — return an empty list:

# lib/chatter/chat.ex
defmodule Chatter.Chat do
  def all_rooms do    []  endend

Run the test again. It now fails in the assertion, which is what we want:

$ mix test test/chatter/chat_test.exs

  1) test all_rooms/0 returns all rooms available (Chatter.ChatTest)

    Assertion with in failed
    code:  assert room1 in rooms
    left:  %Chatter.Chat.Room{
            __meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">,
            id: 10,
            inserted_at: ~N[2019-10-08 15:16:36],
            name: "chat room 0",
            updated_at: ~N[2019-10-08 15:16:36]
          }
    right: []
    stacktrace:
      test/chatter/chat_test.exs:14: (test)

Finished in 0.06 seconds
1 test, 1 failure

Write an implementation that passes the test by retrieving all the chat rooms from our database:

# lib/chatter/chat.ex
defmodule Chatter.Chat do
  alias Chatter.{Chat, Repo}
  def all_rooms do
    Chat.Room |> Repo.all()  end
end

Run the test. We should see some green!

$ mix test test/chatter/chat_test.exs

.

Finished in 0.07 seconds
1 test, 0 failures

Well done! But our work is not finished. We managed to get the test passing "inside". What about the "outside" feature test? Let's run it to see what we have:

mix test test/chatter_web/features/user_visits_rooms_page_test.exs

.

Finished in 0.3 seconds
1 test, 0 failures

🎉🎉🎉 Celebrate! 🎉🎉🎉

Seriously, celebrate. Now celebrate some more! This is a big deal. You just test-drove this whole feature!

Wrap up

If this is your first time doing test-driven development, this may seem like a lot of process. But if you keep practicing, it will become second nature. You will get faster at running through the red-green-refactor cycles, you will gain confidence in your code, and you will find that it is hard to beat the wonderful iterative nature of test-driven development. And the more complex the feature, the more you will gain from iterative development.

This is a good point to commit our work. But remember the red-green-refactor cycle. We still have something to do — refactor. That's what we'll do in the next chapter.