Authentication
Now that the core of our application works, it's time to implement authentication. If we were to look at our chat, we would be unable to know which user wrote each message: all messages are anonymous. We need authorship, and for that, we need authentication. So let's do that next.
Adding authentication now — instead of at the beginning — is beneficial because we can add authentication to existing tests rather than building tests solely for authentication testing. Once the modified tests are passing, we can consider the work finished. That is one reason why I prefer handling the core flows of the application first and only later adding authentication. Let's get to it.
Test-driving by changing our tests
Even though we’re modifying tests — rather than adding new ones — we will still test-drive the changes. We will modify the tests to reflect the flow we wish existed before changing the implementation. Then, we'll let the test failures guide us.
Let's start with a straightforward test: UserCreatesNewChatRoomTest
. We should
require users to authenticate before can create chat rooms. To do that, we'll
have the test session visit the root path (which seems more realistic of a
user's interaction) instead of going directly to the rooms index route. Since
the users won’t be authenticated, our application should redirect them to the
login page. Once there, users can sign in and be redirected back to the home
page.
# test/chatter_web/features/user_creates_new_chat_room_test.exs
use ChatterWeb.FeatureCase, async: true
test "user creates a new chat room successfully", %{session: session} do
user = insert(:user)
session
|> visit("/") |> sign_in(as: user) |> click(new_chat_link())
|> create_chat_room(name: "elixir")
|> assert_has(room_title("elixir"))
end
Let's run the test to see our first failure!
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
warning: function rooms_index/0 is unused
test/chatter_web/features/user_creates_new_chat_room_test.exs:15
== Compilation error in file test/chatter_web/features/user_creates_new_chat_room_test.exs ==
** (CompileError) test/chatter_web/features/user_creates_new_chat_room_test.exs:9: undefined function sign_in/2
(elixir 1.11.0) src/elixir_locals.erl:114: anonymous fn/3 in :elixir_locals.ensure_no_undefined_local/3
(stdlib 3.13.1) erl_eval.erl:680: :erl_eval.do_apply/6
(elixir 1.11.0) lib/kernel/parallel_compiler.ex:416: Kernel.ParallelCompiler.require_file/2
(elixir 1.11.0) lib/kernel/parallel_compiler.ex:316: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7
We have an undefined function sign_in/2
. Let's add that function. And while
we're at it, remove the (now) unused rooms_index/0
function.
# test/chatter_web/features/user_creates_new_chat_room_test.exs
test "user creates a new chat room successfully", %{session: session} do
user = insert(:user)
session
|> visit("/")
|> sign_in(as: user)
|> click(new_chat_link())
|> create_chat_room(name: "elixir")
|> assert_has(room_title("elixir"))
end
defp sign_in(session, as: user) do session |> fill_in(Query.text_field("Email"), with: user.email) |> fill_in(Query.text_field("Password"), with: user.password) |> click(Query.button("Sign in")) end
defp new_chat_link, do: Query.link("New chat room")
The Wallaby syntax should look familiar now. These are the additions to the test:
- We insert a user (though no factory, schema, or database table exist yet)
- When visiting the to the root path, we are redirected to the login page
- We fill in an email and password (with the aforementioned user)
- And we sign in
Let's run our test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (ExMachina.UndefinedFactoryError) No factory defined for :user.
Please check for typos or define your factory:
def user_factory do
...
end
code: user = insert(:user)
stacktrace:
(ex_machina 2.4.0) lib/ex_machina.ex:205: ExMachina.build/3
(chatter 0.1.0) test/support/factory.ex:2: Chatter.Factory.insert/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:5: (test)
Finished in 0.3 seconds
1 test, 1 failure
We do not have a user
factory. Let's fix that:
# test/support/factory.ex
def chat_room_factory do
%Chatter.Chat.Room{
name: sequence(:name, &"chat room #{&1}")
}
end
def user_factory do %Chatter.User{ email: sequence(:email, &"super#{&1}@example.com"), password: "password1" } endend
Trying to run the test will give us a compilation error:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 1 file (.ex)
== Compilation error in file test/support/factory.ex ==
** (CompileError) test/support/factory.ex:11: Chatter.User.__struct__/1 is undefined, cannot expand struct Chatter.User. Make sure the struct name is correct. If the struct name exists and is correct but it still cannot be found, you likely have cyclic module usage in your code
test/support/factory.ex:10: (module)
We’ll create a users table and Chatter.User
struct with an Ecto schema. But
what fields do we need in the schema and what columns in the table?
In the test, we only require an email
and password
, but users will need more
fields to authenticate correctly. Different authentication libraries in Elixir
require varied fields and columns.
Since our need is simple, I will choose a simple library: Doorman. Along with basic functionality for hashing passwords, Doorman has some helper functions to check if a user is logged in.
But we won’t add Doorman as a dependency just yet. For now, we just want to know
which fields we need to create. Looking at Doorman's documentation, it requires
we create a users
table with an email
, hashed_password
, and
session_secret
columns. We'll use Phoenix's schema generator:
$ mix phx.gen.schema User users email:unique hashed_password:string session_secret:string
* creating lib/chatter/user.ex
* creating priv/repo/migrations/20191129101830_create_users.exs
Remember to update your repository by running migrations:
$ mix ecto.migrate
Run the migration:
$ mix ecto.migrate
Compiling 3 files (.ex)
Generated chatter app
05:17:59.992 [info] == Running 20201017091747 Chatter.Repo.Migrations.CreateUsers.change/0 forward
05:17:59.995 [info] create table users
05:18:00.027 [info] create index users_email_index
05:18:00.029 [info] == Migrated 20201017091747 in 0.0s
Now, rerun our test to see where we stand:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs:9
Compiling 2 files (.ex)
== Compilation error in file test/support/factory.ex ==
** (KeyError) key :password not found
expanding struct: Chatter.User.__struct__/1
test/support/factory.ex:11: Chatter.Factory.user_factory/0
(elixir) lib/kernel/parallel_compiler.ex:208: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/6
The error shows that our Chatter.User
struct does not have a :password
key:
** (KeyError) key :password not found
. Let's fix that in our user schema:
# lib/chatter/user.ex
defmodule Chatter.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true field :hashed_password, :string
field :session_secret, :string
timestamps()
end
Let's run our 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 'Email' but 0, visible text inputs or textareas were found.
code: |> sign_in(as: user)
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:17: ChatterWeb.UserCreatesNewChatRoomTest.sign_in/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:9: (test)
Finished in 3.6 seconds
1 test, 1 failure
Good! We've made it past the user factory issues, and now Wallaby cannot find the email field because we're not redirecting the unauthenticated user to the login page. Let's do that next.
Requiring users to sign in
Doorman's documentation recommends including a plug called RequireLogin
that
redirects unauthenticated users to the login page. Let's create that plug and
have the errors guide us into adding Doorman as a dependency.
# lib/chatter_web/plugs/require_login.ex
defmodule ChatterWeb.Plugs.RequireLogin do
import Plug.Conn
alias ChatterWeb.Router.Helpers, as: Routes
def init(opts), do: opts
def call(conn, _opts) do
if Doorman.logged_in?(conn) do
conn
else
conn
|> Phoenix.Controller.redirect(to: Routes.session_path(conn, :new))
|> halt()
end
end
end
Now let's add RequireLogin
as part of our pipe_through
plugs for our routes:
# lib/chatter_web/router.ex
defmodule ChatterWeb.Router do
use ChatterWeb, :router
alias ChatterWeb.Plugs
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
#... other plugs and api pipeline
scope "/", ChatterWeb do
pipe_through [:browser, Plugs.RequireLogin]
resources "/chat_rooms", ChatRoomController, only: [:new, :create, :show]
get "/", ChatRoomController, :index
Run our test again:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 3 files (.ex)
warning: Doorman.logged_in?/1 is undefined (module Doorman is not available or is yet to be defined)
lib/chatter_web/plugs/require_login.ex:9: ChatterWeb.Plugs.RequireLogin.call/2
Generated chatter app
05:22:13.206 [error] #PID<0.601.0> running ChatterWeb.Endpoint (connection #PID<0.600.0>, stream id 1) terminated
Server: localhost:4002 (http)
Request: GET /
** (exit) an exception was raised:
** (UndefinedFunctionError) function Doorman.logged_in?/1 is undefined (module Doorman is not available)
Doorman.logged_in?(%Plug.Conn{adapter: {Plug.Cowboy.Conn, :...}, assigns: %{}, before_send: [#Function<0.12088478/1 in Plug.CSRFProtection.call/2>, #Function<2.13727930/1 in Phoenix.Controller.fetch_flash/2>, #Function<0.123471702/1 in Plug.Session.before_send/2>, #Function<0.11227428/1 in Plug.Telemetry.call/2>], body_params: %{}, cookies: %{}, halted: false, host: "localhost", method: "GET", owner: #PID<0.601.0>, params: %{}, path_info: [], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_endpoint => ChatterWeb.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_request_logger => {"request_logger", "request_logger"}, :phoenix_router => ChatterWeb.Router, :plug_session => %{}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{}, req_headers: [{"accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"}, {"accept-encoding", "gzip, deflate, br"}, {"accept-language", "en-US"}, {"connection", "keep-alive"}, {"host", "localhost:4002"}, {"sec-fetch-dest", "document"}, {"sec-fetch-mode", "navigate"}, {"sec-fetch-site", "none"}, {"sec-fetch-user", "?1"}, {"upgrade-insecure-requests", "1"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyWGQADW5vbm9kZUBub2hvc3QAAAJWAAAAAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "Fj69MHN1hgguW9YAAAHD"}, {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}, {"x-download-options", "noopen"}, {"x-permitted-cross-domain-policies", "none"}, {"cross-origin-window-policy", "deny"}], scheme: :http, script_name: [], secret_key_base: :..., state: :unset, status: nil})
(chatter 0.1.0) lib/chatter_web/plugs/require_login.ex:9: ChatterWeb.Plugs.RequireLogin.call/2
(chatter 0.1.0) lib/chatter_web/router.ex:1: ChatterWeb.Router.__pipe_through0__/1
(phoenix 1.5.4) lib/phoenix/router.ex:347: Phoenix.Router.__call__/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:65: Phoenix.Endpoint.Cowboy2Handler.init/4
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible text input or textarea 'Email' but 0, visible text inputs or textareas were found.
code: |> sign_in(as: user)
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_creates_new_chat_room_test.exs:17: ChatterWeb.UserCreatesNewChatRoomTest.sign_in/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:9: (test)
Finished in 3.5 seconds
1 test, 1 failure
Both the warning and the error tells us to add Doorman: **
(UndefinedFunctionError) function Doorman.logged_in?/1 is undefined (module
Doorman is not available)
Let's add the library as a dependency:
# mix.exs
{:phoenix, "~> 1.4.2"},
{:phoenix_pubsub, "~> 1.1"},
{:phoenix_ecto, "~> 4.0"},
{:doorman, "~> 0.6.2"}, {:ecto_sql, "~> 3.0"},
{:ex_machina, "~> 2.3", only: :test},
{:postgrex, ">= 0.0.0"},
Do a mix deps.get
and rerun our test.
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Generated chatter app
05:28:13.633 [error] #PID<0.921.0> running ChatterWeb.Endpoint (connection #PID<0.920.0>, stream id 1) terminated
Server: localhost:4002 (http)
Request: GET /
** (exit) an exception was raised:
** (UndefinedFunctionError) function ChatterWeb.Router.Helpers.session_path/2 is undefined or private
(chatter 0.1.0) ChatterWeb.Router.Helpers.session_path(%Plug.Conn{adapter: {Plug.Cowboy.Conn, :...}, assigns: %{}, before_send: [#Function<0.12088478/1 in Plug.CSRFProtection.call/2>, #Function<2.13727930/1 in Phoenix.Controller.fetch_flash/2>, #Function<0.123471702/1 in Plug.Session.before_send/2>, #Function<0.11227428/1 in Plug.Telemetry.call/2>], body_params: %{}, cookies: %{}, halted: false, host: "localhost", method: "GET", owner: #PID<0.921.0>, params: %{}, path_info: [], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_endpoint => ChatterWeb.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_request_logger => {"request_logger", "request_logger"}, :phoenix_router => ChatterWeb.Router, :plug_session => %{}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{}, req_headers: [{"accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"}, {"accept-encoding", "gzip, deflate, br"}, {"accept-language", "en-US"}, {"connection", "keep-alive"}, {"host", "localhost:4002"}, {"sec-fetch-dest", "document"}, {"sec-fetch-mode", "navigate"}, {"sec-fetch-site", "none"}, {"sec-fetch-user", "?1"}, {"upgrade-insecure-requests", "1"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyWGQADW5vbm9kZUBub2hvc3QAAAOTAAAAAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "Fj69hF6fBcCvl8cAAAyB"}, {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}, {"x-download-options", "noopen"}, {"x-permitted-cross-domain-policies", "none"}, {"cross-origin-window-policy", "deny"}], scheme: :http, script_name: [], secret_key_base: :..., state: :unset, status: nil}, :new)
(chatter 0.1.0) lib/chatter_web/plugs/require_login.ex:13: ChatterWeb.Plugs.RequireLogin.call/2
(chatter 0.1.0) lib/chatter_web/router.ex:1: ChatterWeb.Router.__pipe_through0__/1
(phoenix 1.5.4) lib/phoenix/router.ex:347: Phoenix.Router.__call__/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:65: Phoenix.Endpoint.Cowboy2Handler.init/4
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible text input or textarea 'Email' but 0, visible text inputs or textareas were found.
code: |> sign_in(as: user)
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_creates_new_chat_room_test.exs:17: ChatterWeb.UserCreatesNewChatRoomTest.sign_in/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:9: (test)
Finished in 3.7 seconds
1 test, 1 failure
Good. We now have an error because we have not yet defined the session path:
** (UndefinedFunctionError) function ChatterWeb.Router.Helpers.session_path/2 is
undefined or private
Let's define that route in a separate scope since it should not be piped through
RequireLogin
. And give the route a user-friendly name, "/sign_in"
:
# lib/chatter_web/router.ex
scope "/", ChatterWeb do pipe_through :browser get "/sign_in", SessionController, :new end
scope "/", ChatterWeb do
pipe_through [:browser, Plugs.RequireLogin]
resources "/chat_rooms", ChatRoomController, only: [:new, :create, :show]
get "/", ChatRoomController, :index
end
Now rerun our test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 2 files (.ex)
05:21:34.169 [error] #PID<0.568.0> running ChatterWeb.Endpoint (connection #PID<0.566.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /sign_in
** (exit) an exception was raised:
** (UndefinedFunctionError) function ChatterWeb.SessionController.init/1 is undefined (module ChatterWeb.SessionController is not available)
ChatterWeb.SessionController.init(:new)
(phoenix) lib/phoenix/router.ex:275: Phoenix.Router.__call__/1
(chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix) lib/phoenix/endpoint/cowboy2_handler.ex:33: Phoenix.Endpoint.Cowboy2Handler.init/2
(stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible text input or textarea 'Email' but 0, visible text inputs or textareas were found.
code: |> sign_in(as: user)
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:17: ChatterWeb.UserCreatesNewChatRoomTest.sign_in/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:9: (test)
Finished in 3.6 seconds
1 test, 1 failure
That's a large error message, but if we look near the top we see: **
(UndefinedFunctionError) function ChatterWeb.SessionController.init/1 is
undefined (module ChatterWeb.SessionController is not available)
There is no SessionController.init/1
because we haven't created that
controller. This may start seeming familiar, and that's a good thing! Let's
create that controller:
# lib/chatter_web/controllers/session_controller.ex
defmodule ChatterWeb.SessionController do
use ChatterWeb, :controller
end
Run the test again:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 3 files (.ex)
Generated chatter app
05:23:13.648 [error] #PID<0.574.0> running ChatterWeb.Endpoint (connection #PID<0.572.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /sign_in
** (exit) an exception was raised:
** (UndefinedFunctionError) function ChatterWeb.SessionController.new/2 is undefined or private
(chatter) ChatterWeb.SessionController.new(%Plug.Conn{adapter: {Plug.Cowboy.Conn, :...}, assigns: %{}, before_send: [#Function<0.35788658/1 in Plug.CSRFProtection.call/2>, #Function<2.99118896/1 in Phoenix.Controller.fetch_flash/2>, #Function<0.58261320/1 in Plug.Session.before_send/2>, #Function<1.112466771/1 in Plug.Logger.call/2>], body_params: %{}, cookies: %{}, halted: false, host: "localhost", method: "GET", owner: #PID<0.574.0>, params: %{}, path_info: ["sign_in"], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_action => :new, :phoenix_controller => ChatterWeb.SessionController, :phoenix_endpoint => ChatterWeb.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_layout => {ChatterWeb.LayoutView, :app}, :phoenix_pipelines => [:browser], :phoenix_router => ChatterWeb.Router, :phoenix_view => ChatterWeb.SessionView, :plug_session => %{}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{}, req_headers: [{"accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}, {"accept-encoding", "gzip, deflate"}, {"accept-language", "en-US,*"}, {"connection", "Keep-Alive"}, {"host", "localhost:4002"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyZ2QADW5vbm9kZUBub2hvc3QAAAI5AAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/sign_in", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "FdzVY6UvnpgHB2UAAAQi"}, {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}, {"x-download-options", "noopen"}, {"x-permitted-cross-domain-policies", "none"}, {"cross-origin-window-policy", "deny"}], scheme: :http, script_name: [], secret_key_base: :..., state: :unset, status: nil}, %{})
(chatter) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.action/2
(chatter) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.phoenix_controller_pipeline/2
(chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.instrument/4
(phoenix) lib/phoenix/router.ex:275: Phoenix.Router.__call__/1
(chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix) lib/phoenix/endpoint/cowboy2_handler.ex:33: Phoenix.Endpoint.Cowboy2Handler.init/2
(stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible text input or textarea 'Email' but 0, visible text inputs or textareas were found.
code: |> sign_in(as: user)
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:17: ChatterWeb.UserCreatesNewChatRoomTest.sign_in/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:9: (test)
Finished in 3.6 seconds
1 test, 1 failure
Focus near the top of the message. We see that the new/2
function is
undefined:
** (UndefinedFunctionError) function ChatterWeb.SessionController.new/2 is
undefined or private
Let's define it:
# lib/chatter_web/controllers/session_controller.ex
use ChatterWeb, :controller
def new(conn, _) do render(conn, "new.html") endend
Rerun our test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 2 files (.ex)
05:25:53.073 [error] #PID<0.578.0> running ChatterWeb.Endpoint (connection #PID<0.576.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /sign_in
** (exit) an exception was raised:
** (UndefinedFunctionError) function ChatterWeb.SessionView.render/2 is undefined (module ChatterWeb.SessionView is not available)
ChatterWeb.SessionView.render("new.html", %{conn: %Plug.Conn{adapter: {Plug.Cowboy.Conn, :...}, assigns: %{layout: {ChatterWeb.LayoutView, "app.html"}}, before_send: [#Function<0.35788658/1 in Plug.CSRFProtection.call/2>, #Function<2.99118896/1 in Phoenix.Controller.fetch_flash/2>, #Function<0.58261320/1 in Plug.Session.before_send/2>, #Function<1.112466771/1 in Plug.Logger.call/2>], body_params: %{}, cookies: %{}, halted: false, host: "localhost", method: "GET", owner: #PID<0.578.0>, params: %{}, path_info: ["sign_in"], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_action => :new, :phoenix_controller => ChatterWeb.SessionController, :phoenix_endpoint => ChatterWeb.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_layout => {ChatterWeb.LayoutView, :app}, :phoenix_pipelines => [:browser], :phoenix_router => ChatterWeb.Router, :phoenix_template => "new.html", :phoenix_view => ChatterWeb.SessionView, :plug_session => %{}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{}, req_headers: [{"accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}, {"accept-encoding", "gzip, deflate"}, {"accept-language", "en-US,*"}, {"connection", "Keep-Alive"}, {"host", "localhost:4002"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyZ2QADW5vbm9kZUBub2hvc3QAAAI9AAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/sign_in", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "FdzViMOJSfBtJ3cAAAMD"}, {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}, {"x-download-options", "noopen"}, {"x-permitted-cross-domain-policies", "none"}, {"cross-origin-window-policy", "deny"}], scheme: :http, script_name: [], secret_key_base: :..., state: :unset, status: nil}, view_module: ChatterWeb.SessionView, view_template: "new.html"})
(chatter) lib/chatter_web/templates/layout/app.html.eex:26: ChatterWeb.LayoutView."app.html"/1
(phoenix) lib/phoenix/view.ex:399: Phoenix.View.render_to_iodata/3
(phoenix) lib/phoenix/controller.ex:729: Phoenix.Controller.__put_render__/5
(phoenix) lib/phoenix/controller.ex:746: Phoenix.Controller.instrument_render_and_send/4
(chatter) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.action/2
(chatter) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.phoenix_controller_pipeline/2
(chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.instrument/4
(phoenix) lib/phoenix/router.ex:275: Phoenix.Router.__call__/1
(chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix) lib/phoenix/endpoint/cowboy2_handler.ex:33: Phoenix.Endpoint.Cowboy2Handler.init/2
(stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible text input or textarea 'Email' but 0, visible text inputs or textareas were found.
code: |> sign_in(as: user)
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:17: ChatterWeb.UserCreatesNewChatRoomTest.sign_in/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:9: (test)
Finished in 3.7 seconds
1 test, 1 failure
That's another verbose message. Focusing near the beginning, we see that we don't
have a SessionView
module and render/2
is undefined:
** (UndefinedFunctionError) function ChatterWeb.SessionView.render/2 is
undefined (module ChatterWeb.SessionView is not available)
Let's fix that by adding a view module:
# lib/chatter_web/views/session_view.ex
defmodule ChatterWeb.SessionView do
use ChatterWeb, :view
end
Run our test again:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 2 files (.ex)
Generated chatter app
05:27:14.210 [error] #PID<0.565.0> running ChatterWeb.Endpoint (connection #PID<0.563.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /sign_in
** (exit) an exception was raised:
** (Phoenix.Template.UndefinedError) Could not render "new.html" for ChatterWeb.SessionView, please define a matching clause for render/2 or define a template at "lib/chatter_web/templates/session". No templates were compiled for this module.
Assigns:
%{conn: %Plug.Conn{adapter: {Plug.Cowboy.Conn, :...}, assigns: %{layout: {ChatterWeb.LayoutView, "app.html"}}, before_send: [#Function<0.12088478/1 in Plug.CSRFProtection.call/2>, #Function<2.13727930/1 in Phoenix.Controller.fetch_flash/2>, #Function<0.123471702/1 in Plug.Session.before_send/2>, #Function<0.11227428/1 in Plug.Telemetry.call/2>], body_params: %{}, cookies: %{}, halted: false, host: "localhost", method: "GET", owner: #PID<0.617.0>, params: %{}, path_info: ["sign_in"], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_action => :new, :phoenix_controller => ChatterWeb.SessionController, :phoenix_endpoint => ChatterWeb.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_layout => {ChatterWeb.LayoutView, :app}, :phoenix_request_logger => {"request_logger", "request_logger"}, :phoenix_router => ChatterWeb.Router, :phoenix_template => "new.html", :phoenix_view => ChatterWeb.SessionView, :plug_session => %{}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{}, req_headers: [{"accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"}, {"accept-encoding", "gzip, deflate, br"}, {"accept-language", "en-US"}, {"connection", "keep-alive"}, {"host", "localhost:4002"}, {"sec-fetch-dest", "document"}, {"sec-fetch-mode", "navigate"}, {"sec-fetch-site", "none"}, {"sec-fetch-user", "?1"}, {"upgrade-insecure-requests", "1"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyWGQADW5vbm9kZUBub2hvc3QAAAJgAAAAAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/sign_in", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "Fj695_1EXDAKOXkAAALi"}, {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}, {"x-download-options", "noopen"}, {"x-permitted-cross-domain-policies", "none"}, {"cross-origin-window-policy", "deny"}], scheme: :http, script_name: [], secret_key_base: :..., state: :unset, status: nil}, view_module: ChatterWeb.SessionView, view_template: "new.html"}
Assigned keys: [:conn, :view_module, :view_template]
(phoenix 1.5.4) lib/phoenix/template.ex:337: Phoenix.Template.raise_template_not_found/3
(phoenix 1.5.4) lib/phoenix/view.ex:310: Phoenix.View.render_within/3
(phoenix 1.5.4) lib/phoenix/view.ex:472: Phoenix.View.render_to_iodata/3
(phoenix 1.5.4) lib/phoenix/controller.ex:776: Phoenix.Controller.render_and_send/4
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.action/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.phoenix_controller_pipeline/2
(phoenix 1.5.4) lib/phoenix/router.ex:352: Phoenix.Router.__call__/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:65: Phoenix.Endpoint.Cowboy2Handler.init/4
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible text input or textarea 'Email' but 0, visible text inputs or textareas were found.
code: |> sign_in(as: user)
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:17: ChatterWeb.UserCreatesNewChatRoomTest.sign_in/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:9: (test)
Finished in 3.6 seconds
1 test, 1 failure
Now we see that there is no "new.html"
template:
** (Phoenix.Template.UndefinedError) Could not render "new.html" for
ChatterWeb.SessionView, please define a matching clause for render/2 or define a
template at "lib/chatter_web/templates/session". No templates were compiled for
this module.
You might have expected that error after our last one. Let's just create an empty template there:
$ mkdir lib/chatter_web/templates/session
$ touch lib/chatter_web/templates/session/new.html.eex
Rerun the test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 1 file (.ex)
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible text input or textarea 'Email' but 0, visible text inputs or textareas were found.
code: |> sign_in(as: user)
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_creates_new_chat_room_test.exs:17: ChatterWeb.UserCreatesNewChatRoomTest.sign_in/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:9: (test)
Finished in 3.6 seconds
1 test, 1 failure
Good! We got rid of all Elixir compilation errors and Phoenix errors. We can now focus on Wallaby's error — expecting to find forum inputs:
** (Wallaby.QueryError) Expected to find 1, visible text input or textarea
'Email' but 0, visible text inputs or textareas were found.
Let's add a form for our users to sign in:
# lib/chatter_web/templates/session/new.html.eex
<%= form_for @conn, Routes.session_path(@conn, :create), fn f -> %>
<label>
Email: <%= text_input f, :email %>
</label>
<label>
Password: <%= password_input f, :password %>
</label>
<%= submit "Sign in" %>
<% end %>
Now rerun our test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 1 file (.ex)
05:42:41.871 [error] #PID<0.588.0> running ChatterWeb.Endpoint (connection #PID<0.586.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /sign_in
** (exit) an exception was raised:
** (ArgumentError) no action :create for ChatterWeb.Router.Helpers.session_path/2. The following actions/clauses are supported:
session_path(conn_or_endpoint, :new, params \\ [])
(phoenix 1.5.4) lib/phoenix/router/helpers.ex:374: Phoenix.Router.Helpers.invalid_route_error/3
(chatter 0.1.0) lib/chatter_web/templates/session/new.html.eex:1: ChatterWeb.SessionView."new.html"/1
(phoenix 1.5.4) lib/phoenix/view.ex:310: Phoenix.View.render_within/3
(phoenix 1.5.4) lib/phoenix/view.ex:472: Phoenix.View.render_to_iodata/3
(phoenix 1.5.4) lib/phoenix/controller.ex:776: Phoenix.Controller.render_and_send/4
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.action/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.phoenix_controller_pipeline/2
(phoenix 1.5.4) lib/phoenix/router.ex:352: Phoenix.Router.__call__/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:65: Phoenix.Endpoint.Cowboy2Handler.init/4
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible text input or textarea 'Email' but 0, visible text inputs or textareas were found.
code: |> sign_in(as: user)
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_creates_new_chat_room_test.exs:17: ChatterWeb.UserCreatesNewChatRoomTest.sign_in/2
test/chatter_web/features/user_creates_new_chat_room_test.exs:9: (test)
Finished in 3.5 seconds
1 test, 1 failure
The test fails because we do not have a create Routes.session_path
. Let's add
that route:
# lib/chatter_web/router.ex
scope "/", ChatterWeb do
pipe_through :browser
get "/sign_in", SessionController, :new
resources "/sessions", SessionController, only: [:create] end
Rerun our test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 2 files (.ex)
05:44:22.757 [error] #PID<0.619.0> running ChatterWeb.Endpoint (connection #PID<0.608.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: POST /sessions
** (exit) an exception was raised:
** (UndefinedFunctionError) function ChatterWeb.SessionController.create/2 is undefined or private
(chatter 0.1.0) ChatterWeb.SessionController.create(%Plug.Conn{adapter: {Plug.Cowboy.Conn, :...}, assigns: %{}, before_send: [#Function<0.12088478/1 in Plug.CSRFProtection.call/2>, #Function<2.13727930/1 in Phoenix.Controller.fetch_flash/2>, #Function<0.123471702/1 in Plug.Session.before_send/2>, #Function<0.11227428/1 in Plug.Telemetry.call/2>], body_params: %{"_csrf_token" => "ZSxZQCRRWHAZIXwAfioDGyt4Wx0rNAhW1i3xvd-7FSIxGcOke12jj_p2", "email" => "super0@example.com", "password" => "password1"}, cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYVEVqOFI1dUdfcjV4OUlMcE5JaXdBa3hk.0C64sTtBlBCTsxbYzQYczNiMVJGBfC__PRZS064ni0k"}, halted: false, host: "localhost", method: "POST", owner: #PID<0.619.0>, params: %{"_csrf_token" => "ZSxZQCRRWHAZIXwAfioDGyt4Wx0rNAhW1i3xvd-7FSIxGcOke12jj_p2", "email" => "super0@example.com", "password" => "password1"}, path_info: ["sessions"], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_action => :create, :phoenix_controller => ChatterWeb.SessionController, :phoenix_endpoint => ChatterWeb.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_layout => {ChatterWeb.LayoutView, :app}, :phoenix_request_logger => {"request_logger", "request_logger"}, :phoenix_router => ChatterWeb.Router, :phoenix_view => ChatterWeb.SessionView, :plug_session => %{"_csrf_token" => "TEj8R5uG_r5x9ILpNIiwAkxd"}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYVEVqOFI1dUdfcjV4OUlMcE5JaXdBa3hk.0C64sTtBlBCTsxbYzQYczNiMVJGBfC__PRZS064ni0k"}, req_headers: [{"accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"}, {"accept-encoding", "gzip, deflate, br"}, {"accept-language", "en-US"}, {"cache-control", "max-age=0"}, {"connection", "keep-alive"}, {"content-length", "114"}, {"content-type", "application/x-www-form-urlencoded"}, {"cookie", "_chatter_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYVEVqOFI1dUdfcjV4OUlMcE5JaXdBa3hk.0C64sTtBlBCTsxbYzQYczNiMVJGBfC__PRZS064ni0k"}, {"host", "localhost:4002"}, {"origin", "http://localhost:4002"}, {"referer", "http://localhost:4002/sign_in"}, {"sec-fetch-dest", "document"}, {"sec-fetch-mode", "navigate"}, {"sec-fetch-site", "same-origin"}, {"sec-fetch-user", "?1"}, {"upgrade-insecure-requests", "1"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyWGQADW5vbm9kZUBub2hvc3QAAAJNAAAAAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/sessions", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "Fj6-ZgNnTxi_6ocAAAHh"}, {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}, {"x-download-options", "noopen"}, {"x-permitted-cross-domain-policies", "none"}, {"cross-origin-window-policy", "deny"}], scheme: :http, script_name: [], secret_key_base: :..., state: :unset, status: nil}, %{"_csrf_token" => "ZSxZQCRRWHAZIXwAfioDGyt4Wx0rNAhW1i3xvd-7FSIxGcOke12jj_p2", "email" => "super0@example.com", "password" => "password1"})
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.action/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.phoenix_controller_pipeline/2
(phoenix 1.5.4) lib/phoenix/router.ex:352: Phoenix.Router.__call__/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:65: Phoenix.Endpoint.Cowboy2Handler.init/4
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible link 'New chat room' but 0, visible links were found.
code: |> click(new_chat_link())
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_creates_new_chat_room_test.exs:10: (test)
Finished in 4.0 seconds
1 test, 1 failure
Our test now fails because our controller's create action is undefined: **
(UndefinedFunctionError) function ChatterWeb.SessionController.create/2 is
undefined or private
.
Let's define that action with a modified version of Doorman's example in its documentation for creating the session:
# lib/chatter_web/controllers/session_controller.ex
def new(conn, _) do
render(conn, "new.html")
end
def create(conn, %{"email" => email, "password" => password}) do user = Doorman.authenticate(email, password) conn |> Doorman.Login.Session.login(user) |> redirect(to: "/") endend
Rerun the test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 2 files (.ex)
05:45:45.327 [error] #PID<0.620.0> running ChatterWeb.Endpoint (connection #PID<0.608.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: POST /sessions
** (exit) an exception was raised:
** (RuntimeError) You must add `user_module` to `doorman` in your config
Here is an example configuration:
config :doorman,
repo: MyApp.Repo,
secure_with: Doorman.Auth.Bcrypt,
user_module: MyApp.User
(doorman 0.6.2) lib/doorman.ex:81: Doorman.get_module/1
(doorman 0.6.2) lib/doorman.ex:26: Doorman.authenticate/3
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:9: ChatterWeb.SessionController.create/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.action/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.phoenix_controller_pipeline/2
(phoenix 1.5.4) lib/phoenix/router.ex:352: Phoenix.Router.__call__/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:65: Phoenix.Endpoint.Cowboy2Handler.init/4
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible link 'New chat room' but 0, visible links were found.
code: |> click(new_chat_link())
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_creates_new_chat_room_test.exs:10: (test)
Finished in 3.9 seconds
1 test, 1 failure
Whoops! This time the test caught an oversight on our part. When adding Doorman, we did not configure it properly. Thankfully we got a helpful error:
** (exit) an exception was raised:
** (RuntimeError) You must add `user_module` to `doorman` in your config
Here is an example configuration:
config :doorman,
repo: MyApp.Repo,
secure_with: Doorman.Auth.Bcrypt,
user_module: MyApp.User
Let's set that configuration in our config.exs
file:
# config/config.exs
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
config :doorman, repo: Chatter.Repo, secure_with: Doorman.Auth.Bcrypt, user_module: Chatter.User
Now rerun our test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 26 files (.ex)
Generated chatter app
05:47:38.978 [error] #PID<0.790.0> running ChatterWeb.Endpoint (connection #PID<0.775.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: POST /sessions
** (exit) an exception was raised:
** (ArgumentError) Wrong type. The password and hash need to be strings.
(comeonin 2.6.0) lib/comeonin/bcrypt.ex:122: Comeonin.Bcrypt.checkpw/2
(doorman 0.6.2) lib/doorman.ex:29: Doorman.authenticate/3
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:9: ChatterWeb.SessionController.create/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.action/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.phoenix_controller_pipeline/2
(phoenix 1.5.4) lib/phoenix/router.ex:352: Phoenix.Router.__call__/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:65: Phoenix.Endpoint.Cowboy2Handler.init/4
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible link 'New chat room' but 0, visible links were found.
code: |> click(new_chat_link())
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_creates_new_chat_room_test.exs:10: (test)
Finished in 3.9 seconds
1 test, 1 failure
Whoa! That is an unexpected error. The stack trace goes out of our application and into Doorman's modules and even into Comeonin, a library Doorman uses:
** (ArgumentError) Wrong type. The password and hash need to be strings.
(comeonin 2.6.0) lib/comeonin/bcrypt.ex:122: Comeonin.Bcrypt.checkpw/2
(doorman 0.6.2) lib/doorman.ex:29: Doorman.authenticate/3
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:9: ChatterWeb.SessionController.create/2
The error may seem daunting at first. But focusing on the error message, we see
that the password
and hash
need to be strings. We set the password in our
user factory, so it is a string. But the hashed_password
is missing. Doorman
and Comeonin must be trying to compare our password to the missing hashed
version, and that's why we get an error.
We would expect Doorman to set the hashed_password
for us. And indeed, looking
at the User
module in the documentation's example, we see it import a
hash_password/1
function from Doorman.Auth.Bcrypt
. Let's use that function
in our factory.
At this point, I do not know if all of our test users will need a
hashed_password
. And since hashing could be slow, I'll create a new function
set_password/2
to do the hashing. Let's change the insert(:user)
in our test
to build a user, set and hash the password, and then insert it:
# test/chatter_web/features/user_creates_new_chat_room_test.exs
test "user creates a new chat room successfully", %{session: session} do
- user = insert(:user)
+ user = build(:user) |> set_password("superpass") |> insert()
session
|> visit("/")
Now let's add the set_password/2
function to our factory:
# test/support/factory.ex
def set_password(user, password) do user |> Ecto.Changeset.change(%{password: password}) |> Doorman.Auth.Bcrypt.hash_password() |> Ecto.Changeset.apply_changes() endend
In set_password/2
, we take a User
struct and a password
. We then turn that
data into a changeset, since the Doorman.Auth.Bcrypt.hash_password/1
function
requires a changeset. Finally, we apply the changes to get a User
struct ready
to be inserted into the database.
Now rerun our test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible link 'New chat room' but 0, visible links were found.
code: |> click(new_chat_link())
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_creates_new_chat_room_test.exs:10: (test)
Finished in 4.5 seconds
1 test, 1 failure
Alright! We're past the strange error Doorman was throwing. And now, our users can sign in! But why can't Wallaby find the "New chat room" link?
Our application loses all knowledge of a user's authentication when it redirects to a new page — we need to use a session.
Setting the current user
Doorman.logged_in?/1
checks for a current_user
in the conn.assigns
. But we need another plug to
set the current_user
in the first place. Doorman's documentation recommends
adding the Doorman.Login.Session
plug in our :browser
pipeline to do just
that. So let's do it:
# lib/chatter_web/router.ex
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
plug Doorman.Login.Session end
Now, rerun the test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
Compiling 2 files (.ex)
18:54:14.932 [error] #PID<0.647.0> running ChatterWeb.Endpoint (connection #PID<0.623.0>, stream id 3) terminated
Server: localhost:4002 (http)
Request: GET /
** (exit) an exception was raised:
** (ArgumentError) nil given for :session_secret. Comparison with nil is forbidden as it is unsafe. Instead write a query with is_nil/1, for example: is_nil(s.session_secret)
(ecto 3.4.6) lib/ecto/query/builder/filter.ex:135: Ecto.Query.Builder.Filter.kw!/7
(ecto 3.4.6) lib/ecto/query/builder/filter.ex:128: Ecto.Query.Builder.Filter.kw!/3
(ecto 3.4.6) lib/ecto/query/builder/filter.ex:110: Ecto.Query.Builder.Filter.filter!/6
(ecto 3.4.6) lib/ecto/query/builder/filter.ex:122: Ecto.Query.Builder.Filter.filter!/7
(ecto 3.4.6) lib/ecto/repo/queryable.ex:70: Ecto.Repo.Queryable.get_by/4
(doorman 0.6.2) lib/login/session.ex:2: Doorman.Login.Session.call/2
(chatter 0.1.0) ChatterWeb.Router.browser/2
(chatter 0.1.0) lib/chatter_web/router.ex:1: ChatterWeb.Router.__pipe_through1__/1
(phoenix 1.5.4) lib/phoenix/router.ex:347: Phoenix.Router.__call__/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:65: Phoenix.Endpoint.Cowboy2Handler.init/4
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
1) test user creates a new chat room successfully (ChatterWeb.UserCreatesNewChatRoomTest)
test/chatter_web/features/user_creates_new_chat_room_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible link 'New chat room' but 0, visible links were found.
code: |> click(new_chat_link())
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_creates_new_chat_room_test.exs:10: (test)
Finished in 4.7 seconds
1 test, 1 failure
The output is large, but the main error is this one:
** (ArgumentError) nil given for :session_secret. Comparison with nil is
forbidden as it is unsafe. Instead write a query with is_nil/1, for example:
is_nil(s.session_secret)
Our :session_secret
is nil
for some reason. Looking at the stack trace, we
can see it comes from Doorman:
(doorman 0.6.2) lib/login/session.ex:2: Doorman.Login.Session.call/2
Why do we get that error?
If you recall, we defined a session_secret
in our users
table, but we never
set it in our tests. According to Doorman's documentation, it needs to be set
during user creation with Doorman.Auth.Secret.put_session_secret/1
. Let's add
that step to our set_password/2
function to set the session secret when we set
the password:
# test/support/factory.ex
def set_password(user, password) do
user
|> Ecto.Changeset.change(%{password: password})
|> Doorman.Auth.Bcrypt.hash_password()
|> Doorman.Auth.Secret.put_session_secret() |> Ecto.Changeset.apply_changes()
end
Now run our test once more!
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
.
Finished in 1.4 seconds
1 test, 0 failures
Great! We did it. Our users can now sign in before they start chatting.
Testing invalid authentication credentials
Before we move further, there's one thing we glossed over in our quest for a passing feature spec. When I copied over Doorman's example of creating a session, I called it a modified version because we didn't include any error handling. Let's do that now.
Create a test file for the session controller
test/chatter_web/controllers/session_controller_test.exs
, and add the
following test:
defmodule ChatterWeb.SessionControllerTest do
use ChatterWeb.ConnCase, async: true
describe "create/2" do
test "renders error when email/password combination is invalid", %{conn: conn} do
user = build(:user) |> set_password("superpass") |> insert()
response =
conn
|> post(Routes.session_path(conn, :create), %{
"email" => user.email,
"password" => "invalid password"
})
|> html_response(200)
assert response =~ "Invalid email or password"
end
end
end
Let's run the test:
$ mix test test/chatter_web/controllers/session_controller_test.exs
Compiling 2 files (.ex)
1) test create/2 renders error when email/password combination is invalid (ChatterWeb.SessionControllerTest)
test/chatter_web/controllers/session_controller_test.exs:5
** (UndefinedFunctionError) function nil.id/0 is undefined. If you are using the dot syntax, such as map.field or module.function(), make sure the left side of the dot is an atom or a map
code: |> post(Routes.session_path(conn, :create), %{
stacktrace:
nil.id()
(doorman 0.6.2) lib/login/session.ex:12: Doorman.Login.Session.login/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:12: ChatterWeb.SessionController.create/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.action/2
(chatter 0.1.0) lib/chatter_web/controllers/session_controller.ex:1: ChatterWeb.SessionController.phoenix_controller_pipeline/2
(phoenix 1.5.4) lib/phoenix/router.ex:352: Phoenix.Router.__call__/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.plug_builder_call/2
(chatter 0.1.0) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.call/2
(phoenix 1.5.4) lib/phoenix/test/conn_test.ex:225: Phoenix.ConnTest.dispatch/5
test/chatter_web/controllers/session_controller_test.exs:11: (test)
Finished in 0.7 seconds
1 test, 1 failure
We see the nil.id/0
error because we're always assuming
Doorman.authenticate/3
will return a user
. But when the email/password
combination is invalid, the function returns nil
. So we're accidentally
passing nil
to Doorman.Login.Session.login/2
instead of a user
.
Let's go ahead and make it a case statement to handle the failure case:
# lib/chatter_web/controllers/session_controller.ex
def create(conn, %{"email" => email, "password" => password}) do
- user = Doorman.authenticate(email, password)
+ case Doorman.authenticate(email, password) do
+ nil ->
+ conn
+ |> put_flash(:error, "Invalid email or password")
+ |> render("new.html")
- conn
- |> Doorman.Login.Session.login(user)
- |> redirect(to: "/")
+ user ->
+ conn
+ |> Doorman.Login.Session.login(user)
+ |> redirect(to: "/")
+ end
end
Now rerun our test:
$ mix test test/chatter_web/controllers/session_controller_test.exs
Compiling 2 files (.ex)
.
Finished in 0.7 seconds
1 test, 0 failures
Excellent. We've now covered that failure case.
Updating Broken Tests
With authentication in place, I would expect the rest of our feature tests to fail, since users in those tests aren't signed in. Let's run all of our feature tests to see if that's true:
$ mix test test/chatter_web/features
Compiling 1 file (.ex)
1) test user can visit homepage (ChatterWeb.UserVisitsHomepageTest)
test/chatter_web/features/user_visits_homepage_test.exs:4
** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element that matched the css '.title' but 0, visible elements were found.
code: |> assert_has(Query.css(".title", text: "Welcome to Chatter!"))
stacktrace:
test/chatter_web/features/user_visits_homepage_test.exs:7: (test)
2) test user visits rooms page to see a list of rooms (ChatterWeb.UserVisitsRoomsPageTest)
test/chatter_web/features/user_visits_rooms_page_test.exs:4
** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'room' but 0, visible elements with the attribute were found.
code: |> assert_has(room_name(room1))
stacktrace:
test/chatter_web/features/user_visits_rooms_page_test.exs:9: (test)
3) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
test/chatter_web/features/user_can_chat_test.exs:4
** (Wallaby.QueryError) Expected to find 1, visible link 'chat room 0' but 0, visible links were found.
code: |> join_room(room.name)
stacktrace:
(wallaby 0.26.2) lib/wallaby/browser.ex:716: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_can_chat_test.exs:11: (test)
Finished in 4.7 seconds
4 tests, 3 failures
All three tests fail because Wallaby is unable to find some element that was
there before adding authentication. Though not evident from the errors, that
happens because the RequireLogin
plug redirects unauthenticated users to the
sign-in page.
To fix those errors, we simply need to add authentication to our tests. Let's
start with /user_visits_rooms_page_test.exs
. Copy the sign_in/2
function
from our previous feature test, and create a user to sign in:
# test/chatter_web/features/user_visits_rooms_page_test.exs
test "user visits rooms page to see a list of rooms", %{session: session} do
[room1, room2] = insert_pair(:chat_room)
user = build(:user) |> set_password("password") |> insert()
session
|> visit(rooms_index())
|> sign_in(as: user) |> assert_has(room_name(room1))
|> assert_has(room_name(room2))
end
defp sign_in(session, as: user) do session |> fill_in(Query.text_field("Email"), with: user.email) |> fill_in(Query.text_field("Password"), with: user.password) |> click(Query.button("Sign in")) end
defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)
Now run that test:
$ mix test test/chatter_web/features/user_visits_rooms_page_test.exs
.
Finished in 1.3 seconds
1 test, 0 failures
Good!
Before moving forward with the other two failing tests, let's pause to do a
refactoring: extract the sign_in/2
function into a module that we can reuse
across tests. That way, we can avoid having to copy the sign_in/2
logic over
and over again.
Extracting a common sign_in/2
function
Create a ChatterWeb.FeatureHelpers
module in
test/support/feature_helpers.ex
, and move the sign_in/2
function there.
Since we're moving it to make it reusable, change it from a private function to
a public one:
# test/support/feature_helpers.ex
defmodule ChatterWeb.FeatureHelpers do
def sign_in(session, as: user) do
session
|> fill_in(Query.text_field("Email"), with: user.email)
|> fill_in(Query.text_field("Password"), with: user.password)
|> click(Query.button("Sign in"))
end
end
Our sign_in/2
function uses Wallaby's DSL, so add that to the file:
# test/support/feature_helpers.ex
defmodule ChatterWeb.FeatureHelpers do
use Wallaby.DSL
def sign_in(session, as: user) do
Finally, import our new ChatterWeb.FeatureHelpers
in our test, and remove the
sign_in/2
private function to avoid conflicts:
# test/chatter_web/features/user_visits_room_page_test.exs
defmodule ChatterWeb.UserVisitsRoomsPageTest do
use ChatterWeb.FeatureCase, async: true
+ import ChatterWeb.FeatureHelpers
+
test "user visits rooms page to see a list of rooms", %{session: session} do
[room1, room2] = insert_pair(:chat_room)
user = build(:user) |> set_password("password") |> insert()
session
|> visit(rooms_index())
|> sign_in(as: user)
|> assert_has(room_name(room1))
|> assert_has(room_name(room2))
end
- defp sign_in(session, as: user) do
- session
- |> fill_in(Query.text_field("Email"), with: user.email)
- |> fill_in(Query.text_field("Password"), with: user.password)
- |> click(Query.button("Sign in"))
- end
-
defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)
Now rerun that test:
$ mix test test/chatter_web/features/user_visits_rooms_page_test.exs
Compiling 1 file (.ex)
Generated chatter app
.
Finished in 1.3 seconds
1 test, 0 failures
Great! The extraction worked, but we're not done. Let's include
ChatterWeb.FeatureHelpers
in all feature tests. The easiest way to do that is
to import the module in our ChatterWeb.FeatureCase
:
# test/support/feature_case.ex
quote do
use Wallaby.DSL
import Chatter.Factory
import ChatterWeb.FeatureHelpers
alias ChatterWeb.Router.Helpers, as: Routes
@endpoint ChatterWeb.Endpoint
end
And now we can remove the import ChatterWeb.FeatureHelpers
from our test,
since it'll be imported via use ChatterWeb.FeatureCase
:
# test/chatter_web/features/user_visits_room_page_test.exs
defmodule ChatterWeb.UserVisitsRoomsPageTest do
use ChatterWeb.FeatureCase, async: true
- import ChatterWeb.FeatureHelpers
-
test "user visits rooms page to see a list of rooms", %{session: session} do
Rerun the test. It should still pass:
$ mix test test/chatter_web/features/user_visits_rooms_page_test.exs
Compiling 1 file (.ex)
.
Finished in 1.3 seconds
1 test, 0 failures
Good!
Now that we are including a sign_in/2
function across all feature tests, we
need to remove the original sign_in/2
private function from
user_creates_new_chat_room_test.exs
. Do that:
# test/chatter_web/features/user_creates_new_chat_room_test.exs
|> assert_has(room_title("elixir"))
end
- defp sign_in(session, as: user) do
- session
- |> fill_in(Query.text_field("Email"), with: user.email)
- |> fill_in(Query.text_field("Password"), with: user.password)
- |> click(Query.button("Sign in"))
- end
-
defp new_chat_link, do: Query.link("New chat room")
And run that test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
.
Finished in 1.3 seconds
1 test, 0 failures
Excellent! Now we can easily add authentication to the rest of the feature tests.
Updating the rest of the broken feature tests
Let's update user_visits_homepage_text.exs
next. Create a user and sign in:
# test/chatter_web/features/user_visits_homepage_test.exs
defmodule ChatterWeb.UserVisitsHomepageTest do
use ChatterWeb.FeatureCase, async: true
test "user can visit homepage", %{session: session} do
user = build(:user) |> set_password("password") |> insert()
session
|> visit("/")
|> sign_in(as: user) |> assert_has(Query.css(".title", text: "Welcome to Chatter!"))
end
end
Run the test:
$ mix test test/chatter_web/features/user_visits_homepage_test.exs
.
Finished in 1.1 seconds
1 test, 0 failures
Great.
The last test to update is user_can_chat_test.exs
. Since two users
chat in that test, we need to sign in twice. Unlike the other tests, however,
this test already has the concept of a "user":
user =
metadata
|> new_user()
|> visit(rooms_index())
|> join_room(room.name)
A single look at new_user/1
shows that the function is misleading — It
doesn't create a user; it creates a session. We'll correct the misleading name
in a minute. But first, let's add authentication so our test passes:
# test/chatter_web/features/user_can_chat_test.exs
test "user can chat with others successfully", %{metadata: metadata} do
room = insert(:chat_room)
user1 = build(:user) |> set_password("password") |> insert() user2 = build(:user) |> set_password("password") |> insert()
user =
metadata
|> new_user()
|> visit(rooms_index())
|> sign_in(as: user1) |> join_room(room.name)
other_user =
metadata
|> new_user()
|> visit(rooms_index())
|> sign_in(as: user2) |> join_room(room.name)
We used user1
and user2
to avoid conflicts with user
and other_user
(which are truly sessions). Run the test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
.
Finished in 2.4 seconds
1 test, 0 failures
Great! Now that the test is passing, we can refactor it. Let's rename the session-related code to use "session" terminology rather than "user" terminology:
# test/chatter_web/features/user_can_chat_test.exs
test "user can chat with others successfully", %{metadata: metadata} do
room = insert(:chat_room)
user1 = build(:user) |> set_password("password") |> insert()
user2 = build(:user) |> set_password("password") |> insert()
- user =
+ session1 =
metadata
- |> new_user()
+ |> new_session()
|> visit(rooms_index())
|> sign_in(as: user1)
|> join_room(room.name)
- other_user =
+ session2 =
metadata
- |> new_user()
+ |> new_session()
|> visit(rooms_index())
|> sign_in(as: user2)
|> join_room(room.name)
- user
+ session1
|> add_message("Hi everyone")
- other_user
+ session2
|> assert_has(message("Hi everyone"))
|> add_message("Hi, welcome to #{room.name}")
- user
+ session1
|> assert_has(message("Hi, welcome to #{room.name}"))
end
- defp new_user(metadata) do
- {:ok, user} = Wallaby.start_session(metadata: metadata)
- user
+ defp new_session(metadata) do
+ {:ok, session} = Wallaby.start_session(metadata: metadata)
+ session
end
defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)
Now rerun the test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
.
Finished in 2.4 seconds
1 test, 0 failures
Excellent!
Checking for regressions
Now that we've worked on all of our feature tests, let's run them all to confirm they pass:
$ mix test test/chatter_web/features
....
Finished in 2.7 seconds
4 tests, 0 failures
Perfect. Let's now run our full test suite to see if we have any other failures:
$ mix test
....
1) test create/2 renders new page with errors when data is invalid (ChatterWeb.ChatRoomControllerTest)
** (RuntimeError) expected response with status 200, got: 302, with body:
<html><body>You are being <a href="/sign_in">redirected</a>.</body></html>
code: |> html_response(200)
stacktrace:
(phoenix 1.5.4) lib/phoenix/test/conn_test.ex:369: Phoenix.ConnTest.response/2
(phoenix 1.5.4) lib/phoenix/test/conn_test.ex:383: Phoenix.ConnTest.html_response/2
test/chatter_web/controllers/chat_room_controller_test.exs:12: (test)
..........
Finished in 2.7 seconds
16 tests, 1 failure
Aha! We do have one more failure. The controller test fails because of authentication. Let's handle that next.
Sign in for the controller
Let's open up the test that is failing and run it on its own:
$ 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)
** (RuntimeError) expected response with status 200, got: 302, with body:
<html><body>You are being <a href="/sign_in">redirected</a>.</body></html>
code: |> html_response(200)
stacktrace:
(phoenix 1.5.4) lib/phoenix/test/conn_test.ex:369: Phoenix.ConnTest.response/2
(phoenix 1.5.4) lib/phoenix/test/conn_test.ex:383: Phoenix.ConnTest.html_response/2
test/chatter_web/controllers/chat_room_controller_test.exs:12: (test)
Finished in 0.08 seconds
1 test, 1 failure
Good. Now let's add a sign_in/1
function to fix our test. Unlike our feature
tests, this test will not sign in via the web browser. Instead, we will add a
helper function that acts as a back door. Add a sign_in/1
helper that uses
Doorman.Login.Session.login/2
function:
# test/chatter_web/controllers/chat_room_controller_test.exs
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
|> sign_in() |> post(Routes.chat_room_path(conn, :create), %{"room" => params})
|> html_response(200)
assert response =~ "has already been taken"
end
end
def sign_in(conn) do user = build(:user) |> set_password("password") |> insert() conn |> Doorman.Login.Session.login(user) endend
Rerun 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)
** (ArgumentError) session not fetched, call fetch_session/2
code: |> sign_in()
stacktrace:
(plug 1.10.4) lib/plug/conn.ex:1570: Plug.Conn.get_session/1
(plug 1.10.4) lib/plug/conn.ex:1729: Plug.Conn.put_session/2
(doorman 0.6.2) lib/login/session.ex:12: Doorman.Login.Session.login/2
test/chatter_web/controllers/chat_room_controller_test.exs:11: (test)
Finished in 0.4 seconds
1 test, 1 failure
That may be a confusing error: ** (ArgumentError) session not fetched, call
fetch_session/2
. Typically, Phoenix fetches the session for us, so many don't
know we need to fetch a session first to access it. Fortunately,
Plug.Test comes with an
init_test_session/2
function made for tests like ours. Let's use it:
# test/chatter_web/controllers/chat_room_controller_test.exs
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
|> sign_in()
|> post(Routes.chat_room_path(conn, :create), %{"room" => params})
|> html_response(200)
assert response =~ "has already been taken"
end
end
def sign_in(conn) do
user = build(:user) |> set_password("password") |> insert()
conn
|> Plug.Test.init_test_session(%{}) |> Doorman.Login.Session.login(user)
end
end
Now rerun the test:
$ mix test test/chatter_web/controllers/chat_room_controller_test.exs
.
Finished in 0.6 seconds
1 test, 0 failures
Great!
Let's run all tests once more to make sure they pass:
$ mix test
................
Finished in 2.9 seconds
16 tests, 0 failures
Perfect! Nothing like seeing a sea of green dots. Now is a good time to commit before we dive into refactoring.
Refactoring
Most of the code we added in this chapter has been related to tests. Some of
that code — like the sign_in/2
feature helper — we already
refactored to reuse across tests. But there's still more we can do.
Even though we're not testing multiple controllers with authentication, it would
be nice to reuse the sign_in/1
function we defined in
chat_room_controller_test
. Let's do that next.
Refactoring controller sign_in/1
Run the controller test to have a baseline for refactoring. Remember, we want to keep that test passing while refactoring:
$ mix test test/chatter_web/controllers/chat_room_controller_test.exs
.
Finished in 0.5 seconds
1 test, 0 failures
Good. Now let's move the sign_in/1
function to a new file: ConnTestHelpers
.
Create a file named test/support/conn_test_helpers.ex
, and move the function
there. Remember to import our factory module since we create the user in the
sign_in/1
helper:
# test/support/conn_test_helpers.ex
defmodule ChatterWeb.ConnTestHelpers do
import Chatter.Factory
def sign_in(conn) do
user = build(:user) |> set_password("password") |> insert()
conn
|> Plug.Test.init_test_session(%{})
|> Doorman.Login.Session.login(user)
end
end
Now, let's remove the sign_in/1
function from the controller and import
our
helper module:
# test/chatter_web/controllers/chat_room_controller_test.exs
defmodule ChatterWeb.ChatRoomControllerTest do
use ChatterWeb.ConnCase, async: true
+ import ChatterWeb.ConnTestHelpers
+
describe "create/2" do
test "renders new page with errors when data is invalid", %{conn: conn} do
insert(:chat_room, name: "elixir")
# unchanged code omitted
assert response =~ "has already been taken"
end
end
-
- def sign_in(conn) do
- user = build(:user) |> set_password("password") |> insert()
-
- conn
- |> Plug.Test.init_test_session(%{})
- |> Doorman.Login.Session.login(user)
- end
end
Rerun the controller test:
$ mix test test/chatter_web/controllers/chat_room_controller_test.exs
.
Finished in 0.4 seconds
1 test, 0 failures
Great! But we can improve this even more. I think we can safely import that
helper module into all controllers, so let's move the import
declaration to
the ConnCase
module itself.
First, add it to the ChatterWeb.ConnCase
module's using
macro:
# test/support/conn_case.ex
using do
quote do
import Plug.Conn
import Phoenix.ConnTest
import Chatter.Factory
import ChatterWeb.ConnCase
import ChatterWeb.ConnTestHelpers
alias ChatterWeb.Router.Helpers, as: Routes
Now, remove the import
declaration from the controller:
# test/chatter_web/controllers/chat_room_controller_test.exs
defmodule ChatterWeb.ChatRoomControllerTest do
use ChatterWeb.ConnCase, async: true
- import ChatterWeb.ConnTestHelpers
-
describe "create/2" do
And let's rerun our test:
$ mix test test/chatter_web/controllers/chat_room_controller_test.exs
.
Finished in 0.4 seconds
1 test, 0 failures
Excellent!
Refactoring user factory
I initially assumed our tests wouldn't always need a user
with a
hashed_password
. That's why I created a set_password/1
helper function for
our user
factory. But the more we progressed through this chapter, the more
the assumption proved false. Therefore, before we complete this feature, I would
like to go back and remove that unneeded step of complexity.
To do this type of refactoring, I like to use one test as a baseline, which we
will try to keep passing throughout the process. Once we successfully refactor
that test, we can update the others that are using the set_password/1
helper.
Let's pick user_creates_new_chat_room_test
as our baseline. Currently, we do
the following to create a valid user:
build(:user) |> set_password("superpass") |> insert()
Ideally, we can go back to a simple insert(:user)
. So, let's run our baseline
test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
.
Finished in 1.6 seconds
1 test, 0 failures
Good. Now open up the factory file.
Because Doorman.Auth
functions work with changesets, set_password/2
currently turns the user
into a changeset. We'll need to do something similar
in our user_factory
, so copy the body of set_password/2
into the
user_factory
definition. We'll now set the password via
Ecto.Changeset.change/2
instead of directly assigning it to %Chatter.User{}
:
# test/support/factory.ex
def user_factory do
- %Chatter.User{
- email: sequence(:email, &"super#{&1}@example.com"),
- password: "password1"
- }
+ %Chatter.User{email: sequence(:email, &"super#{&1}@example.com")}
+ |> Ecto.Changeset.change(%{password: "password1"})
+ |> Doorman.Auth.Bcrypt.hash_password()
+ |> Doorman.Auth.Secret.put_session_secret()
+ |> Ecto.Changeset.apply_changes()
end
def set_password(user, password) do
# this is unchanged for now
end
Now rerun our baseline test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
.
Finished in 1.8 seconds
1 test, 0 failures
Great. It's still passing. Now open up the test and insert the user directly
instead of building it and piping it through set_password/1
:
# 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
- user = build(:user) |> set_password("superpass") |> insert()
+ user = insert(:user)
session
|> visit("/")
Rerun the test:
$ mix test test/chatter_web/features/user_creates_new_chat_room_test.exs
.
Finished in 1.5 seconds
1 test, 0 failures
Excellent! Our changes worked. Now find all other instances where we used
build(:user) |> set_password("password") |> insert()
and replace them with
insert(:user)
. A quick search found these files for me:
test/support/conn_test_helpers.ex
test/chatter_web/controllers/session_controller_test.exs
test/chatter_web/features/user_visits_rooms_page_test.exs
test/chatter_web/features/user_can_chat_test.exs
test/chatter_web/features/user_visits_homepage_test.exs
Once we replace those, let's run our test suite:
$ mix test
................
Finished in 3.3 seconds
16 tests, 0 failures
Perfect! Since everything is working without set_password/2
, we can safely
delete it from the factory:
# test/support/factory.ex
def user_factory do
%Chatter.User{email: sequence(:email, &"super#{&1}@example.com")}
|> Ecto.Changeset.change(%{password: "password1"})
|> Doorman.Auth.Bcrypt.hash_password()
|> Doorman.Auth.Secret.put_session_secret()
|> Ecto.Changeset.apply_changes()
end
-
- def set_password(user, password) do
- user
- |> Ecto.Changeset.change(%{password: password})
- |> Doorman.Auth.Bcrypt.hash_password()
- |> Doorman.Auth.Secret.put_session_secret()
- |> Ecto.Changeset.apply_changes()
- end
Ah, nothing like deleting code. 🔥
Let's rerun our tests once more to be certain nothing depended on the
set_password/2
function:
$ mix test
Compiling 1 file (.ex)
................
Finished in 3.2 seconds
16 tests, 0 failures
Nicely done! Our app has authentication. Unfortunately for our users, they cannot create accounts and use our magical chat app! So commit this work, and let's do that next.
This is a work in progress.
To find out more, see TDD Phoenix - a work in progress.