TDD Phoenix

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.