From Ruby to Elixir: Broken test for MessageController (182)

Hi! I believe I’ve found an erratum.

tl;dr: MessageController/create does not add changeset validation errors to the flash, which breaks one of its tests.

On page 182 we write this test:

test "invalid params is rejected", %{conn: conn} do
  conn = post(conn, ~p"/messages/new", %{}) assert html_response(conn, 200) =~
             Plug.HTML.html_escape("can't be blank")
end

Which resulted in this error when I ran it:

1) test POST /messages/new invalid params is rejected (PhoneAppWeb.MessageControllerTest)
     test/phone_app_web/controllers/message_controller_test.exs:34
     Assertion with =~ failed
     code:  assert html_response(conn, 200) =~ Plug.HTML.html_escape("can't be blank")
     left:  "<!DOCTYPE html>\n<html lang=\"en\" class=\"[scrollbar-gutter:stable]\">\n  <head>
     [rest of page truncated]
     right: "can&#39;t be blank"

After some valuable Elixir debugging practice, I believe I have the cause. The test in this case is looking for a “can’t be blank” error somewhere on the page. In MessageController/create, there is code that takes errors and puts them into the flash on line 48. However, that code is never reached because changeset validation happens on line 43 and the error shoots us down to line 55. That error handler doesn’t do anything specifically with the error. It passes the changeset to the view to render, but I don’t see where in the view it would display the error. Thus, the validation error doesn’t appear on the rendered view, and the test can’t find it.

Thanks, and just let me know if I’ve gotten something wrong.

Thanks for the thorough debugging here.

The error message should be displayed by the input component that lives in CoreComponents module:

  • The controller action passes the error changeset to the view
  • The view passes it to the form component
  • The input component receives the data from the form structure and renders the error
  • The code that renders this is in CoreComponents

Are you able to include the full test failure, without the rest of the page truncated? That will let me see if the message changed or if there’s some other potential issue here. Looking at the latest Phoenix codebase, I don’t expect this test would fail.

I recommend grabbing the online source code if you hit a wall. Although there’s a lot of value in the debugging process.

No problem, here is the full test output, with the MessageController freshly copied from the zip file:

╭─ ~/repos/rtoe/phone_app ───────────────────────────────────────────────────────────────────────────────────────── 3s  1.17.2-otp-27 Wed 04 14:44:20
╰─❯ mix test
Compiling 1 file (.ex)
Running ExUnit with seed: 704115, max_cases: 16

...........

  1) test POST /messages/new invalid params is rejected (PhoneAppWeb.MessageControllerTest)
     test/phone_app_web/controllers/message_controller_test.exs:34
     Assertion with =~ failed
     code:  assert html_response(conn, 200) =~ Plug.HTML.html_escape("can't be blank")
     left:  "<!DOCTYPE html>\n<html lang=\"en\" class=\"[scrollbar-gutter:stable]\">\n  <head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <meta name=\"csrf-token\" content=\"L3ErESY-Ei8daBUJJAARKiAYNj54MjQ-uEoETqJlQYYlkuuBImLg7FAo\">\n    <title data-suffix=\" · Phoenix Framework\">\nPhoneApp\n     · Phoenix Framework</title>\n    <link phx-track-static rel=\"stylesheet\" href=\"/assets/app.css\">\n    <script defer phx-track-static type=\"text/javascript\" src=\"/assets/app.js\">\n    </script>\n  </head>\n  <body class=\"bg-white\">\n<div class=\"fixed inset-y-0 flex w-96 flex-col p-2 lg:p-8 bg-gray-100 border-r\">\n  <div class=\"rounded-lg flex flex-grow flex-col border border-gray-200 bg-white pt-5 px-4 pb-4 overflow-hidden\">\n    <h1 class=\"text-2xl font-semibold flex items-center\">\n      <a href=\"/messages\" class=\"flex-grow\">Your Conversations</a>\n      <a href=\"/messages/new\" class=\"px-2\">➕</a>\n    </h1>\n\n    <div class=\"mt-4 overflow-y-auto overscroll-contain\">\n\n        <p class=\"italic\">Nothing here, yet</p>\n\n    </div>\n  </div>\n</div>\n\n<div class=\"pl-96\">\n  <div class=\"mx-auto flex max-w-4xl flex-col md:px-8 xl:px-0\">\n    <main class=\"flex-1\">\n      <div class=\"px-4 sm:px-6 md:px-0\">\n\n<div class=\"py-6\">\n  <h1 class=\"text-2xl font-semibold text-gray-900\">Start a Conversation</h1>\n\n  <div class=\"mt-4\">\n    <form action=\"/messages/new\" method=\"post\" class=\"relative\">\n  \n  \n    <input name=\"_csrf_token\" type=\"hidden\" hidden value=\"L3ErESY-Ei8daBUJJAARKiAYNj54MjQ-uEoETqJlQYYlkuuBImLg7FAo\">\n  \n  \n\n    <div>\n  <label for=\"message_to\" class=\"block text-sm font-semibold leading-6 text-zinc-800\">\n  \n</label>\n  <input type=\"text\" name=\"message[to]\" id=\"message_to\" class=\"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 border-zinc-300 focus:border-zinc-400\" placeholder=\"To (Phone Number)\">\n  \n</div>\n\n  <div>\n  <label for=\"message_body\" class=\"block text-sm font-semibold leading-6 text-zinc-800\">\n  \n</label>\n  <textarea id=\"message_body\" name=\"message[body]\" class=\"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem] border-zinc-300 focus:border-zinc-400\" rows=\"2\" placeholder=\"Send a message...\">\n</textarea>\n  \n</div>\n\n  <div class=\"mt-2\">\n    <button type=\"submit\" class=\"inline-flex items-center rounded-md border border-transparent\n                               bg-blue-600 px-4 py-2 text-sm font-medium text-white\n                                 shadow-sm ring-inset hover:bg-blue-700 focus:outline-none\n                                 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\">\n      Deliver\n    </button>\n  </div>\n\n</form>\n  </div>\n</div>\n      </div>\n    </main>\n  </div>\n</div>\n  </body>\n</html>"
     right: "can&#39;t be blank"
     stacktrace:
       test/phone_app_web/controllers/message_controller_test.exs:37: (test)

.......
Finished in 0.2 seconds (0.2s async, 0.00s sync)
19 tests, 1 failure

diff is telling me that there aren’t any differences from the code I originally copied from the zip archive (and then copied to my work directory) and the code in the zip you linked there, but I will check the views and CoreComponents in my working directory against the fresh zip download. Here’s those test results in the meantime, though!

That’s quite strange. Are you able to zip your full code folder (remove _build, deps, node_modules and it will zip up easily)?

I suspect the code is different somewhere, but it’s hard to tell without seeing the whole project. Immediately my head goes to router misconfiguration or CoreComponents differences.

I found a few differences between my code and the demo code. Most notably, there are changes in CoreComponents that deal with the flash and errors, so that could be the problem.

If I’m reading correctly, that file would have been generated by Phoenix and then never modified by us, no? I think it was generated by the same version of Phoenix in my mix.exs, 1.7.14. But lots of things are possible–at one point I set up a new Phoenix project and tried to copy my work over, and I don’t think I overwrote CoreComponents but I don’t have something like a git history to verify that with.

Here’s my full project if you want to look closer.

Thanks for sharing that. I took a look at CoreComponents.input and found the definition was slightly varied. I changed this line:

    # used to be:
    # errors =  if Phoenix.Component.used_input?(field), do: field.errors, else: []
    errors = field.errors

and the test passes. This was a change in the latest implementation of CoreComponents, hence the difference.

Now, to get this test passing without modifying the above line, I had to tweak the test to pass in blank inputs:

    test "invalid params is rejected", %{conn: conn} do
      params = %{message: %{to: "", body: ""}}
      conn = post(conn, ~p"/messages/new", params)

      assert html_response(conn, 200) =~
               Plug.HTML.html_escape("can't be blank")
    end

We can look through the implementation of used_input? to see why this works. By passing in the data, the form.params field contains the input field, so it counts as “used”

Makes sense! So it sounds like I got a bit ahead of the compatible Phoenix versions. Thanks!