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 thebuild(:chat_room)
) to thechat_room_factory/0
function. - Any attributes passed to our
insert
function will be merged with the struct we define inchat_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 ofname
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:
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 ofinsert_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
Compiling 1 files (.ex)
warning: Chatter.Chat.all_rooms/0 is undefined (module Chatter.Chat is not available or is yet to be defined)
test/chatter/chat_test.exs:12: Chatter.ChatTest."test all_rooms/0 returns all rooms available"/1
1) test all_rooms/0 returns all rooms available (Chatter.ChatTest)
test/chatter/chat_test.exs:9
** (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.1 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 or is yet to be defined)
. 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
Compiling 1 file (.ex)
warning: Chatter.Chat.all_rooms/0 is undefined or private
lib/chatter_web/controllers/chat_room_controller.ex:5: ChatterWeb.ChatRoomController.index/2
Generated chatter app
warning: Chatter.Chat.all_rooms/0 is undefined or private
test/chatter/chat_test.exs:12: Chatter.ChatTest."test all_rooms/0 returns all rooms available"/1
1) test all_rooms/0 returns all rooms available (Chatter.ChatTest)
test/chatter/chat_test.exs:9
** (UndefinedFunctionError) function Chatter.Chat.all_rooms/0 is undefined or private
code: rooms = Chat.all_rooms()
stacktrace:
(chatter 0.1.0) Chatter.Chat.all_rooms()
test/chatter/chat_test.exs:12: (test)
Finished in 0.1 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.
This is a work in progress.
To find out more, see TDD Phoenix - a work in progress.