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:10
Excluding tags: [:test]
Including tags: [line: "10"]



  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) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: 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:10
Compiling 1 file (.ex)
warning: function 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:14

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

05:16:59.921 [error] #PID<0.560.0> running ChatterWeb.Endpoint (connection #PID<0.558.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) ChatterWeb.Router.Helpers.user_path(%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.560.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 (g2gCZAACdjF0AAAAAmQABW93bmVyZ2QADW5vbm9kZUBub2hvc3QAAAIqAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/sign_in", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "FeUet_OMpKAukywAAAKE"}, {"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) lib/chatter_web/templates/session/new.html.eex:14: ChatterWeb.SessionView."new.html"/1
        (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 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) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: 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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

05:23:46.345 [error] #PID<0.567.0> running ChatterWeb.Endpoint (connection #PID<0.558.0>, stream id 5) 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) 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 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) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
       test/chatter_web/features/guest_signs_up_test.exs:11: (test)



Finished in 4.1 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 sonn 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:10
Compiling 2 files (.ex)
Generated chatter app
Excluding tags: [:test]
Including tags: [line: "10"]

05:26:33.344 [error] #PID<0.573.0> running ChatterWeb.Endpoint (connection #PID<0.564.0>, stream id 5) 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) ChatterWeb.UserController.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: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYcVlQMVBpMStpMGloeDRSL1VxRno0QT09.Norkdx8tNYBPKAswTKhhE0jpQ5XUqaLw3D8oiOmB6Mk"}, halted: false, host: "localhost", method: "GET", owner: #PID<0.573.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_pipelines => [:browser], :phoenix_router => ChatterWeb.Router, :phoenix_view => ChatterWeb.UserView, :plug_session => %{"_csrf_token" => "qYP1Pi1+i0ihx4R/UqFz4A=="}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYcVlQMVBpMStpMGloeDRSL1VxRno0QT09.Norkdx8tNYBPKAswTKhhE0jpQ5XUqaLw3D8oiOmB6Mk"}, 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"}, {"cookie", "_chatter_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYcVlQMVBpMStpMGloeDRSL1VxRno0QT09.Norkdx8tNYBPKAswTKhhE0jpQ5XUqaLw3D8oiOmB6Mk"}, {"host", "localhost:4002"}, {"referer", "http://localhost:4002/sign_in"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyZ2QADW5vbm9kZUBub2hvc3QAAAIwAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/users/new", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "FeUfPXd9nnUPDiIAAACh"}, {"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/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 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) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
       test/chatter_web/features/guest_signs_up_test.exs:11: (test)



Finished in 3.9 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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

05:29:37.961 [error] #PID<0.567.0> running ChatterWeb.Endpoint (connection #PID<0.558.0>, stream id 5) 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.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: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYOTEwTS9tUWVaVU9pdUErRGRleWFiZz09.2BZPZyn_zUZSK0cRTe01oexBCoQImg0eQpc9DhtgEO8"}, halted: false, host: "localhost", method: "GET", owner: #PID<0.567.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_pipelines => [:browser], :phoenix_router => ChatterWeb.Router, :phoenix_template => "new.html", :phoenix_view => ChatterWeb.UserView, :plug_session => %{"_csrf_token" => "910M/mQeZUOiuA+Ddeyabg=="}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYOTEwTS9tUWVaVU9pdUErRGRleWFiZz09.2BZPZyn_zUZSK0cRTe01oexBCoQImg0eQpc9DhtgEO8"}, 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"}, {"cookie", "_chatter_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYOTEwTS9tUWVaVU9pdUErRGRleWFiZz09.2BZPZyn_zUZSK0cRTe01oexBCoQImg0eQpc9DhtgEO8"}, {"host", "localhost:4002"}, {"referer", "http://localhost:4002/sign_in"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyZ2QADW5vbm9kZUBub2hvc3QAAAIqAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/users/new", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "FeUfaHNwuTBTynwAAAMk"}, {"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"})
        (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/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 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) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
       test/chatter_web/features/guest_signs_up_test.exs:11: (test)



Finished in 3.9 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:10
Compiling 1 file (.ex)
Generated chatter app
Excluding tags: [:test]
Including tags: [line: "10"]

05:33:01.049 [error] #PID<0.568.0> running ChatterWeb.Endpoint (connection #PID<0.559.0>, stream id 5) 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:

%{__phx_template_not_found__: ChatterWeb.UserView, 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: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYMm1lSmZkRENYSzArMy9ZWnhPUkZkQT09.8WSGL_aI3CUJt50oC2KPSyAA_YpiaC1rQIbLEzXt_Zo"}, halted: false, host: "localhost", method: "GET", owner: #PID<0.568.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_pipelines => [:browser], :phoenix_router => ChatterWeb.Router, :phoenix_template => "new.html", :phoenix_view => ChatterWeb.UserView, :plug_session => %{"_csrf_token" => "2meJfdDCXK0+3/YZxORFdA=="}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYMm1lSmZkRENYSzArMy9ZWnhPUkZkQT09.8WSGL_aI3CUJt50oC2KPSyAA_YpiaC1rQIbLEzXt_Zo"}, 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"}, {"cookie", "_chatter_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYMm1lSmZkRENYSzArMy9ZWnhPUkZkQT09.8WSGL_aI3CUJt50oC2KPSyAA_YpiaC1rQIbLEzXt_Zo"}, {"host", "localhost:4002"}, {"referer", "http://localhost:4002/sign_in"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyZ2QADW5vbm9kZUBub2hvc3QAAAIrAAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/users/new", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "FeUfl7w0q3B-fa8AAAMi"}, {"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: [:__phx_template_not_found__, :conn, :view_module, :view_template]

        (phoenix) lib/phoenix/template.ex:340: Phoenix.Template.raise_template_not_found/3
        (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/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 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) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: 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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]



  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) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: 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 @conn, 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 %>

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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

04:21:18.055 [error] #PID<0.567.0> running ChatterWeb.Endpoint (connection #PID<0.558.0>, stream id 5) terminated
Server: localhost:4002 (http)
Request: GET /users/new
** (exit) an exception was raised:
    ** (ArgumentError) no function clause for ChatterWeb.Router.Helpers.user_path/2 and action :create. The following actions/clauses are supported:

    user_path(conn_or_endpoint, :new, params \\ [])
        (phoenix) lib/phoenix/router/helpers.ex:324: Phoenix.Router.Helpers.raise_route_error/6
        (chatter) lib/chatter_web/templates/user/new.html.eex:1: ChatterWeb.UserView."new.html"/1
        (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/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 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) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
       test/chatter_web/features/guest_signs_up_test.exs:11: (test)



Finished in 4.1 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:10
Compiling 3 files (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

04:25:11.728 [error] #PID<0.587.0> running ChatterWeb.Endpoint (connection #PID<0.572.0>, stream id 8) terminated
Server: localhost:4002 (http)
Request: POST /users
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function ChatterWeb.UserController.create/2 is undefined or private
        (chatter) ChatterWeb.UserController.create(%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: %{"_csrf_token" => "BmkcDwljAwZjJAQTdyNTSTRTMyAKAAAABFwNs+Lr0npD8W4xS9wFMQ==", "_utf8" => "✓", "email" => "super0@example.com", "password" => "password1"}, cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYRC9rQXpIT3RTSnRXT3RnMWdqRGZHUT09.YJlNAK0DU3f7HQYaKrzavhVzjLRtMfCo1qAZaad1zwQ"}, halted: false, host: "localhost", method: "POST", owner: #PID<0.587.0>, params: %{"_csrf_token" => "BmkcDwljAwZjJAQTdyNTSTRTMyAKAAAABFwNs+Lr0npD8W4xS9wFMQ==", "_utf8" => "✓", "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_pipelines => [:browser], :phoenix_router => ChatterWeb.Router, :phoenix_view => ChatterWeb.UserView, :plug_session => %{"_csrf_token" => "D/kAzHOtSJtWOtg1gjDfGQ=="}, :plug_session_fetch => :done}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{"_chatter_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYRC9rQXpIT3RTSnRXT3RnMWdqRGZHUT09.YJlNAK0DU3f7HQYaKrzavhVzjLRtMfCo1qAZaad1zwQ"}, 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"}, {"content-length", "136"}, {"content-type", "application/x-www-form-urlencoded"}, {"cookie", "_chatter_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYRC9rQXpIT3RTSnRXT3RnMWdqRGZHUT09.YJlNAK0DU3f7HQYaKrzavhVzjLRtMfCo1qAZaad1zwQ"}, {"host", "localhost:4002"}, {"origin", "http://localhost:4002"}, {"referer", "http://localhost:4002/users/new"}, {"user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1/BeamMetadata (g2gCZAACdjF0AAAAAmQABW93bmVyZ2QADW5vbm9kZUBub2hvc3QAAAI4AAAAAABkAARyZXBvZAATRWxpeGlyLkNoYXR0ZXIuUmVwbw==)"}], request_path: "/users", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "FeZWNosb53APbroAAALk"}, {"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" => "BmkcDwljAwZjJAQTdyNTSTRTMyAKAAAABFwNs+Lr0npD8W4xS9wFMQ==", "_utf8" => "✓", "email" => "super0@example.com", "password" => "password1"})
        (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 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.0 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, our User 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
    render(conn, "new.html")
  end

  def create(conn, params) do    {:ok, _user} =      %User{}      |> User.create_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:10
Compiling 1 file (.ex)
warning: function Chatter.User.create_changeset/2 is undefined or private
  lib/chatter_web/controllers/user_controller.ex:15

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

04:44:51.724 [error] #PID<0.574.0> running ChatterWeb.Endpoint (connection #PID<0.558.0>, stream id 8) terminated
Server: localhost:4002 (http)
Request: POST /users
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function Chatter.User.create_changeset/2 is undefined or private
        (chatter) Chatter.User.create_changeset(%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}, %{"_csrf_token" => "O0VmACUuRRZbP1kYGz9uX2ZmH0UBEAAAV7WDmE0x8R+IPK65+TGtkQ==", "_utf8" => "✓", "email" => "super0@example.com", "password" => "password1"})
        (chatter) lib/chatter_web/controllers/user_controller.ex:15: ChatterWeb.UserController.create/2
        (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 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 3.8 seconds
1 test, 1 failure

Since we have not yet defined a User.create_changeset/2, we get the following error:

** (UndefinedFunctionError) function Chatter.User.create_changeset/2 is
undefined or private

Just as in previous chapters, we want to "step in" because testing code in the Chatter namespace is outside the scope of our application's web layer. So let's write a test for the Chatter.User.create_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 create_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

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 "create_changeset/2" do    test "validates that an email must be present" do      params = %{}      changeset = User.create_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:10
Excluding tags: [:test]
Including tags: [line: "10"]



  1) test create_changeset/2 validates that an email must be present (Chatter.UserTest)
     test/chatter/user_test.exs:7
     ** (UndefinedFunctionError) function Chatter.User.create_changeset/2 is undefined or private
     code: changeset = User.create_changeset(%User{}, params)
     stacktrace:
       (chatter) Chatter.User.create_changeset(%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/user_test.exs:10: (test)



Finished in 0.05 seconds
1 test, 1 failure

Good. We get the same error we got on our feature test, so we're successfully stepping in. Now, let's get past that error by defining the function with a simple implementation. Since our tests involve the email field, let's cast that attribute and return a changeset, even if it's not the correct one yet:

# lib/chatter/user.ex

    timestamps()
  end

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

Let's rerun the test:

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



  1) test create_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.04 seconds
1 test, 1 failure

Better. The error is no longer about an undefined function. We now have the failure we want: there was no validation failure for the email field. Let's add that validation:

# lib/chatter/user.ex

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

Now rerun the test:

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

.

Finished in 0.03 seconds
1 test, 0 failures

Great. But we're not ready to step out to the feature test yet. Recall we had two more requirements: validating the presence of a password and hashing that password. So we'll write two more tests. Add the test to validate that a password is present:

# test/chatter/user_test.exs

+    test "validates that a password must be present" do
+      params = %{}
+
+      changeset = User.create_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:16
Excluding tags: [:test]
Including tags: [line: "16"]



  1) test create_changeset/2 validates that a password must be present (Chatter.UserTest)
     test/chatter/user_test.exs:15
     ** (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.05 seconds
2 tests, 1 failure, 1 excluded

Good. The error is what we might have expected. Let's add password casting and validation:

# lib/chatter/user.ex

   def create_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:16
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "16"]

.

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

Nice! Now let's add a test for our final 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.create_changeset(%User{}, params)      assert changeset.changes.hashed_password    end  end
end

Now let's run the test:

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



  1) test create_changeset/2 creates a hashed_password (Chatter.UserTest)
     test/chatter/user_test.exs:23
     ** (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.07 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 create_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:27
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "27"]

.

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

Good! 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.3 seconds
3 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.create_changeset/2, we'd expect our "guest signs up" 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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]



  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

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

So 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 a 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
    render(conn, "new.html")
  end

  def create(conn, params) do
    {:ok, user} =      %User{}
      |> User.create_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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

.

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, of course, 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 that tests that behavior. And that's what the controller test will be.

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, 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:9
Excluding tags: [:test]
Including tags: [line: "9"]



  1) test create/2 renders page with errors when data is invalid (ChatterWeb.UserControllerTest)
     test/chatter_web/controllers/user_controller_test.exs:5
     ** (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: |> post(Routes.user_path(conn, :create, params))
     stacktrace:
       (ecto) lib/ecto/repo/schema.ex:687: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
       (elixir) lib/enum.ex:1327: Enum."-map/2-lists^map/1-0-"/2
       (ecto) lib/ecto/repo/schema.ex:672: Ecto.Repo.Schema.constraints_to_errors/3
       (ecto) lib/ecto/repo/schema.ex:274: anonymous fn/15 in Ecto.Repo.Schema.do_insert/4
       (chatter) lib/chatter_web/controllers/user_controller.ex:18: ChatterWeb.UserController.create/2
       (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.action/2
       (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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/test/conn_test.ex:235: Phoenix.ConnTest.dispatch/5
       test/chatter_web/controllers/user_controller_test.exs:11: (test)



Finished in 0.9 seconds
1 test, 1 failure

Whoa! Glad we added this test! It caught something we missed when adding the User.create_changeset/2 function. We have a uniqueness constraint on emails in our users table, but we did not specify that in the changeset. Thankfully, Ecto raises a very helpful message:

** (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.

Since the uniqueness constraint is something we should test in our application's core business logic, let's "step in" to our user test and add a test that ensures we handle the uniqueness constraint (like we should have done from the beginning):

# 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.create_changeset(params)        |> Repo.insert()      assert "has already been taken" in errors_on(changeset).email    end  end
end

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

Let's run that test to make sure we fail with the same error that we were seeing in our controller test:

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



  1) test create_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: |> Repo.insert()
     stacktrace:
       (ecto) lib/ecto/repo/schema.ex:687: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
       (elixir) lib/enum.ex:1327: Enum."-map/2-lists^map/1-0-"/2
       (ecto) lib/ecto/repo/schema.ex:672: Ecto.Repo.Schema.constraints_to_errors/3
       (ecto) lib/ecto/repo/schema.ex:274: anonymous fn/15 in Ecto.Repo.Schema.do_insert/4
       test/chatter/user_test.exs:38: (test)



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

Good. Now let's add the unique_constraint/3 function Ecto recommends:

# lib/chatter/user.ex

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

And rerun the test:

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

.

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

Great. Now, we can step back out to our controller test. Let's run it again:

$ mix test test/chatter_web/controllers/user_controller_test.exs:9
Excluding tags: [:test]
Including tags: [line: "9"]



  1) test create/2 renders page with errors when data is invalid (ChatterWeb.UserControllerTest)
     test/chatter_web/controllers/user_controller_test.exs:5
     ** (MatchError) no match of right hand side value: {:error, #Ecto.Changeset<action: :insert, changes: %{email: "taken@example.com", hashed_password: "$2b$12$JqTab6ZHtcOBLk7Tex/8EucTbUElXUxd41iPkuZyFAMinAbna/HNK", password: "password1", session_secret: "n22oI_JnS1dXBQdAgWZYB59imLMpxdoUmAxFH6fKTbXgfCoW807C35pwenThWion"}, 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, params))
     stacktrace:
       (chatter) lib/chatter_web/controllers/user_controller.ex:14: ChatterWeb.UserController.create/2
       (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.action/2
       (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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/test/conn_test.ex:235: Phoenix.ConnTest.dispatch/5
       test/chatter_web/controllers/user_controller_test.exs:11: (test)



Finished in 0.9 seconds
1 test, 1 failure

Good! That is what we expected when we first wrote the controller test. 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.create_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")    end  end

We separated the generation of the changeset from the Repo.insert/1 just as a style preferences. Note that for now, we only render the "new.html" template without passing any options just to get us past the pattern matching error. Let's see what error we get next:

$ mix test test/chatter_web/controllers/user_controller_test.exs:9
Excluding tags: [:test]
Including tags: [line: "9"]



  1) test create/2 renders page with errors when data is invalid (ChatterWeb.UserControllerTest)
     test/chatter_web/controllers/user_controller_test.exs:5
     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  </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          </ul>\n        </nav>\n        <a
     href=\"http://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 accept-charset=\"UTF-8\" action=\"/users\"
     method=\"post\"><input name=\"_csrf_token\" type=\"hidden\"
     value=\"D2VIFiNFMFsZNlAOIVpBI1x4GjAbJgAAHT/LntwttSa9ShqV4ANJMQ==\"><input
     name=\"_utf8\" type=\"hidden\" value=\"\">  <label>\n    Email: <input
     id=\"email\" name=\"email\" type=\"text\" value=\"taken@example.com\">\n
     </label>\n\n  <label>\n    Password: <input id=\"password\"
     name=\"password\" type=\"password\">\n  </label>\n\n<button
     type=\"submit\">Sign up</button></form>\n    </main>\n    <script
     type=\"text/javascript\" src=\"/js/app.js\"></script>\n
     </body>\n</html>\n"
     right: "has already been taken"
     stacktrace:
       test/chatter_web/controllers/user_controller_test.exs:14: (test)



Finished in 0.9 seconds
1 test, 1 failure

Much better. We now render the "new" page on error but still do not have the error "has already been taken" on the page.

Changeset errors are automatically displayed by Phoenix forms when we add error_tags and pass a changeset as the first argument to form_for. But if you recall, we passed a @conn to form_for in our new.html.eex template. That means we'll have to do some extra work to render those errors, and in the end, we might refactor to use a changeset. But remember, we don't want to refactor now, since we're in the middle of introducing new functionality. We'll refactor later when our tests are passing.

Since our form is not acting on a changeset, let's pass the changeset errors explicitly as @errors:

# lib/chatter_web/controllers/user_controller.ex

  def create(conn, params) do
    changeset =
      %User{}
      |> User.create_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", errors: changeset.errors)    end
  end

Now let's provide those errors to form_for as an option and add the error_tags for our fields:

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

<%= form_for @conn, Routes.user_path(@conn, :create), [errors: @errors], 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" %>

Now rerun the test:

$ mix test test/chatter_web/controllers/user_controller_test.exs:9
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "9"]

.

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
................11:15:01.948 [error] #PID<0.705.0> running ChatterWeb.Endpoint (connection #PID<0.627.0>, stream id 4) terminated
Server: localhost:4002 (http)
Request: GET /users/new
** (exit) an exception was raised:
    ** (ArgumentError) assign @errors 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) lib/phoenix_html/engine.ex:132: Phoenix.HTML.Engine.fetch_assign!/2
        (chatter) lib/chatter_web/templates/user/new.html.eex:1: ChatterWeb.UserView."new.html"/1
        (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/user_controller.ex:1: ChatterWeb.UserController.action/2
        (chatter) lib/chatter_web/controllers/user_controller.ex:1: ChatterWeb.UserController.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 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) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
       test/chatter_web/features/guest_signs_up_test.exs:11: (test)



Finished in 6.9 seconds
21 tests, 1 failure

Oh no! Our "guest signs up" feature test fails because the "new.html.eex" template expects @errors to be available. Let's add a simple fix — pass empty errors to UserController.new/2:

# lib/chatter_web/controllers/user_controller.ex

defmodule ChatterWeb.UserController do
  alias Chatter.Repo

  def new(conn, _params) do
    render(conn, "new.html", errors: [])  end

Now rerun the test suite:

$ mix test
Compiling 1 file (.ex)
.....................

Finished in 4.9 seconds
21 tests, 0 failures

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

Refactoring

For the most part, the feature we added was straightforward. It includes some basic logic to create a new user — logic that is similar to creating any other resource.

Nevertheless, there are two improvements that I think we could make: the first is for the user creation form to use a changeset instead of a conn struct. I do not like assigning an empty errors' list in UserController.new/2. And since Phoenix forms work very well with changesets, it would be nicer if we could assign a changeset. The second improvement is to move the user creation logic into the Chatter namespace. It's part of our core business logic and should reflect the rest of our application's design.

Refactor to use a changeset

As with other refactors, we will use tests to ensure we're not breaking our application's behavior. If we break a test, we'll stop to analyze what went wrong.

For this refactor, we have two tests that check the complete behavior around user creation: the "guest signs up" feature test and the user controller test. So we'll run both of them as our baseline. Open up your terminal and run them:

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

Finished in 2.1 seconds
2 tests, 0 failures

Good. Unlike other refactors we've done, this one might be more complicated. We'll do it in several steps, and if possible, we want to keep the tests passing at each step, not just at the beginning and the end. Taking small steps reduces the risk of making breaking changes that are tough to undo later. It might also make our code worse before it gets better — we might add duplicate or temporary code. So hang in there with me.

Let's start by passing a changeset as an assign when rendering the new.html template. Remember to do it on the new/2 and create/2 actions, since they both render the template. Open up the UserController and add the changeset:

# lib/chatter_web/controllers/user_controller.ex

  alias Chatter.Repo

  def new(conn, _params) do
    changeset =      %User{}      |> User.create_changeset(%{})
    render(conn, "new.html", errors: [], changeset: changeset)  end

  def create(conn, params) do
    changeset =
      %User{}
      |> User.create_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", errors: changeset.errors, changeset: changeset)    end
  end
end

Since we're making changes in small steps and keeping our tests passing, we will not remove the errors assigns yet. The new.html template is still using the @errors variable, so removing it now would break our tests.

Now let's rerun the tests:

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

Finished in 2.0 seconds
2 tests, 0 failures

Good, both tests are still passing. Now, let's use @changeset in the form instead of @conn. Since the form will use the errors in the @changeset, we'll also remove the @errors option:

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

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

Now rerun our tests:

$ mix test test/chatter_web/features/guest_signs_up_test.exs test/chatter_web/controllers/user_controller_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.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.7 seconds
2 tests, 1 failure

Oh no! Our feature test failed. That change was not perfect. Why did it fail?

Wallaby's error message is not very informative. But it tells us, implicitly, that the failure happens at some point when the user creation form is submitted (since the test is trying to find the chat room a user would see after signing up). So, the user creation process is failing without raising an error. And perhaps surprisingly, the controller test is still passing.

Those clues lead me to think that the parameters passed to the form might be different somehow. And indeed, when we use form_for with a conn struct, the parameters passed to the controller are not nested under a top-level key. But when we use form_for with a changeset, the user's parameters are all nested under a "user" key. Our controller test is still passing because we send the parameters without a top-level key.

Having figured that out, let's get back to a green state. We'll put some temporary code in the UserController to handle the case when the parameters are nested under the "user" key. Let's add a create/2 function that pattern matches the case with the "user" key and delegate the logic to the existing create/2 action. Add the following:

# lib/chatter_web/controllers/user_controller.ex

  alias Chatter.Repo

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

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

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

Now the request coming from our feature test — the one with the "user" key — should be handled by the first create/2 action. The controller test which does not have a "user" key will be handled by the second one.

Let's run the tests:

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

Finished in 2.0 seconds
2 tests, 0 failures

Great. We're back to green!

We now have to ask: which of the two create/2 do we want to keep?

Since we want to use a changeset, we need to accept the create/2 function that takes in parameters nested under a top-level "user" key. That means that our controller test is out of date, so changing it is in order.

Update that test to pass the params under a "user" key:

# test/chatter_web/controllers/user_controller_test.exs

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

       assert response =~ "has already been taken"

Simple change. Now let's rerun both of our tests:

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

Finished in 2.0 seconds
2 tests, 0 failures

Great. We're still passing. Since both of our tests are now going through the first create/2 action in the UserController, we can safely update our code to always pattern match the params with the top-level "user" key. Combine the actions into one:

# lib/chatter_web/controllers/user_controller.ex

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

And now, let's rerun our tests:

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

Finished in 2.0 seconds
2 tests, 0 failures

Good! In refactoring, things sometimes get worse before getting better. Adding that temporary create/2 action was an example of that. But it let us take small steps.

We're now ready to finish our refactor. Since we're no longer using the @errors in our form, we can safely remove those from our controller actions:

# lib/chatter_web/controllers/user_controller.ex

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

-    render(conn, "new.html", errors: [], changeset: changeset)
+    render(conn, "new.html", changeset: changeset)
   end

   def create(conn, %{"user" => params}) do
     changeset =
       %User{}
       |> User.create_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", errors: changeset.errors, changeset: changeset)
+        |> render("new.html", changeset: changeset)
     end
   end

And rerun our tests one more time!

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

Finished in 2.0 seconds
2 tests, 0 failures

Voila! Refactoring accomplished. Let's commit our work and move on to the next one.

Refactoring user creation

Let's now turn our attention to the second refactoring: extracting the user creation logic out of UserController. Just like Chatter.Chat handles all interactions with chat rooms, let's define a Chatter.Accounts module to handle all user creation logic.

Our "guest signs up" 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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

.

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 create(conn, %{"user" => params}) do
    changeset =
      %User{}
      |> User.create_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

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 to 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.create_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.create_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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

.

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 Chattter.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:11
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "11"]



  1) test create_user/1 creates a user with email and password (Chatter.AccountsTest)
     test/chatter/accounts_test.exs:7
     ** (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)



Finished in 0.04 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:11
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

Generated chatter app
Excluding tags: [:test]
Including tags: [line: "11"]



  1) test create_user/1 creates a user with email and password (Chatter.AccountsTest)
     test/chatter/accounts_test.exs:7
     ** (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.05 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.create_changeset(params)    |> Secret.put_session_secret()    |> Repo.insert()  end
end

Now rerun the test:

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

.

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:20
Excluding tags: [:test]
Including tags: [line: "20"]

.

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:10
Excluding tags: [:test]
Including tags: [line: "10"]

.

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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

.

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
     # 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
-
-  def create_user(params) do
-    %User{}
-    |> User.create_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:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

.

Finished in 1.3 seconds
1 test, 0 failures

Well done!

Refactoring new user logic

The final refactoring I'd like to do is extract the building of a new user 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.new_user/0

Since we just ran the feature test, we know it's passing. We'll jump directly into the Chatter.Accounts test to test a new_user/0 function we'll define. 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

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

Now run it:

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



  1) test new_user/0 prepares a changeset for a new user (Chatter.AccountsTest)
     test/chatter/accounts_test.exs:7
     ** (UndefinedFunctionError) function Chatter.Accounts.new_user/0 is undefined or private
     code: assert %Ecto.Changeset{} = Accounts.new_user()
     stacktrace:
       (chatter) Chatter.Accounts.new_user()
       test/chatter/accounts_test.exs:8: (test)



Finished in 0.06 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 new_user do  end
  def create_user(params) do
    # code omitted
  end
end

Rerun the test:

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



  1) test new_user/0 prepares a changeset for a new user (Chatter.AccountsTest)
     test/chatter/accounts_test.exs:7
     match (=) failed
     code:  assert %Ecto.Changeset{} = Accounts.new_user()
     right: nil
     stacktrace:
       test/chatter/accounts_test.exs:8: (test)



Finished in 0.06 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 new_user do
    %User{} |> User.create_changeset(%{})  end

  def create_user(params) do
    # code omitted
  end
end

Now rerun the test:

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

.

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

Great! Let's now step back to the controller to continue our refactoring.

Using Chatter.Accounts.new_user/0

Let's call our new Accounts.new_user/0 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{}
-      |> User.create_changeset(%{})
+    changeset = Accounts.new_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:10
Compiling 1 file (.ex)
warning: unused alias User
  lib/chatter_web/controllers/user_controller.ex:6

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

.

Finished in 1.2 seconds
1 test, 0 failures

Excellent! And now, we see a warning because we're no longer using the User alias in the controller. Let's remove the alias:

# 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 = Accounts.new_user()

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

And run the test once more:

$ mix test test/chatter_web/features/guest_signs_up_test.exs:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

.

Finished in 1.1 seconds
1 test, 0 failures

Perfect! Now is a great time to commit.

Wrap up

Well done! That was a lot of work. We not only introduced the ability for our users to sign up but in doing so, we saw how to refactor larger pieces of our application. Hopefully, this chapter helped cement the red-green-refactor cycle in your mind: we write the tests, see them fail, get them to pass, and then improve our code while the tests continue to pass.

You might have also noticed that while we refactor, our code can sometimes get "worse" before it gets "better" — just like when we introduced temporary code during some of our refactorings. That's why it's important to keep our baseline test passing and refactoring in small steps: it assures us that our code works at every stage, even if our code looks a bit worse during the process.

Over the last two chapters, we focused on users being able to sign in and sign up. Up next, we'll finally make use of the user data in our application's core. We'll add authors to messages.