TDD Phoenix

Creating Users

In the last chapter, we set up authentication so that users who haven't signed in cannot access our application. But our users do not have a way to create accounts yet. That is what we'll do in this chapter. As usual, let's start with a test.

Users can create accounts

Let's create a new feature test in which a user will sign up with an email and password, land on the root page, and see the available chat rooms. We will call it GuestSignsUpTest, since a person is a guest in our application until they create an account, at which point they become users.

# test/chatter_web/features/guest_signs_up_test.exs

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

  test "guest signs up with email and password", %{session: session} do
    room = insert(:chat_room)
    attrs = params_for(:user)

    session
    |> visit("/")
    |> click(Query.link("Create an account"))
    |> fill_in(Query.text_field("Email"), with: attrs[:email])
    |> fill_in(Query.text_field("Password"), with: attrs[:password])
    |> click(Query.button("Sign up"))
    |> assert_has(Query.data("role", "room", text: room.name))
  end
end

That test should look familiar. We use the session provided by our setup in FeatureCase, visit our home page, expect to be redirected to the login page, where we should be able to find a link to create an account. We then fill in the email and password fields and submit the form with "Sign up". Finally, we expect to see a room's name in the list of rooms found in the home page.

Now let's run our test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs


  1) test guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_test.exs:4
     ** (Wallaby.QueryError) Expected to find 1, visible link 'Create an account' but 0, visible links were found.

     code: |> click(Query.link("Create an account"))
     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/guest_signs_up_test.exs:10: (test)



Finished in 3.7 seconds
1 test, 1 failure

We get an expected error: Wallaby cannot find the Create an account link. Let's add that to our sign in page:

# lib/chatter_web/templates/session/new.html.eex

  <%= submit "Sign in" %>
<% end %>

<div>  <p>Don't have an account?</p>  <%= link "Create an account", to: Routes.user_path(@conn, :new) %></div>

Now let's rerun our test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 1 file (.ex)
warning: ChatterWeb.Router.Helpers.user_path/2 is undefined or private. Did you mean one of:

      * session_path/2
      * session_path/3

  lib/chatter_web/templates/session/new.html.eex:15: ChatterWeb.SessionView."new.html"/1

19:09:25.304 [error] #PID<0.577.0> running ChatterWeb.Endpoint (connection #PID<0.575.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /sign_in
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function ChatterWeb.Router.Helpers.user_path/2 is undefined or private
        (chatter 0.1.0) ChatterWeb.Router.Helpers.user_path(%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.577.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 (g2gCZAACdjF0AAAAAmQABW93bmVyWGQADW5vbm9kZUBub2hvc3QAAAI8AAAAAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/sign_in", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "Fj_WEgZp5uhQUIgAAAEG"}, {"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/templates/session/new.html.eex:15: 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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_test.exs:4
     ** (Wallaby.QueryError) Expected to find 1, visible link 'Create an account' but 0, visible links were found.

     code: |> click(Query.link("Create an account"))
     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/guest_signs_up_test.exs:10: (test)



Finished in 3.8 seconds
1 test, 1 failure

By now, you might be used to seeing some of these large errors, especially when dealing with undefined routes, views, or templates. In this case, we see a warning and an error about a user_path/2 not being defined: ** (UndefinedFunctionError) function ChatterWeb.Router.Helpers.user_path/2 is undefined or private.

Let's go ahead and define that route next to our sessions routes:

# lib/chatter_web/router.ex

    get "/sign_in", SessionController, :new
    resources "/sessions", SessionController, only: [:create]
    resources "/users", UserController, only: [:new]  end

  scope "/", ChatterWeb do

Rerun the test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 2 files (.ex)
warning: ChatterWeb.UserController.init/1 is undefined (module ChatterWeb.UserController is not available or is yet to be defined)
  lib/chatter_web/router.ex:2: ChatterWeb.Router.__checks__/0

19:10:58.514 [error] #PID<0.602.0> running ChatterWeb.Endpoint (connection #PID<0.596.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /users/new
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function ChatterWeb.UserController.init/1 is undefined (module ChatterWeb.UserController is not available)
        ChatterWeb.UserController.init(:new)
        (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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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: |> fill_in(Query.text_field("Email"), with: attrs[:email])
     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/guest_signs_up_test.exs:11: (test)



Finished in 3.9 seconds
1 test, 1 failure

Now we see that we do not have a UserController: ** (UndefinedFunctionError) function ChatterWeb.UserController.init/1 is undefined (module ChatterWeb.UserController is not available). If this looks familiar, that's good! It means the TDD process for CRUD applications is becoming engrained in your mind. You'll soon start anticipating the errors, and the test failures will confirm your knowledge of the application's composition. But enough talk. Let's create the controller:

# lib/chatter_web/controllers/user_controller.ex

defmodule ChatterWeb.UserController do
  use ChatterWeb, :controller
end

Now run the test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 1 file (.ex)
Generated chatter app
19:12:27.612 [error] #PID<0.595.0> running ChatterWeb.Endpoint (connection #PID<0.589.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /users/new
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function ChatterWeb.UserController.new/2 is undefined or private
        (chatter 0.1.0) ChatterWeb.UserController.new(%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: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYQWxnc0Z3cVBRZ2EtSEdmRG8wcGlqRGhh.jYv1qpQpwgXRBlIfOJiIVnygLPZ7bkjzionPLobIWOM"}, halted: false, host: "localhost", method: "GET", owner: #PID<0.595.0>, params: %{}, path_info: ["users", "new"], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_action => :new, :phoenix_controller => ChatterWeb.UserController, :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.UserView, :plug_session => %{"_csrf_token" => "AlgsFwqPQga-HGfDo0pijDha"}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYQWxnc0Z3cVBRZ2EtSEdmRG8wcGlqRGhh.jYv1qpQpwgXRBlIfOJiIVnygLPZ7bkjzionPLobIWOM"}, 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"}, {"cookie", "_chatter_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYQWxnc0Z3cVBRZ2EtSEdmRG8wcGlqRGhh.jYv1qpQpwgXRBlIfOJiIVnygLPZ7bkjzionPLobIWOM"}, {"host", "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 (g2gCZAACdjF0AAAAAmQABW93bmVyWGQADW5vbm9kZUBub2hvc3QAAAJGAAAAAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/users/new", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "Fj_WPHo5FOjbVZsAAAIE"}, {"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/controllers/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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: |> fill_in(Query.text_field("Email"), with: attrs[:email])
     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/guest_signs_up_test.exs:11: (test)



Finished in 4.0 seconds
1 test, 1 failure

We don't have a new action in the controller: ** (UndefinedFunctionError) function ChatterWeb.UserController.new/2 is undefined or private. Let's fix that:

# lib/chatter_web/controllers/user_controller.ex

defmodule ChatterWeb.UserController do
  use ChatterWeb, :controller

  def new(conn, _params) do    render(conn, "new.html")  endend

Rerun the test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 2 files (.ex)
19:13:14.975 [error] #PID<0.603.0> running ChatterWeb.Endpoint (connection #PID<0.597.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /users/new
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function ChatterWeb.UserView.render/2 is undefined (module ChatterWeb.UserView is not available)
        ChatterWeb.UserView.render("new.html", %{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: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYdGNrd2hxazJ3cmNyY3IyeVlxOHo0LTVn.ezswqcDnyN93whhBPaTpo3evFD4EyZXANJ8Ao_didPE"}, halted: false, host: "localhost", method: "GET", owner: #PID<0.603.0>, params: %{}, path_info: ["users", "new"], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_action => :new, :phoenix_controller => ChatterWeb.UserController, :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.UserView, :plug_session => %{"_csrf_token" => "tckwhqk2wrcrcr2yYq8z4-5g"}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYdGNrd2hxazJ3cmNyY3IyeVlxOHo0LTVn.ezswqcDnyN93whhBPaTpo3evFD4EyZXANJ8Ao_didPE"}, 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"}, {"cookie", "_chatter_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYdGNrd2hxazJ3cmNyY3IyeVlxOHo0LTVn.ezswqcDnyN93whhBPaTpo3evFD4EyZXANJ8Ao_didPE"}, {"host", "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 (g2gCZAACdjF0AAAAAmQABW93bmVyWGQADW5vbm9kZUBub2hvc3QAAAJOAAAAAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/users/new", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "Fj_WR4ErUmAfBX4AAACB"}, {"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.UserView, view_template: "new.html"})
        (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/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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: |> fill_in(Query.text_field("Email"), with: attrs[:email])
     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/guest_signs_up_test.exs:11: (test)



Finished in 4.5 seconds
1 test, 1 failure

The error, ** (UndefinedFunctionError) function ChatterWeb.UserView.render/2 is undefined (module ChatterWeb.UserView is not available), says we do not have a UserView defined (as you might have expected). Let's add that view:

# lib/chatter_web/views/user_view.ex

defmodule ChatterWeb.UserView do
  use ChatterWeb, :view
end

Let's rerun our test. This time, you might expect the error to mention that our template not being defined:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 1 file (.ex)
Generated chatter app
19:14:21.626 [error] #PID<0.586.0> running ChatterWeb.Endpoint (connection #PID<0.580.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /users/new
** (exit) an exception was raised:
    ** (Phoenix.Template.UndefinedError) Could not render "new.html" for ChatterWeb.UserView, please define a matching clause for render/2 or define a template at "lib/chatter_web/templates/user/*". 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: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYSVl4Z2FnV3BaNm4yVzVxUTVuanlMSUst.IfrzOXtieMq73d-_8-cV3DtNHua2SMtDef7C1SpKVFM"}, halted: false, host: "localhost", method: "GET", owner: #PID<0.586.0>, params: %{}, path_info: ["users", "new"], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_action => :new, :phoenix_controller => ChatterWeb.UserController, :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.UserView, :plug_session => %{"_csrf_token" => "IYxgagWpZ6n2W5qQ5njyLIK-"}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYSVl4Z2FnV3BaNm4yVzVxUTVuanlMSUst.IfrzOXtieMq73d-_8-cV3DtNHua2SMtDef7C1SpKVFM"}, 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"}, {"cookie", "_chatter_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYSVl4Z2FnV3BaNm4yVzVxUTVuanlMSUst.IfrzOXtieMq73d-_8-cV3DtNHua2SMtDef7C1SpKVFM"}, {"host", "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 (g2gCZAACdjF0AAAAAmQABW93bmVyWGQADW5vbm9kZUBub2hvc3QAAAI9AAAAAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/users/new", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "Fj_WVwWAXBjyb58AAALD"}, {"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.UserView, 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/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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: |> fill_in(Query.text_field("Email"), with: attrs[:email])
     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/guest_signs_up_test.exs:11: (test)



Finished in 4.0 seconds
1 test, 1 failure

Our expectation was correct! We need to define a template:

** (Phoenix.Template.UndefinedError) Could not render "new.html" for
ChatterWeb.UserView, please define a matching clause for render/2 or define a
template at "lib/chatter_web/templates/user/*". No templates were compiled for
this module.

Let's just touch a new template:

$ mkdir lib/chatter_web/templates/user
$ touch lib/chatter_web/templates/user/new.html.eex

Now rerun the test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 1 file (.ex)


  1) test guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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: |> fill_in(Query.text_field("Email"), with: attrs[:email])
     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/guest_signs_up_test.exs:11: (test)



Finished in 4.0 seconds
1 test, 1 failure

Great! We now have a simple Wallaby error — no compilation errors or warnings. Wallaby cannot find an email input field for the form. Since the template is empty, that's not a surprise. Let's add a form:

# lib/chatter_web/templates/user/new.html.eex

<%= form_for @changeset, Routes.user_path(@conn, :create), fn f -> %>
  <label>
    Email: <%= text_input f, :email %>
  </label>

  <label>
    Password: <%= password_input f, :password %>
  </label>

  <%= submit "Sign up" %>
<% end %>

Let's rerun our test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 1 file (.ex)
19:18:40.752 [error] #PID<0.585.0> running ChatterWeb.Endpoint (connection #PID<0.579.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /users/new
** (exit) an exception was raised:
    ** (ArgumentError) assign @changeset not available in eex template.

Please make sure all proper assigns have been set. If this
is a child template, ensure assigns are given explicitly by
the parent template as they are not automatically forwarded.

Available assigns: [:conn, :view_module, :view_template]

        (phoenix_html 2.14.2) lib/phoenix_html/engine.ex:134: Phoenix.HTML.Engine.fetch_assign!/2
        (chatter 0.1.0) lib/chatter_web/templates/user/new.html.eex:1: ChatterWeb.UserView."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/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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: |> fill_in(Query.text_field("Email"), with: attrs[:email])
     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/guest_signs_up_test.exs:11: (test)



Finished in 4.2 seconds
1 test, 1 failure

We wrote the form as we want it to exist, but we don't have a @changeset defined. That's why we got the error: ** (ArgumentError) assign @changeset not available in eex template.

Let's pass a changeset assign from the controller right now. We'll reuse the User.changeset/2 function:

# lib/chatter_web/controllers/user_controller.ex

defmodule ChatterWeb.UserController do
  use ChatterWeb, :controller

  alias Chatter.User
  def new(conn, _params) do
    changeset = User.changeset(%User{}, %{})    render(conn, "new.html", changeset: changeset)  end
end

Now, if we run our test, we would expect to see a failure because related to Routes.user_path(@conn, :create) since we do not have a path for user creation defined in our routes:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 3 files (.ex)
19:26:40.468 [error] #PID<0.609.0> running ChatterWeb.Endpoint (connection #PID<0.603.0>, stream id 2) terminated
Server: localhost:4002 (http)
Request: GET /users/new
** (exit) an exception was raised:
    ** (ArgumentError) no action :create for ChatterWeb.Router.Helpers.user_path/2. The following actions/clauses are supported:

    user_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/user/new.html.eex:1: ChatterWeb.UserView."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/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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: |> fill_in(Query.text_field("Email"), with: attrs[:email])
     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/guest_signs_up_test.exs:11: (test)



Finished in 3.9 seconds
1 test, 1 failure

And our guess was correct! Let's define the route by adding :create to the routes for "/users":

# lib/chatter_web/router.ex

    get "/sign_in", SessionController, :new
    resources "/sessions", SessionController, only: [:create]
    resources "/users", UserController, only: [:new, :create]  end

Running our test now should tells us that we are missing the create/2 action in our UserController:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 2 files (.ex)
04:22:14.472 [error] #PID<0.610.0> running ChatterWeb.Endpoint (connection #PID<0.594.0>, stream id 5) terminated
Server: localhost:4002 (http)
Request: POST /users
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function ChatterWeb.UserController.create/2 is undefined or private
        (chatter 0.1.0) ChatterWeb.UserController.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" => "L3McFj59Sg4aNgBkLC0WBighBygGEFkpN2PYjN0zPA-7ElW_piJ_me3G", "user" => %{"email" => "super0@example.com", "password" => "password1"}}, cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYYUFMT1QzenRKdy1TaUFBWVhITXdrdWpu.yv06nFanD8g4QQZ0Egv_mh2UsyGcauCmwEjkZmSpN8g"}, halted: false, host: "localhost", method: "POST", owner: #PID<0.610.0>, params: %{"_csrf_token" => "L3McFj59Sg4aNgBkLC0WBighBygGEFkpN2PYjN0zPA-7ElW_piJ_me3G", "user" => %{"email" => "super0@example.com", "password" => "password1"}}, path_info: ["users"], path_params: %{}, port: 4002, private: %{ChatterWeb.Router => {[], %{}}, :phoenix_action => :create, :phoenix_controller => ChatterWeb.UserController, :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.UserView, :plug_session => %{"_csrf_token" => "aALOT3ztJw-SiAAYXHMwkujn"}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYYUFMT1QzenRKdy1TaUFBWVhITXdrdWpu.yv06nFanD8g4QQZ0Egv_mh2UsyGcauCmwEjkZmSpN8g"}, 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", "134"}, {"content-type", "application/x-www-form-urlencoded"}, {"cookie", "_chatter_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYYUFMT1QzenRKdy1TaUFBWVhITXdrdWpu.yv06nFanD8g4QQZ0Egv_mh2UsyGcauCmwEjkZmSpN8g"}, {"host", "localhost:4002"}, {"origin", "http://localhost:4002"}, {"referer", "http://localhost:4002/users/new"}, {"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 (g2gCZAACdjF0AAAAAmQABW93bmVyWGQADW5vbm9kZUBub2hvc3QAAAJPAAAAAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/users", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "Fj_0PNQGRjBHxBgAAALE"}, {"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" => "L3McFj59Sg4aNgBkLC0WBighBygGEFkpN2PYjN0zPA-7ElW_piJ_me3G", "user" => %{"email" => "super0@example.com", "password" => "password1"}})
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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(Query.data("role", "room", text: room.name))
     stacktrace:
       test/chatter_web/features/guest_signs_up_test.exs:14: (test)



Finished in 4.5 seconds
1 test, 1 failure

Right again! Let's add the create action to our controller. Now is a good time to reference the creating users section of Doorman's documentation. We'll add a slightly modified version of what is found in the documentation because we want to test drive both case statement scenarios.

We'll alias Doorman's Secret module and our Repo module. In the create/2 action, we'll add the user creation logic and redirect to the root path for now:

# lib/chatter_web/controllers/user_controller.ex

defmodule ChatterWeb.UserController do
  use ChatterWeb, :controller

  alias Doorman.Auth.Secret  alias Chatter.User
  alias Chatter.Repo
  def new(conn, _params) do
    # code omitted
  end

  def create(conn, %{"user" => params}) do    {:ok, _user} =      %User{}      |> User.changeset(params)      |> Secret.put_session_secret()      |> Repo.insert()    conn |> redirect(to: "/")  endend

Now let's rerun our test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 4 files (.ex)
04:57:57.980 [error] #PID<0.620.0> running ChatterWeb.Endpoint (connection #PID<0.604.0>, stream id 5) terminated
Server: localhost:4002 (http)
Request: POST /users
** (exit) an exception was raised:
    ** (MatchError) no match of right hand side value: {:error, #Ecto.Changeset<action: :insert, changes: %{session_secret: "KaJUlUtQl4rIN4jHjI3KBGVYbQ76CcYEvj_mSwzNtzlQroW0vuVREMoUYrF_c15s"}, errors: [email: {"can't be blank", [validation: :required]}, hashed_password: {"can't be blank", [validation: :required]}, session_secret: {"can't be blank", [validation: :required]}], data: #Chatter.User<>, valid?: false>}
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:15: ChatterWeb.UserController.create/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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(Query.data("role", "room", text: room.name))
     stacktrace:
       test/chatter_web/features/guest_signs_up_test.exs:14: (test)



Finished in 4.3 seconds
1 test, 1 failure

The error we get comes from the User.changeset/2 validations. If you recall from last chapter, that changeset was auto-generated from running the mix phx.gen.schema task.

So if you open the User module, you'll see we're validating things like the session_secret and the hashed_password. That's not what we want, so let's remove the default implementation, and we'll test drive the new implementation.

# lib/chatter/user.ex

 def changeset(user, attrs) do
-  user
-  |> cast(attrs, [:email, :hashed_password, :session_secret])
-  |> validate_required([:email, :hashed_password, :session_secret])
-  |> unique_constraint(:email)
 end

Now let's rerun our test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 2 files (.ex)
warning: variable "attrs" is unused (if the variable is not meant to be used, prefix it with an underscore)
  lib/chatter/user.ex:15: Chatter.User.changeset/2

warning: variable "user" is unused (if the variable is not meant to be used, prefix it with an underscore)
  lib/chatter/user.ex:15: Chatter.User.changeset/2

warning: unused import Ecto.Changeset
  lib/chatter/user.ex:3

05:08:47.616 [error] #PID<0.613.0> running ChatterWeb.Endpoint (connection #PID<0.597.0>, stream id 5) terminated
Server: localhost:4002 (http)
Request: POST /users
** (exit) an exception was raised:
    ** (FunctionClauseError) no function clause matching in Ecto.Changeset.put_change/3
        (ecto 3.4.6) lib/ecto/changeset.ex:1185: Ecto.Changeset.put_change(nil, :session_secret, "GoQo14Pl9AXTdAzmfmA1SqYhj5mMd1a_Ca_xOSUBIbhOM6H2mn_HCb137es49pUz")
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:18: ChatterWeb.UserController.create/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 guest signs up with email and password (ChatterWeb.GuestSignsUpTest)
     test/chatter_web/features/guest_signs_up_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(Query.data("role", "room", text: room.name))
     stacktrace:
       test/chatter_web/features/guest_signs_up_test.exs:14: (test)



Finished in 4.2 seconds
1 test, 1 failure

Ignore the warnings for now. We get an error because our User.changeset/2 function is returning nil. We clearly need to add back some casting and validation of fields. Let's "step in" and test-drive the behavior of our User.changeset/2 function.

Stepping in to Chatter.UserTest

First, create a test/chatter/user_test.exs file. Just as with Chatter.ChatTest, we'll use the Chatter.DataCase in this test to get a basic setup for dealing with Ecto. Copy the following:

# test/chatter/user_test.exs

defmodule Chatter.UserTest do
  use Chatter.DataCase, async: true

  alias Chatter.User
end

We'll want changeset/2 to do a few things:

  • validate that an email is provided
  • validate that a password is provided
  • generate a hashed_password from the password
  • validate uniqueness of an email

Let's start with the first requirement, validating the presence of an email:

# test/chatter/user_test.exs

defmodule Chatter.UserTest do
  use Chatter.DataCase, async: true

  alias Chatter.User

  describe "changeset/2" do    test "validates that an email must be present" do      params = %{}      changeset = User.changeset(%User{}, params)      assert "can't be blank" in errors_on(changeset).email    end  endend

Now run the test:

$ mix test test/chatter/user_test.exs:7
Excluding tags: [:test]
Including tags: [line: "7"]



  1) test changeset/2 validates that an email must be present (Chatter.UserTest)

     ** (FunctionClauseError) no function clause matching in Ecto.Changeset.traverse_errors/2

     The following arguments were given to Ecto.Changeset.traverse_errors/2:

         # 1
         nil

         # 2
         #Function<0.17687520/1 in Chatter.DataCase.errors_on/1>

     Attempted function clauses (showing 1 out of 1):

         def traverse_errors(%Ecto.Changeset{errors: errors, changes: changes, types: types} = changeset, msg_func) when is_function(msg_func, 1) or is_function(msg_func, 3)

     code: assert "can't be blank" in errors_on(changeset).email
     stacktrace:
       (ecto 3.4.6) lib/ecto/changeset.ex:2869: Ecto.Changeset.traverse_errors/2
       test/chatter/user_test.exs:12: (test)



Finished in 0.09 seconds
1 test, 1 failure

Not surprisingly, the return value is nil. Now, let's get past that error by casting the email field:

# lib/chatter/user.ex

    timestamps()
  end

  @doc false  def changeset(user, attrs) do    user    |> cast(attrs, [:email])  end

Let's rerun the test:

$ mix test test/chatter/user_test.exs:7
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "7"]



  1) test changeset/2 validates that an email must be present (Chatter.UserTest)
     test/chatter/user_test.exs:7
     ** (KeyError) key :email not found in: %{}
     code: assert "can't be blank" in errors_on(changeset).email
     stacktrace:
       test/chatter/user_test.exs:12: (test)



Finished in 0.07 seconds
1 test, 1 failure

Better. The error is no longer about the return value being nil. We now have the failure we want: there was no validation error for the email field. Let's add that validation:

# lib/chatter/user.ex

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email])
    |> validate_required([:email])  end

Now rerun the test:

$ mix test test/chatter/user_test.exs:7
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "7"]

.

Finished in 0.07 seconds
1 test, 0 failures

Great. Now let's add our second requirement: validating the presence of a password. Add the following test:

# test/chatter/user_test.exs

    test "validates that a password must be present" do
      params = %{}

      changeset = User.changeset(%User{}, params)

      assert "can't be blank" in errors_on(changeset).password
    end

Now let's run the new test:

$ mix test test/chatter/user_test.exs:15
Excluding tags: [:test]
Including tags: [line: "15"]



  1) test changeset/2 validates that a password must be present (Chatter.UserTest)

     ** (KeyError) key :password not found in: %{email: ["can't be blank"]}
     code: assert "can't be blank" in errors_on(changeset).password
     stacktrace:
       test/chatter/user_test.exs:20: (test)



Finished in 0.06 seconds
2 tests, 1 failure, 1 excluded

Good. That's the error we expected. Let's add password casting and validation:

# lib/chatter/user.ex

   def changeset(user, attrs) do
     user
-    |> cast(attrs, [:email])
-    |> validate_required([:email])
+    |> cast(attrs, [:email, :password])
+    |> validate_required([:email, :password])
   end

And rerun our test:

$ mix test test/chatter/user_test.exs:15
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "15"]

.

Finished in 0.04 seconds
2 tests, 0 failures, 1 excluded

Nice! Onto our next requirement: hashing the password. In our test, we won't assert the exact value of the hashed password. For our purposes, we just want to make sure it is present:

# test/chatter/user_test.exs

    test "creates a hashed_password" do
      params = %{email: "random@example.com", password: "password"}

      changeset = User.changeset(%User{}, params)

      assert changeset.changes.hashed_password
    end

Now let's run the test:

$ mix test test/chatter/user_test.exs:23
Excluding tags: [:test]
Including tags: [line: "23"]



  1) test changeset/2 creates a hashed_password (Chatter.UserTest)

     ** (KeyError) key :hashed_password not found in: %{email: "random@example.com", password: "password"}
     code: assert changeset.changes.hashed_password
     stacktrace:
       test/chatter/user_test.exs:28: (test)



Finished in 0.1 seconds
3 tests, 1 failure, 2 excluded

The failure shows that the changeset does not have a hashed_password in its set of changes. So let's add the hash_password/1 function from Doorman.Auth.Bcrypt:

# lib/chatter/user.ex

defmodule Chatter.User do
  use Ecto.Schema
  import Ecto.Changeset
  import Doorman.Auth.Bcrypt, only: [hash_password: 1]
  schema "users" do
    field :email, :string
    field :password, :string, virtual: true
    field :hashed_password, :string
    field :session_secret, :string

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :password])
    |> validate_required([:email, :password])
    |> hash_password()  end

And now our test should now pass. Rerun it:

$ mix test test/chatter/user_test.exs:23
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "23"]

.

Finished in 0.3 seconds
3 tests, 0 failures, 2 excluded

Good! Now let's handle our final requirement: validating uniqueness of emails.

# test/chatter/user_test.exs

    test "validates that email is unique" do
      insert(:user, email: "taken@example.com")
      params = %{email: "taken@example.com", password: "valid"}

      {:error, changeset} = %User{} |> User.changeset(params) |> Repo.insert()

      assert "has already been taken" in errors_on(changeset).email
    end

Unlike our other tests, this one actually tries to insert the changeset because Ecto uniqueness constraint errors are only populated when we attempt to insert the record into the database (since Ecto is relying on the database to raise the validation error).

Let's run the test:

$ mix test test/chatter/user_test.exs:31
Excluding tags: [:test]
Including tags: [line: "31"]



  1) test changeset/2 validates that email is unique (Chatter.UserTest)
     test/chatter/user_test.exs:31
     ** (Ecto.ConstraintError) constraint error when attempting to insert struct:

         * users_email_index (unique_constraint)

     If you would like to stop this constraint violation from raising an
     exception and instead add it as an error to your changeset, please
     call `unique_constraint/3` on your changeset with the constraint
     `:name` as an option.

     The changeset has not defined any constraint.

     code: {:error, changeset} = %User{} |> User.changeset(params) |> Repo.insert()
     stacktrace:
       (ecto 3.4.6) lib/ecto/repo/schema.ex:700: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
       (elixir 1.11.0) lib/enum.ex:1399: Enum."-map/2-lists^map/1-0-"/2
       (ecto 3.4.6) lib/ecto/repo/schema.ex:685: Ecto.Repo.Schema.constraints_to_errors/3
       (ecto 3.4.6) lib/ecto/repo/schema.ex:666: Ecto.Repo.Schema.apply/4
       (ecto 3.4.6) lib/ecto/repo/schema.ex:263: anonymous fn/15 in Ecto.Repo.Schema.do_insert/4
       test/chatter/user_test.exs:35: (test)



Finished in 0.7 seconds
4 tests, 1 failure, 3 excluded

Ecto gives us a nice error message with the solution included. Let's add a unique_constraint for email.

# lib/chatter/user.ex

   @doc false
   def changeset(user, attrs) do
     user
     |> cast(attrs, [:email, :password])
     |> validate_required([:email, :password])
     |> hash_password()
+    |> unique_constraint(:email)
   end

And rerun our test:

$ mix test test/chatter/user_test.exs:31
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "31"]

.

Finished in 0.7 seconds
4 tests, 0 failures, 3 excluded

Great. Now let's run all tests in user_test.exs to make they all work together:

$ mix test test/chatter/user_test.exs
....

Finished in 0.9 seconds
4 tests, 0 failures

Excellent! It's time to step out to our feature test.

Automatic sign in

Now that we've implemented the Chatter.User.changeset/2, we'd expect our GuestSignsUp feature test to make it one step further. Let's run it to see what fails next:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 2 files (.ex)


  1) test guest signs up with email and password (ChatterWeb.GuestSignsUpTest)

     ** (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(Query.data("role", "room", text: room.name))
     stacktrace:
       test/chatter_web/features/guest_signs_up_test.exs:14: (test)



Finished in 4.4 seconds
1 test, 1 failure

Our users can sign up, but they can't see the list of available rooms. Wallaby doesn't give us a great clue why our users can't see that list of rooms. It's possible that we're not rendering the rooms correctly or that our users are not landing on the room's index page. My guess is that users aren't automatically signed in when they create accounts, so they get redirected to the sign-in page. But that's a guess. It would be nice to confirm that.

There's one easy way to confirm that guess: Wallaby comes with a take_screenshot/1 helper to help with debugging. Add take_screenshot/1 to the test pipeline after |> click(Query.button("Sign up")), and when we rerun the test, Wallaby will take a screenshot of the page and save it in the /screenshots directory in our application.

# test/chatter_web/features/guest_signs_up_test.exs

  test "guest signs up with email and password", %{session: session} do
    room = insert(:chat_room)
    attrs = params_for(:user)

    session
    |> visit("/")
    |> click(Query.link("Create an account"))
    |> fill_in(Query.text_field("Email"), with: attrs[:email])
    |> fill_in(Query.text_field("Password"), with: attrs[:password])
    |> click(Query.button("Sign up"))
    |> take_screenshot()    |> assert_has(Query.data("role", "room", text: room.name))
  end

Rerun the test — the failure should not have changed — and open up the screenshot, $ open screenshot/1596188211276859000.png (your screenshot name will be different than mine):

screenshot showing sign in page

My guess was correct. Our users aren't automatically signed in, and thus, they are redirected to the sign in page after they create accounts. Before we fix that, remember to remove the take_screenshot/1 function from our test.

Some applications require a new user to sign in after creating an account. I find that behavior counter intuitive, so I'd like to modify the implementation in the UserController to automatically sign in the user when an account is created.

If you recall, Doorman provides a login/2 helper function — we use it in our SessionController — to set the user in the session. Let's use that in our UserController. Alias Doorman.Login.Session and login the user before redirecting to the root page:

# lib/chatter_web/controllers/user_controller.ex

defmodule ChatterWeb.UserController do
  use ChatterWeb, :controller

  alias Doorman.Auth.Secret
  alias Doorman.Login.Session  alias Chatter.User
  alias Chatter.Repo

  def new(conn, _params) do
    # code omitted
  end

  def create(conn, params) do
    {:ok, user} =      %User{}
      |> User.changeset(params)
      |> Secret.put_session_secret()
      |> Repo.insert()

    conn    |> Session.login(user)    |> redirect(to: "/")  end
end

Now let's rerun our test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs

.

Finished in 1.1 seconds
1 test, 0 failures

Excellent! Our users can now sign up. Let's commit our work, but we're not done yet. We still want to handle the scenario when an account creation fails.

Testing sign up failure

Just as we tested the failure to create a chat room in chat_room_controller_test, I'd like to test the failure to sign up in a controller test. We could do this in a feature test, but I find that testing all failure scenarios via feature tests can cause the test suite to get very slow. What's more, since we're only testing conditional logic in the controller — and if we think of the controller as any other Elixir module — it makes sense to add a unit test to confirm that behavior.

Let's jump to it. Create a test/chatter_web/user_controller_test.exs file, and add the following test:

# test/chatter_web/controllers/user_controller_test.exs

defmodule ChatterWeb.UserControllerTest do
  use ChatterWeb.ConnCase, async: true

  describe "create/2" do
    test "renders page with errors when data is invalid", %{conn: conn} do
      user = insert(:user, email: "taken@example.com")
      params = string_params_for(:user, email: user.email)

      response =
        conn
        |> post(Routes.user_path(conn, :create, %{"user" => params}))
        |> html_response(200)

      assert response =~ "has already been taken"
    end
  end
end

The test should look familiar. We use ChatterWeb.ConnCase to get access to the connection struct. Since we want the account creation to fail, we use the knowledge that emails need to be unique in our system to trigger the failure: we insert a user with an email, and then we generate parameters with the same email for a new user. We then make a post request to the user creation path and expect to get a 200 back (rendering the "new" page) instead of being redirected. Finally, we assert that we see the error "has already been taken" on the form.

Let's run the test to see what happens:

$ mix test test/chatter_web/controllers/user_controller_test.exs


  1) test create/2 renders page with errors when data is invalid (ChatterWeb.UserControllerTest)

     ** (MatchError) no match of right hand side value: {:error, #Ecto.Changeset<action: :insert, changes: %{email: "taken@example.com", hashed_password: "$2b$12$w0EHBiw52bOmQ4zUMeMhb.7b0cj3RXsQ/ITMD4QuuhjizSHIINnxW", password: "password1", session_secret: "Lx-_irfPzmAse4PYJToHpRsnM3MeN8swrV1-rtLzXXi3zwydFfJU0Wy4bpHDK1Oc"}, errors: [email: {"has already been taken", [constraint: :unique, constraint_name: "users_email_index"]}], data: #Chatter.User<>, valid?: false>}
     code: |> post(Routes.user_path(conn, :create, %{"user" => params}))
     stacktrace:
       (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:16: ChatterWeb.UserController.create/2
       (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.action/2
       (chatter 0.1.0) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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/user_controller_test.exs:11: (test)



Finished in 1.0 seconds
1 test, 1 failure

Good, that is the error we expected. If you recall, we assumed the user creation would succeed in UserController.create/2 by assigning the result of Repo.insert/1 to {:ok, user}. Because Ecto fails to insert the record, it returns an {:error, changeset} instead, and our pattern match fails. Let's update our controller to handle the case when Repo.insert/1 returns an {:error, changeset} tuple:

# lib/chatter_web/controllers/user_controller.ex

  def create(conn, params) do
    changeset =      %User{}
      |> User.changeset(params)
      |> Secret.put_session_secret()

    case Repo.insert(changeset) do      {:ok, user} ->        conn        |> Session.login(user)        |> redirect(to: "/")      {:error, changeset} ->        conn        |> render("new.html", changeset: changeset)    end  end

We separated the generation of the changeset from the Repo.insert/1 just as a style preference. Note that in the error case, we pass the changeset with errors to the "new.html" template. Let's see what error we get next:

$ mix test test/chatter_web/controllers/user_controller_test.exs
Compiling 2 files (.ex)


  1) test create/2 renders page with errors when data is invalid (ChatterWeb.UserControllerTest)

     Assertion with =~ failed
     code:  assert response =~ "has already been taken"
     left:  "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta
     charset=\"utf-8\"/>\n    <meta http-equiv=\"X-UA-Compatible\"
     content=\"IE=edge\"/>\n    <meta name=\"viewport\"
     content=\"width=device-width, initial-scale=1.0\"/>\n    <title>Chatter ·
     Phoenix Framework</title>\n    <link rel=\"stylesheet\"
     href=\"/css/app.css\"/>\n    <script defer type=\"text/javascript\"
     src=\"/js/app.js\"></script>\n  </head>\n  <body>\n    <header>\n
     <section class=\"container\">\n        <nav role=\"navigation\">\n
     <ul>\n            <li><a
     href=\"https://hexdocs.pm/phoenix/overview.html\">Get Started</a></li>\n\n
     <li><a href=\"/dashboard\">LiveDashboard</a></li>\n\n          </ul>\n
     </nav>\n        <a href=\"https://phoenixframework.org/\"
     class=\"phx-logo\">\n          <img src=\"/images/phoenix.png\"
     alt=\"Phoenix Framework Logo\"/>\n        </a>\n      </section>\n
     </header>\n    <main role=\"main\" class=\"container\">\n      <p
     class=\"alert alert-info\" role=\"alert\"></p>\n      <p class=\"alert
     alert-danger\" role=\"alert\"></p>\n<form action=\"/users\"
     method=\"post\"><input name=\"_csrf_token\" type=\"hidden\"
     value=\"CT9WBX4GECsVITACbxAuNVc9PTUtcGJNZmovG3jQQPD4BAyfnzPwKB7x\">\n
     <label>\n    Email: <input id=\"user_email\" name=\"user[email]\"
     type=\"text\" value=\"taken@example.com\">\n  </label>\n\n  <label>\n
     Password: <input id=\"user_password\" name=\"user[password]\"
     type=\"password\">\n  </label>\n<button type=\"submit\">Sign
     up</button>\n</form>\n    </main>\n  </body>\n</html>\n"
     right: "has already been taken"
     stacktrace:
       test/chatter_web/controllers/user_controller_test.exs:14: (test)



Finished in 1.0 seconds
1 test, 1 failure

Better, but we're still not showing the "has already been taken" error on the page.

Changeset errors are automatically displayed by Phoenix forms when we pass a changeset as the first argument to form_for and add error_tags to the fields. It seems we might not have error_tags. Let's add them.

# lib/chatter_web/templates/user/new.html.eex

<%= form_for @changeset, Routes.user_path(@conn, :create), fn f -> %>
  <label>
    Email: <%= text_input f, :email %>
    <%= error_tag f, :email %>  </label>

  <label>
    Password: <%= password_input f, :password %>
    <%= error_tag f, :password %>  </label>

  <%= submit "Sign up" %>
<% end %>

Now rerun the test:

$ mix test test/chatter_web/controllers/user_controller_test.exs

.

Finished in 0.9 seconds
1 test, 0 failures

Great! We have now tested the account creation error case.

Before we commit our work, let's run our test suite to ensure everything is working correctly:

$ mix test
......................

Finished in 4.9 seconds
22 tests, 0 failures

Excellent! We can now commit our work. Next, we'll see what to refactor.

Refactoring

The feature we added was fairly straightforward, so I do not think we have confusing code that needs refactoring. But if you noticed, all the user creation logic is in the UserController. I'd like to move that into the Chatter namespace. It's part of our core business logic and should mirror the rest of our application's design.

Refactoring user creation

Just like Chatter.Chat handles all interactions with chat rooms, let's define a Chatter.Accounts module to handle all user creation logic.

Our GuestSignsUp feature test will serve as a baseline test with which we can refactor. Let's run it to get started.

$ mix test test/chatter_web/features/guest_signs_up_test.exs
.

Finished in 1.4 seconds
1 test, 0 failures

Good. We'll keep that test passing as we refactor the logic.

The code we want to refactor is in UserController and looks like this:

# lib/chatter_web/controllers/user_controller.ex

  def new(conn, _params) do
    changeset = User.changeset(%User{}, %{})

    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => params}) do
    changeset =
      %User{}
      |> User.changeset(params)
      |> Secret.put_session_secret()

    case Repo.insert(changeset) do
      {:ok, user} ->
        conn
        |> Session.login(user)
        |> redirect(to: "/")

      {:error, changeset} ->
        conn
        |> render("new.html", changeset: changeset)
    end
  end

Let's start with the create/2 action. I'd like to extract the changeset creation, the putting of the session secret, and the user's persistence into the database. We will leave the conditional logic for the two return values:{:ok, user} and {:error, changeset} in the controller. Where we redirect or what we render after attempting to insert a new user is controller logic, and thus should stay there.

Let's start by doing an extract-function refactoring first. Create a function in that controller that handles the changeset creation, putting the session secret, and add the call to Repo.insert/1 in the pipeline:

# lib/chatter_web/controllers/user_controller.ex

   def create(conn, %{"user" => params}) do
-    changeset =
-      %User{}
-      |> User.changeset(params)
-      |> Secret.put_session_secret()
-
-    case Repo.insert(changeset) do
+    case create_user(params) do
       {:ok, user} ->
         conn
         |> Session.login(user)
         |> redirect(to: "/")

       {:error, changeset} ->
         conn
         |> render("new.html", changeset: changeset)
     end
   end
+
+  def create_user(params) do
+    %User{}
+    |> User.changeset(params)
+    |> Secret.put_session_secret()
+    |> Repo.insert()
+  end
 end

Now let's run our test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
.

Finished in 1.2 seconds
1 test, 0 failures

Good!

Creating Chatter.Accounts.create_user/1

Before extracting the function into a new module, let's add a test for our core business logic as though this were a new test-driven feature. Even though adding a test is not a necessary step in our refactoring, I'd like to do it because this represents the seam between our web presentation logic and our business logic. Let's create a test file in test/chatter/accounts_test.exs:

# test/chatter/accounts_test.exs

defmodule Chatter.AccountsTest do
  use Chatter.DataCase, async: true

  alias Chatter.Accounts

  describe "create_user/1" do
    test "creates a user with email and password" do
      params = %{"email" => "random@example.com", "password" => "superpass"}

      {:ok, user} = Accounts.create_user(params)

      assert user.id
      assert user.hashed_password
      assert user.session_secret
      assert user.email == "random@example.com"
    end
  end
end

This should look very familiar by now, but let's quickly walk through the test:

We use Chatter.DataCase, which gives us access to our factories, aliases Repo, and imports several Ecto functions to make it easy to work with the test.

We alias our Chatter.Accounts module (which does not yet exist) to make it easy to work with those functions.

Since the test explicitly states that we're creating a user with an email and password, I prefer setting up the email and password manually here and avoid using the factory via string_params_for(:user). The factory has extra user information we could accidentally send into Accounts.create_user/1, altering the behavior in some way we did not expect.

Finally, we call the Accounts.create_user/1 function which does not exist yet, and we make several assertions to make sure that:

  • the user is saved in the database (checking id),
  • the user's password has been hashed,
  • a session secret has been set on the user, and
  • the user's email is the same we passed in the params

Let's run the test:

$ mix test test/chatter/accounts_test.exs
Compiling 2 files (.ex)


  1) test create_user/1 creates a user with email and password (Chatter.AccountsTest)

     ** (UndefinedFunctionError) function Chatter.Accounts.create_user/1 is undefined (module Chatter.Accounts is not available)
     code: {:ok, user} = Accounts.create_user(params)
     stacktrace:
       Chatter.Accounts.create_user(%{"email" => "random@example.com", "password" => "superpass"})
       test/chatter/accounts_test.exs:10: (test)

warning: Chatter.Accounts.create_user/1 is undefined (module Chatter.Accounts is not available or is yet to be defined)
  test/chatter/accounts_test.exs:10: Chatter.AccountsTest."test create_user/1 creates a user with email and password"/1



Finished in 0.1 seconds
1 test, 1 failure

As expected, the error tells us that the create_user/1 function is undefined because the module is not available. Let's create the module and an empty function with that name and arity:

# lib/chatter/accounts.ex

defmodule Chatter.Accounts do
  def create_user(params) do
  end
end

Rerun our test:

$ mix test test/chatter/accounts_test.exs
Compiling 1 file (.ex)
warning: variable "params" is unused (if the variable is not meant to be used, prefix it with an underscore)
  lib/chatter/accounts.ex:2: Chatter.Accounts.create_user/1


  1) test create_user/1 creates a user with email and password (Chatter.AccountsTest)

     ** (MatchError) no match of right hand side value: nil
     code: {:ok, user} = Accounts.create_user(params)
     stacktrace:
       test/chatter/accounts_test.exs:10: (test)



Finished in 0.1 seconds
1 test, 1 failure

Good. Now the test fails because our function returns nil, but our code expects an {:ok, user}. Let's copy the code we have in the create_user/1 controller function we extracted before. And remember to alias the modules we use: Chatter.User, Chatter.Repo, and Doorman.Auth.Secret:

# lib/chatter/accounts.ex

defmodule Chatter.Accounts do
  alias Chatter.{Repo, User}  alias Doorman.Auth.Secret
  def create_user(params) do
    %User{}    |> User.changeset(params)    |> Secret.put_session_secret()    |> Repo.insert()  end
end

Now rerun the test:

$ mix test test/chatter/accounts_test.exs
Compiling 1 file (.ex)
.

Finished in 0.3 seconds
1 test, 0 failures

Good!

Because Repo.insert/1 returns an {:error, changeset} when something goes wrong, we can't test-drive the failure scenario in our account_test. But I would still like to add a test to ensure the create_user/1 function works as expected and as a contract so that people don't change the return value accidentally in the future. Add the following test in the describe "create_user/1" group of tests:

# test/chatter/account_test.exs

    test "returns changeset if fails to create user" do
      params = %{"email" => "random@example.com", "password" => nil}

      {:error, changeset} = Accounts.create_user(params)

      assert "can't be blank" in errors_on(changeset).password
    end

Let's run our new test:

$ mix test test/chatter/accounts_test.exs:18
Excluding tags: [:test]
Including tags: [line: "18"]

.

Finished in 0.05 seconds
2 tests, 0 failures, 1 excluded

Great. Both tests in account_test are passing. Let's now step back out to our feature test and continue our refactoring.

Using Chatter.Accounts.create_user/1

Previously, we extracted the user creation logic in UserController into the create_user/1 function. Since we've now created a replica of that function in Chatter.Accounts.create_user/1, we can call the function in Chatter.Accounts instead of the create_user/1 function inside the controller. But first things first, let's run our feature test to make sure it's still passing:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
.

Finished in 1.4 seconds
1 test, 0 failures

Now let's call Chatter.Accounts.create_user/1:

# lib/chatter_web/controllers/user_controller.ex

  alias Doorman.Auth.Secret
  alias Doorman.Login.Session
  alias Chatter.Accounts  alias Chatter.User
  alias Chatter.Repo

  def new(conn, _params) do
    # code omitted
  end

  def create(conn, %{"user" => params}) do
    case Accounts.create_user(params) do      {:ok, user} ->
        conn
        |> Session.login(user)
        |> redirect(to: "/")

      {:error, changeset} ->
        conn
        |> render("new.html", changeset: changeset)
    end
  end

Rerun our test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
.

Finished in 1.1 seconds
1 test, 0 failures

Excellent! We can now safely delete the unused create_user/1 function in the controller and remove the aliases for Repo and Auth.Secret:

# lib/chatter_web/controllers/user_controller.ex


-  alias Doorman.Auth.Secret
   alias Doorman.Login.Session
   alias Chatter.Accounts
   alias Chatter.User
-  alias Chatter.Repo

   def new(conn, _params) do
     changeset = User.changeset(%User{}, %{})

     render(conn, "new.html", changeset: changeset)
   end

   def create(conn, %{"user" => params}) do
     case Accounts.create_user(params) do
       {:ok, user} ->
         conn
         |> Session.login(user)
         |> redirect(to: "/")

       {:error, changeset} ->
         conn
         |> render("new.html", changeset: changeset)
     end
   end
-
-  def create_user(params) do
-    %User{}
-    |> User.changeset(params)
-    |> Secret.put_session_secret()
-    |> Repo.insert()
-  end
 end

Ah, nothing so good as deleting code. 🔥 Now let's rerun our test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 2 files (.ex)
.

Finished in 1.3 seconds
1 test, 0 failures

Well done!

Refactoring new user changeset

The final refactoring I'd like to do is extract the building of a new user changeset out of the UserController.new/2 action and into the Chatter.Accounts module. We'll follow a very similar process as we did with the user creation refactoring, so this should be familiar.

Creating Chatter.Accounts.change_user/1

Since we just ran the feature test, we know it's passing. We'll jump directly into the Chatter.Accounts test to test a change_user/1 function we'll define. I like the name change_user/1 because it succinctly conveys that we're trying to change a user. In our case, an empty user. Add the following test in the accounts test:

# test/chatter/accounts_test.exs

defmodule Chatter.AccountsTest do
  use Chatter.DataCase, async: true

  alias Chatter.{Accounts, User}
  describe "change_user/1" do    test "prepares a changeset for a new user" do      assert %Ecto.Changeset{} = Accounts.change_user(%User{})    end  end
  describe "create_user/1" do
    # other tests
  end
end

Now run it:

$ mix test test/chatter/accounts_test.exs:7
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "7"]



  1) test change_user/1 prepares a changeset for a new user (Chatter.AccountsTest)

     ** (UndefinedFunctionError) function Chatter.Accounts.change_user/1 is undefined or private
     code: assert %Ecto.Changeset{} = Accounts.change_user(%User{})
     stacktrace:
       (chatter 0.1.0) Chatter.Accounts.change_user(%Chatter.User{__meta__: #Ecto.Schema.Metadata<:built, "users">, email: nil, hashed_password: nil, id: nil, inserted_at: nil, password: nil, session_secret: nil, updated_at: nil})
       test/chatter/accounts_test.exs:8: (test)

warning: Chatter.Accounts.change_user/1 is undefined or private
  test/chatter/accounts_test.exs:8: Chatter.AccountsTest."test change_user/1 prepares a changeset for a new user"/1



Finished in 0.1 seconds
3 tests, 1 failure, 2 excluded

Good. The function is undefined, just as we expected. Let's define an empty function:

# lib/chatter/accounts.ex

defmodule Chatter.Accounts do
  alias Chatter.{Repo, User}
  alias Doorman.Auth.Secret

  def change_user(user) do  end
  def create_user(params) do
    # code omitted
  end
end

Rerun the test:

$ mix test test/chatter/accounts_test.exs:7
Compiling 2 files (.ex)
warning: variable "user" is unused (if the variable is not meant to be used, prefix it with an underscore)
  lib/chatter/accounts.ex:5: Chatter.Accounts.change_user/1

Excluding tags: [:test]
Including tags: [line: "7"]



  1) test change_user/1 prepares a changeset for a new user (Chatter.AccountsTest)

     match (=) failed
     code:  assert %Ecto.Changeset{} = Accounts.change_user(%User{})
     left:  %Ecto.Changeset{}
     right: nil
     stacktrace:
       test/chatter/accounts_test.exs:8: (test)



Finished in 0.09 seconds
3 tests, 1 failure, 2 excluded

Very good. We're expecting an Ecto changeset, but we're getting nil. Let's copy the code from the UserController.new/2 function:

# lib/chatter/accounts.ex

defmodule Chatter.Accounts do
  alias Chatter.{Repo, User}
  alias Doorman.Auth.Secret

  def change_user(user) do
    User.changeset(user, %{})  end

  def create_user(params) do
    # code omitted
  end
end

Now rerun the test:

$ mix test test/chatter/accounts_test.exs:7
Compiling 2 file (.ex)
Excluding tags: [:test]
Including tags: [line: "7"]

.

Finished in 0.05 seconds
3 tests, 0 failures, 2 excluded

Great! Now let's step back to the controller to continue our refactoring.

Using Chatter.Accounts.change_user/1

Let's call our new Accounts.change_user/1 function from the UserController:

# lib/chatter_web/controllers/user_controller.ex

 defmodule ChatterWeb.UserController do
   use ChatterWeb, :controller

   alias Doorman.Login.Session
   alias Chatter.Accounts
   alias Chatter.User

   def new(conn, _params) do
-    changeset = User.changeset(%User{}, %{})
+    changeset = Accounts.change_user(%User{})

     render(conn, "new.html", changeset: changeset)
   end

And now, run our feature test:

$ mix test test/chatter_web/features/guest_signs_up_test.exs
Compiling 3 files (.ex)
.

Finished in 1.2 seconds
1 test, 0 failures

Perfect! Let's commit this refactoring.

Wrap up

Well done! Hopefully, this chapter helped cement the red-green-refactor cycle in your mind. You might have even anticipated some of the errors we got.

Over the last two chapters, we focused on getting users into our application. In our next chapter, we'll finally use that user data in our application's core to add authors to our messages.