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:
- We first visit the index path for chat rooms (we've seen this before)
- We then click on a link with the text "New chat room". See Wallaby.Browser.click/2 Wallaby.Query.link/2
- We then fill in a text field whose label is "Name" with the value "elixir". We use Wallaby.Browser.fill_in/3 and Wallaby.Query.text_field/2
- We then click a submit button, using Wallaby.Query.button/2, to create the room.
- Finally, we assert that we have been taken to a page where the room-title data role attribute has the new room's name.
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
Server: localhost:4002 (http)
Request: GET /
** (exit) an exception was raised:
** (ArgumentError) no action :new for
ChatterWeb.Router.Helpers.chat_room_path/2. 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 action :new for
ChatterWeb.Router.Helpers.chat_room_path/2. The following actions/clauses are
supported:
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
, andwarning: 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 one.
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: Chatter.Chat.Room.changeset/2 is undefined or private. Did you mean one of:
* __changeset__/0
lib/chatter/chat.ex:10: Chatter.Chat.new_chat_room/0
Excluding tags: [:test]
Including tags: [line: "19"]
1) test new_chat_room/0 prepares a changeset for a new chat room (Chatter.ChatTest)
test/chatter/chat_test.exs:20
** (UndefinedFunctionError) function Chatter.Chat.Room.changeset/2 is
undefined or private. Did you mean one of:
* __changeset__/0
code: assert %Ecto.Changeset{} = Chat.new_chat_room()
stacktrace:
(chatter 0.1.0) 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.1 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. It seems that Ecto might have a __changeset__/0
function
defined in our schema, so Elixir suggests that to help. But that's not what we
want. 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 ourChatter.Factory
module when usingChater.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 withinsert/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 action :create for ChatterWeb.Router.Helpers.chat_room_path/2. 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: Chatter.Chat.create_chat_room/1 is undefined or private. Did you mean one of:
* new_chat_room/0
lib/chatter_web/controllers/chat_room_controller.ex:17: ChatterWeb.ChatRoomController.create/2
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)
test/chatter/chat_test.exs:26
** (UndefinedFunctionError) function Chatter.Chat.create_chat_room/1 is undefined or private. Did you mean one of:
* new_chat_room/0
code: {:ok, room} = Chat.create_chat_room(params)
stacktrace:
(chatter 0.1.0) Chatter.Chat.create_chat_room(%{"name" => "chat room 0"})
test/chatter/chat_test.exs:29: (test)
warning: Chatter.Chat.create_chat_room/1 is undefined or private. Did you mean one of:
* new_chat_room/0
test/chatter/chat_test.exs:29: Chatter.ChatTest."test create_chat_room/1 creates a room with valid params"/1
Finished in 0.1 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
warning: variable "params" is unused (if the variable is not meant to be used, prefix it with an underscore)
lib/chatter/chat.ex:13: Chatter.Chat.create_chat_room/1
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 thePlug.Conn
struct as an argument passed to the test:%{conn: conn}
. - We use Phoenix test helpers
post/3
andhtml_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"]
warning: Chatter.Chat.find_room/1 is undefined or private
test/chatter/chat_test.exs:50: Chatter.ChatTest."test find_room/1 retrieves a room by id"/1
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: Chatter.Chat.find_room/1
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
import Plug.Conn
import Phoenix.ConnTest
import Chatter.Factory import ChatterWeb.ConnCase
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.
This is a work in progress.
To find out more, see TDD Phoenix - a work in progress.