Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

BookSearch: Authors

deprecated_book_search_authors.livemd

BookSearch: Authors

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.9", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"}
])

Navigation

Home Report An Issue Blog: PostsBlog: Search

Review Questions

Upon completing this lesson, a student should be able to answer the following questions.

  • How do you test the controller and context in a Phoenix application? Why would you test one or the other?
  • What are flash messages, and how does Phoenix leverage them?
  • How do you use Ecto to filter a database query?

Overview

Over the next several lessons, we’re going to build a BookSearch application that lets us search for books and authors.

The BookSearch app will demonstrate how to build well-tested applications and work with complex data relationships.

See the Demonstration Project for reference if you encounter any issues.

Create Phoenix Project

Initialize a new Phoenix project and install dependencies when prompted.

$ mix phx.new book_search

Then create the database.

$ cd book_search
$ mix ecto.create

Generate Authors

Use the generators to scaffold the context, controllers, and other files for authors.

$ mix phx.gen.html Authors Author authors name:string

Then run migrations.

$ mix ecto.migrate

Router

When you generate authors, you’ll see a message telling you to add resources/2 to your router.

Add the resource to your browser scope in lib/book_search_web/router.ex:

    resources "/authors", AuthorController

That’s because we need to connect our generated AuthorController actions to a URL in our router. We’ll do that in a moment.

Controller Tests

The generator created an author_controller_test.exs file which tests our controller by manually sending HTTP GET, POST, PUT, and DELETE actions.

Here is a documented version of the file to make it easier to understand.

defmodule BookSearchWeb.AuthorControllerTest do
  # Use the ConnCase module to provide a `conn` variable and functions/macros for testing a connection.
  use BookSearchWeb.ConnCase

  # Import the AuthorsFixtures module to provide some helper functions
  import BookSearch.AuthorsFixtures

  # Define some test attributes
  @create_attrs %{name: "some name"}
  @update_attrs %{name: "some updated name"}
  @invalid_attrs %{name: nil}

  # Define a test group for the `:index` action
  describe "index" do
    # Define a test for the `:index` action
    test "lists all authors", %{conn: conn} do
      # Make a GET request to the `:index` action
      conn = get(conn, Routes.author_path(conn, :index))
      # Assert that the response has a status of 200 and contains the string "Listing Authors"
      assert html_response(conn, 200) =~ "Listing Authors"
    end
  end

  # Define a test group for the `:new` action
  describe "new author" do
    # Define a test for the `:new` action
    test "renders form", %{conn: conn} do
      # Make a GET request to the `:new` action
      conn = get(conn, Routes.author_path(conn, :new))
      # Assert that the response has a status of 200 and contains the string "New Author"
      assert html_response(conn, 200) =~ "New Author"
    end
  end

  # Define a test group for the `:create` action
  describe "create author" do
    # Define a test for a successful `:create` action
    test "redirects to show when data is valid", %{conn: conn} do
      # Make a POST request to the `:create` action with valid attributes
      conn = post(conn, Routes.author_path(conn, :create), author: @create_attrs)

      # Assert that the response is a redirect to the `:show` action
      assert %{id: id} = redirected_params(conn)
      assert redirected_to(conn) == Routes.author_path(conn, :show, id)

      # Make a GET request to the `:show` action and assert that the response has a status of 200 and contains the string "Show Author"
      conn = get(conn, Routes.author_path(conn, :show, id))
      assert html_response(conn, 200) =~ "Show Author"
    end

    # Define a test for an unsuccessful `:create` action
    test "renders errors when data is invalid", %{conn: conn} do
      # Make a POST request to the `:create` action with invalid attributes
      conn = post(conn, Routes.author_path(conn, :create), author: @invalid_attrs)
      # Assert that the response has a status of 200 and contains the string "New Author"
      assert html_response(conn, 200) =~ "New Author"
    end
  end

  # Define a test group for the `:edit` action
  describe "edit author" do
    # Run the `create_author` function before each test in this group
    setup [:create_author]

    # Define a test for the `:edit` action
    test "renders form for editing chosen author", %{conn: conn, author: author} do
      # Make a GET request to the `:edit` action with the `author` variable as a parameter
      conn = get(conn, Routes.author_path(conn, :edit, author))
      # Assert that the response has a status of 200 and contains the string "Edit Author"
      assert html_response(conn, 200) =~ "Edit Author"
    end
  end

  # Define a test group for the `:update` action
  describe "update author" do
    # Run the `create_author` function before each test in this group
    setup [:create_author]

    # Define a test for a successful `:update` action
    test "redirects when data is valid", %{conn: conn, author: author} do
      # Make a PUT request to the `:update` action with the `author` variable as a parameter and valid attributes
      conn = put(conn, Routes.author_path(conn, :update, author), author: @update_attrs)

      # Assert that the response is a redirect to the `:show` action with the `author` variable as a parameter
      assert redirected_to(conn) == Routes.author_path(conn, :show, author)

      # Make a GET request to the `:show` action with the `author` variable as a parameter and assert that the response has a status of 200 and contains the string "some updated name"
      conn = get(conn, Routes.author_path(conn, :show, author))
      assert html_response(conn, 200) =~ "some updated name"
    end

    # Define a test for an unsuccessful `:update` action
    test "renders errors when data is invalid", %{conn: conn, author: author} do
      # Make a PUT request to the `:update` action with the `author` variable as a parameter and invalid attributes
      conn = put(conn, Routes.author_path(conn, :update, author), author: @invalid_attrs)
      # Assert that the response has a status of 200 and contains the string "Edit Author"
      assert html_response(conn, 200) =~ "Edit Author"
    end
  end

  # Define a test group for the `:delete` action
  describe "delete author" do
    # Run the `create_author` function before each test in this group
    setup [:create_author]

    # Define a test for the `:delete` action
    test "deletes chosen author", %{conn: conn, author: author} do
      # Make a DELETE request to the `:delete` action with the `author` variable as a parameter
      conn = delete(conn, Routes.author_path(conn, :delete, author))
      # Assert that the response is a redirect to the `:index` action
      assert redirected_to(conn) == Routes.author_path(conn, :index)

      # Assert that a 404 error is returned when making a GET request to the `:show` action with the `author` variable as a parameter
      assert_error_sent 404, fn ->
        get(conn, Routes.author_path(conn, :show, author))
      end
    end
  end

  # Define a private function to create an author
  defp create_author(_) do
    # Create an author using a fixture
    author = author_fixture()
    # Return a map with the `author` variable
    %{author: author}
  end
end

Controller Test Breakdown

The author_controller_text.exs file uses the following macros from Phoenix.ConnTest to simulate HTTP GET, POST, PUT, and DELETE actions.

  • get/3 sends a GET request to a given URL with optional parameters and returns the response.
  • post/3 sends a POST request to a given URL with optional parameters and returns the response.
  • put/3 sends a PUT request to a given URL with optional parameters and returns the response.
  • delete/3 sends a DELETE request to a given URL with optional parameters and returns the response.

It also uses several helper functions, also from Phoenix.ConnTest:

  • html_response/2 retrieves the HTML body of a response usually to check if it contains a given string or pattern.
  • redirected_to/2 returns the URL to which a response has been redirected.
  • get_flash/2 retrieves the value of a flash message with a given key from a response.

Several tests also use the setup/1 function from ExUnit.Callbacks in combination with the private create_author/1 helper function to create an author for the test.

The create_author/1 function uses the author_fixture/1 function from the BookSearch.AuthorsFixtures module in support/fixtures/authors_fixtures.ex.

ConnCase

Phoenix applications generate a ConnCase test module that simulates building the connection between a client and server.

# Test/support/conn_case.ex

defmodule BookSearchWeb.ConnCase do
  @moduledoc """
  This module defines the test case to be used by
  tests that require setting up a connection.

  Such tests rely on `Phoenix.ConnTest` and also
  import other functionality to make it easier
  to build common data structures and query the data layer.

  Finally, if the test case interacts with the database,
  we enable the SQL sandbox, so changes done to the database
  are reverted at the end of every test. If you are using
  PostgreSQL, you can even run database tests asynchronously
  by setting `use BookSearchWeb.ConnCase, async: true`, although
  this option is not recommended for other databases.
  """

  use ExUnit.CaseTemplate

  using do
    quote do
      # Import conveniences for testing with connections
      import Plug.Conn
      import Phoenix.ConnTest
      import BookSearchWeb.ConnCase

      alias BookSearchWeb.Router.Helpers, as: Routes

      # The default endpoint for testing
      @endpoint BookSearchWeb.Endpoint
    end
  end

  setup tags do
    BookSearch.DataCase.setup_sandbox(tags)
    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

BookSearch.ConnCase imports Phoenix.ConnTest which defines many functions for simulating HTTP requests.

These functions often accept the conn structure built in the setup section of BookSearch.ConnCase using Phoenix.ConnTest.build_conn/0.

ConnCase tests allow us to interact with our Phoenix web application programmatically and make assertions on its behavior or HTML response.

When we use BookSearchWeb.ConnCase we automatically include the code inside the using block to import or alias modules, and run the setup block.

Follow Along: Failing Tests

Run your tests.

mix test

Currently, all of our author_controller_test.exs tests fail with the following error message.

** (UndefinedFunctionError) function BookSearchWeb.Router.Helpers.author_path/2 is undefined or private

That’s because we haven’t defined the routes that will dispatch to our AuthorController actions.

We could solve this tests by adding the resources/2 call in our router that defines the matrix of HTTP actions.

Add the authors to our routes in router.ex.

scope "/", BookSearchWeb do
  pipe_through :browser

  get "/", PageController, :index
  resources "/authors", AuthorController
end

All tests should pass.

$ mix test

However, instead of using resources/2, we’ll manually add each route.

Remove resources/2 from your router.ex and add each route individually. We’ve added comments to the code to make it more clear, however usually these comments would be unnecessary for this self-explanatory code.

  # Set up a scope for the BookSearchWeb web application
  scope "/", BookSearchWeb do
    # Use the :browser pipeline for all routes within this scope
    pipe_through :browser

    # Define a route for the root path that maps to the index action of the PageController
    get "/", PageController, :index

    # Define routes for the AuthorController actions
    get "/authors", AuthorController, :index            # Index action
    get "/authors/new", AuthorController, :new          # New action
    get "/authors/:id", AuthorController, :show         # Show action
    get "/authors/edit/:id", AuthorController, :edit    # Edit action
    post "/authors", AuthorController, :create          # Create action
    put "/authors/:id", AuthorController, :update       # Update action
    patch "/authors/:id", AuthorController, :update     # Update action
    delete "/authors/:id", AuthorController, :delete    # Delete action
  end

Now that all of our tests are passing, we’ll dive deeply into how each test works.

HTTP GET “/authors”

For example, let’s look at the "index" test in test/book_search/controllers/author_controller_test.exs.

# Test/book_search_web/controllers/author_controller.ex

describe "index" do
  test "lists all authors", %{conn: conn} do
    conn = get(conn, Routes.author_path(conn, :index))
    assert html_response(conn, 200) =~ "Listing Authors"
  end
end

This test simulates the same interaction as when we manually visit http://localhost:4000/authors.

The "index" test uses %{conn: conn} defined in BookSearch.ConnCase. conn is a Plug.Conn struct we can use to work with requests and responses in an HTTP connection.

The get/3 function accepts the conn and simulates an HTTP GET request. Routes.author_path(conn, :index) returns the "/authors" route.

html_response/2 retrieves the HTML response. 200 is the success code for an HTTP response. After the HTTP GET request to the "/authors" route. We then use =~ to assert that "Listing Authors" was found on the HTML web page response.

Modifying Assertions And Behavior

Under the hood, get(conn, Routes.author_path(conn, :index)) triggers the AuthorsController.index/2 action.

# Lib/book_search_web/controllers/author_controller.ex

def index(conn, _params) do
  authors = Authors.list_authors()
  render(conn, "index.html", authors: authors)
end

The AuthorController renders the template file.

# Lib/book_search_web/templates/authors/index.html.heex

<h1>Listing Authors</h1>

<table>
  <thead>
    <tr>
      <th>Name</th>

      <th></th>
    </tr>
  </thead>
  <tbody>
<%= for author <- @authors do %>
    <tr>
      <td><%= author.name %></td>

      <td>
        <span><%= link "Show", to: Routes.author_path(@conn, :show, author) %></span>
        <span><%= link "Edit", to: Routes.author_path(@conn, :edit, author) %></span>
        <span><%= link "Delete", to: Routes.author_path(@conn, :delete, author), method: :delete, data: [confirm: "Are you sure?"] %></span>
      </td>
    </tr>
<% end %>
  </tbody>
</table>

<span><%= link "New Author", to: Routes.author_path(@conn, :new) %></span>

"Listing Authors" is in this template file. We’re going to implement a feature to search for authors, so let’s change this text to "Search Authors".

# Lib/book_search_web/templates/authors/index.html.heex

<h1>Search Authors</h1>

Changing the text causes our test to fail. The error output is a bit difficult to read. That’s because the left value in our assertion is the full HTML response.

$ mix test
1) test index lists all authors (BookSearchWeb.AuthorControllerTest)
     test/book_search_web/controllers/author_controller_test.exs:11
     Assertion with =~ failed
     code:  assert html_response(conn, 200) =~ "Listing Authors"
     left:  "\n\n  \n    \n    \n    \n    \nBookSearch · Phoenix Framework\n    \n    \n  \n  \n    \n      \n        \n          \n        \n        \n          \\n        \n      \n    \n\n  

\n

\n

Search Authors

\n\n\n \n \n \n\n \n \n \n \n\n \n
Name
\n\nNew Author\n\n \n" right: "Listing Authors" stacktrace: test/book_search_web/controllers/author_controller_test.exs:13: (test) ..... Finished in 0.2 seconds (0.08s async, 0.1s sync) 19 tests, 1 failure

Here, we’ve converted this output into HTML to make it easier to understand. See if you can find

Search Authors

in the test failure output.





    
    
    
    
    BookSearch · Phoenix Framework
    
    



    
        
            
                
              Phoenix Framework Logo 
        
    
    
        

Search Authors

Name
New Author

To fix this test, we need to change our assertion to find "Search Authors" on the HTML web page response.

# Test/book_search_web/controllers/author_controller_test.exs

describe "index" do
  test "lists all authors", %{conn: conn} do
    conn = get(conn, Routes.author_path(conn, :index))
    assert html_response(conn, 200) =~ "Search Authors"
  end
end

Comprehensive Testing

Assertions about the static text contents of the web page are not always desirable. As we’ve just seen, changing the wording causes our tests to fail. These tests can be brittle and hard to maintain. These tests also aren’t very comprehensive.

For example, we can alter the AuthorController.index/2 function to return an empty list of authors, and our test will still pass.

Replace AuthorController.index/2 with the following and run mix test.

# Lib/book_search_web/controllers/author_controller.ex

def index(conn, _params) do
  # authors = Authors.list_authors()
  render(conn, "index.html", authors: [])
end

Alternatively, we can verify the behavior of the web page instead of its static contents. For example, when we send an HTTP GET request to "/authors" we expect to see a list of all authors.

Replace the "index" test with the following.

# Test/book_search_web/controllers/author_controller_test.ex

describe "index" do
  setup [:create_author]

  test "lists all authors", %{conn: conn, author: author} do
    conn = get(conn, Routes.author_path(conn, :index))
    assert html_response(conn, 200) =~ author.name
  end
end

Test Fixtures

The setup [:create_author] function calls the create_author/1 function to bind author: author onto the test context. See ExUnit Module Contexts for more on setup contexts.

# Test/book_search_web/controllers/author_controller_test.ex

defp create_author(_) do
  author = author_fixture()
  %{author: author}
end

The create_author/1 function calls the author_fixture/1 function imported from BookSearch.AuthorsFixtures at the top of the test file.

# Test/book_search_web/controllers/author_controller_test.ex

defmodule BookSearchWeb.AuthorControllerTest do
  use BookSearchWeb.ConnCase

  import BookSearch.AuthorsFixtures
  ...
end

Test fixtures provide convenient functions for setting up test data. They are one of many patterns for writing tests and are completely optional.

The author_fixture/1 function creates an author with some default arguments using the BookSearch.Authors context.

# Test/support/fixtures/authors_fixture.ex

def author_fixture(attrs \\ %{}) do
  {:ok, author} =
    attrs
    |> Enum.into(%{
      name: "some name"
    })
    |> BookSearch.Authors.create_author()

  author
end

Enum.into/2 merges any attrs we pass into the function with the default values.

attrs = %{name: "Name Override"}

Enum.into(attrs, %{name: "some name"})

By default, this will create an author with "some name" if we don’t pass in any :name override.

attrs = %{}

Enum.into(attrs, %{name: "some name"})

This fixture function is merely an abstraction around calling BookSearch.Authors.create_author/1 directly. So, for example, we can replace the setup [:create_author] function in our test with a call to the BookSearch.Authors context.

# Test/book_search_web/controllers/author_controller_test.ex

describe "index" do
  test "lists all authors", %{conn: conn} do
    author = BookSearch.Authors.create_author(%{name: "some name"})
    conn = get(conn, Routes.author_path(conn, :index))
    assert html_response(conn, 200) =~ author.name
  end
end

So why use a fixture? Well, the fixture provides some default arguments. It can also be helpful if the interface to BookSearch.Authors.create_author/1 changes. In that case, we’d only need to update our fixture rather than all of our tests.

Revert the "index" test to use the test fixture.

# Test/book_search_web/controllers/author_controller_test.ex

describe "index" do
  setup [:create_author]

  test "lists all authors", %{conn: conn, author: author} do
    conn = get(conn, Routes.author_path(conn, :index))
    assert html_response(conn, 200) =~ author.name
  end
end

Revert AuthorController to what it was before we broke it, and the test should pass.

# Lib/book_search_web/controllers/author_controller.ex

def index(conn, _params) do
  authors = Authors.list_authors()
  render(conn, "index.html", authors: authors)
end

HTTP POST “/authors”

The "create author" tests handle both the successful creation of an author and failed creation of an author.

# Test/book_search_web/controllers/author_controller_test.exs

describe "create author" do
  test "redirects to show when data is valid", %{conn: conn} do
    conn = post(conn, Routes.author_path(conn, :create), author: @create_attrs)

    assert %{id: id} = redirected_params(conn)
    assert redirected_to(conn) == Routes.author_path(conn, :show, id)

    conn = get(conn, Routes.author_path(conn, :show, id))
    assert html_response(conn, 200) =~ "Show Author"
  end

  test "renders errors when data is invalid", %{conn: conn} do
    conn = post(conn, Routes.author_path(conn, :create), author: @invalid_attrs)
    assert html_response(conn, 200) =~ "New Author"
  end
end

Successful Author Creation

The "redirects to show when data is valid" test checks that when the client sends a POST request to http://localhost:4000/authors with proper values, the server redirects them to http://localhost:4000/authors/1 where 1 is the id of the author created.

# Test/book_search_web/controllers/author_controller_test.exs

test "redirects to show when data is valid", %{conn: conn} do
  conn = post(conn, Routes.author_path(conn, :create), author: @create_attrs)

  assert %{id: id} = redirected_params(conn)
  assert redirected_to(conn) == Routes.author_path(conn, :show, id)

  conn = get(conn, Routes.author_path(conn, :show, id))
  assert html_response(conn, 200) =~ "Show Author"
end

The post/3 function imported from ConnTest simulates a POST request to "/author" which is the return value of Routes.author_path(conn, :create).

redirected_params/1 retrieves params from the current URL http://localhost:4000/authors/1 where 1 is the :id in the url.

We can run mix phx.routes to see the corresponding url with the :id field.

$ mix phx.routes
...
author_path  POST    /authors                                BookSearchWeb.AuthorController :create
...

redirected_to/2 returns the URL that we were redirected to. We assert that we are redirected to Routes.author_path(conn, :show, id) which is the show path.

We then make an additional GET request to http://localhost:4000/authors/1 where 1 is the id of the author using the get/3 function and verify that the page contains the text "Show Author".

Your Turn

Manually create a new author on http://localhost:4000/authors/new to verify the behavior of our successful test above.

Unsuccessful Author Creation

The "renders errors when data is invalid" test ensures the response after a failed creation attempt includes the text "New Author".

test "renders errors when data is invalid", %{conn: conn} do
  conn = post(conn, Routes.author_path(conn, :create), author: @invalid_attrs)
  assert html_response(conn, 200) =~ "New Author"
end

"New Author" is text on the new author template.

# Lib/book_search_web/templates/authors/new.html.heex

<h1>New Author</h1>

<%= render "form.html", Map.put(assigns, :action, Routes.author_path(@conn, :create)) %>

<span><%= link "Back", to: Routes.author_path(@conn, :index) %></span>

Flash Messages

Creating an author displays a flash message. Flash messages are temporary messages attached to the conn.

For example, go to http://localhost:4000/authors/new and create an author. We’ll see the following blue message.

put_flash/3 persists a flash message.

When we create an author, we call the put_flash/3 function in AuthorController.create/2.

def create(conn, %{"author" => author_params}) do
  case Authors.create_author(author_params) do
    {:ok, author} ->
      conn
      |> put_flash(:info, "Author created successfully.") # <-- PUT FLASH
      |> redirect(to: Routes.author_path(conn, :show, author))

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

Messages generally use either the :info key or :error key.

To display the message, we call the get_flash/2 function in the app layout.

# Lib/book_search_web/layout/app.html.heex


  <p><%= get_flash(@conn, :info) %></p>
  <p><%= get_flash(@conn, :error) %></p>
  <%= @inner_content %>

Our application displays a flash message when we create an author. However, this behavior is untested.

We can use the test-specific get_flash/2 from ConnTest to check if there’s a flash message.

Replace the "redirect to show when data is valid" test with the following.

test "redirects to show when data is valid", %{conn: conn} do
  conn = post(conn, Routes.author_path(conn, :create), author: @create_attrs)
  assert get_flash(conn, :info) == "Author created successfully." # <-- Check The Flash Message

  assert %{id: id} = redirected_params(conn)
  assert redirected_to(conn) == Routes.author_path(conn, :show, id)

  conn = get(conn, Routes.author_path(conn, :show, id))
  assert html_response(conn, 200) =~ "Show Author"
end

Your Turn

Change the redirects to show when data is valid" to change the flash message "Author created successfully" to some other message such as "Author created!".

Ensure you fix the AuthorController so that all tests pass.

HTTP PUT “/authors/:id”

The "update author" test verifies we can successfully update an author. It’s essentially the same as the "create author" test except it uses put/3 instead of get/3.

describe "update author" do
  setup [:create_author]

  test "redirects when data is valid", %{conn: conn, author: author} do
    conn = put(conn, Routes.author_path(conn, :update, author), author: @update_attrs)
    assert redirected_to(conn) == Routes.author_path(conn, :show, author)

    conn = get(conn, Routes.author_path(conn, :show, author))
    assert html_response(conn, 200) =~ "some updated name"
  end

  test "renders errors when data is invalid", %{conn: conn, author: author} do
    conn = put(conn, Routes.author_path(conn, :update, author), author: @invalid_attrs)
    assert html_response(conn, 200) =~ "Edit Author"
  end
end

HTTP DELETE “/authors/:id”

The "delete author" tests checks that when the client deletes an author, they are redirected back to http://localhost:4000/authors.

It also verifies that the author show page of the deleted author returns a 404 not found error.

  describe "delete author" do
    setup [:create_author]

    test "deletes chosen author", %{conn: conn, author: author} do
      conn = delete(conn, Routes.author_path(conn, :delete, author))
      assert redirected_to(conn) == Routes.author_path(conn, :index)

      assert_error_sent 404, fn ->
        get(conn, Routes.author_path(conn, :show, author))
      end
    end
  end

assert_error_sent/2 allows us to test HTTP actions that raise an error.

###

Follow Along: Author Search

To better understand how to write effective Phoenix Controller tests using ConnCase and ConnTest we’re going to implement a new feature to search for authors.

We want to visit http://localhost:4000/authors?name=search_query, where search_query is a string entered by the client in a text input.

First, let’s create a new form on the author list page. We want clients to be able to send a GET request through the form to "/authors".

The form has no changeset, so we use @conn as the for value. The method is "get" for an HTTP GET request. The action is the route to send the GET request to. Routes.author_path(@conn, :index) returns the "/authors" route.

<h1>Search Authors</h1>

<table>
  <thead>
    <tr>
      <th>Name</th>

      <th></th>
    </tr>
  </thead>
  <tbody>
  <.form let={f} for={@conn} method={"get"} action={Routes.author_path(@conn, :index)}>
    <%= search_input f, :name %>
    <%= error_tag f, :name %>

    
      <%= submit "Search" %>
    
  
<%= for author <- @authors do %>
    <tr>
      <td><%= author.name %></td>

      <td>
        <span><%= link "Show", to: Routes.author_path(@conn, :show, author) %></span>
        <span><%= link "Edit", to: Routes.author_path(@conn, :edit, author) %></span>
        <span><%= link "Delete", to: Routes.author_path(@conn, :delete, author), method: :delete, data: [confirm: "Are you sure?"] %></span>
      </td>
    </tr>
<% end %>
  </tbody>
</table>

<span><%= link "New Author", to: Routes.author_path(@conn, :new) %></span>

The code above created the following form on http://localhost:4000/authors.

When we submit this form, we’re taken to http://localhost:4000/authors?name=search_query where search_query is the value of the :name text input.

We’re ready to write our test!

Author Search

We’ll need a new "index" test specifically for query params as we still want to list all authors when the client visits http://localhost:4000/authors.

# Test/book_search_web/controllers/author_controller_test.exs

describe "index" do
  setup [:create_author]

  test "lists all authors", %{conn: conn, author: author} do
    conn = get(conn, Routes.author_path(conn, :index))
    assert html_response(conn, 200) =~ author.name
  end

  test "lists all authors _ matching search query", %{conn: conn} do
  end
end

setup [:create_author] introduces coupling between tests. Setup sections are fine when every test has the same setup. However, it becomes an issue if they don’t. That’s why it’s often not recommended to use setup sections in tests too early; otherwise, refactoring and adding new tests becomes difficult.

Let’s refactor the "lists all authors" test to use the author_fixture/1 function directly and remove the setup section.

# Test/book_search_web/controllers/author_controller_test.exs

describe "index" do
  test "lists all authors", %{conn: conn} do
    author = author_fixture()
    conn = get(conn, Routes.author_path(conn, :index))
    assert html_response(conn, 200) =~ author.name
  end

  ...
end

The "lists all authors _ matching search query" test will check for a search matching the author’s name.

We can trigger a request to http://localhost:4000/authors?name=Patrick+Rothfus by passing the query param to the get/3 function.

# Test/book_search_web/controllers/author_controller_test.exs

test "lists all authors _ matching search query", %{conn: conn} do
  author = author_fixture(name: "Patrick Rothfus")
  conn = get(conn, Routes.author_path(conn, :index, name: author.name))
  assert html_response(conn, 200) =~ author.name
end

You’ll notice this test passes!

$ mix test
...
20 tests, 0 failures

Unfortunately, that’s because we’re not filtering at all! We’ll have to write another test for filtering out authors. We’ll refute/1 that the author’s name is on the page this time.

# Test/book_search_web/controllers/author_controller_test.exs

test "lists all authors _ not matching search query", %{conn: conn} do
  author = author_fixture(name: "Patrick Rothfus")
  conn = get(conn, Routes.author_path(conn, :index, name: "Brandon Sanderson"))
  refute html_response(conn, 200) =~ author.name
end

Now, this test should fail. To make the test pass, we need to implement the ability to filter authors by name in the AuthorController.index/2 function.

We can retrieve the “ name “ query parameter inside the second argument to the controller action. Let’s create a new function clause for the AuthorController.index/2 function to use when there is a "name" query parameter. The more specific function clause should always be first, as the order does matter.

We can pass the name to the Authors.list_authors/1 function, which will handle the filtering.

# Lib/book_search_web/controllers/author_controller.ex

def index(conn, %{"name" => name}) do
  authors = Authors.list_authors(name)
  render(conn, "index.html", authors: authors)
end

def index(conn, _params) do
  authors = Authors.list_authors()
  render(conn, "index.html", authors: authors)
end

Authors.list_authors/1

The test fails because the Authors.list_authors/1 function does not exist.

We could implement this function right away. However, this is a great time to write tests for the Authors context!

# Test/book_search/authors_test.exs

describe "authors" do
...
# Add The List_authors/1 Tests Inside The "authors" Describe Block.

test "list_authors/1 _ matching name" do
  author = author_fixture(name: "Andrew Rowe")
  assert Authors.list_authors("Andrew Rowe") == [author]
end

test "list_authors/1 _ non matching name" do
  author = author_fixture(name: "Andrew Rowe")
  assert Authors.list_authors("Dennis E Taylor") == []
end

...
end

Now we need to implement the Authors.list_authors/1 function. We’ll create another function clause for list_authors.

# Lib/book_search/authors.ex

def list_authors do
  Repo.all(Author)
end

def list_authors(name) do
  Repo.all(Author)
end

To make our tests pass, we need to filter the query. By default we’re passing the Author schema to Repo.all/2.

Let’s consider a few solutions to the problem. First, we could retrieve the list of Author structs from the database and filter them using Enum.filter/2.

def list_authors(name) do
  Author
  |> Repo.all()
  |> Enum.filter(fn author -> author.name == name end)
end

Our tests pass. However, this is NOT RECOMMENDED. Why do you think that is? The answer is performance. This solution loads all of the authors from the database, and then filters them in memory. That’s not a problem if we only have a few authors. However, it becomes a massive problem as the list of authors grows.

Instead, rely on the database query for filtering results when possible/appropriate.

Ecto.Query provides functions for writing queries to the database.

We can filter our query using where/3 function. The pin ^ operator with ^name allows us to inject variables into the query expression.

def list_authors(name) do
  Author
  |> where([author], author.name == ^name)
  |> Repo.all()
end

Now all tests (including our original controller tests) pass!

$ mix test
...
23 tests, 0 failures

Author Search Edge Cases

We have a working search! However, there are a few limitations.

For example, let’s add the following test where we search by a partial name.

# Test/book_search/authors_test.exs

test "list_authors/1 _ partially matching name" do
  author = author_fixture(name: "Dennis E Taylor")
  assert Authors.list_authors("Dennis") == [author]
end

This test fails because the list_authors/1 function checks for an exact match.

# Lib/book_search/authors.ex

def list_authors(name) do
  Author
  |> where([author], author.name == ^name)
  |> Repo.all()
end

We can use like/2 from Ecto.Query to check if one string is inside of another.

# Lib/book_search/authors.ex

def list_authors(name) do
  search = "#{name}%"
  Author
  |> where([author], like(author.name, ^search))
  |> Repo.all()
end

The % works similarly to the wildcard * in a regular expression, so this will find all authors whose name starts with the searched name.

Let’s expand the test to check. We’ll check that we can find authors by a partially matching query in the middle or the end of their name as well.

# Test/book_search/authors_test.exs

test "list_authors/1 _ partially matching name" do
  author = author_fixture(name: "Dennis E Taylor")
  assert Authors.list_authors("Dennis") == [author]
  assert Authors.list_authors("E") == [author]
  assert Authors.list_authors("Taylor") == [author]
end

To make this pass, we need to use the % character at the start and end of the query.

# Lib/book_search/authors.ex

def list_authors(name) do
  search = "%#{name}%"
  Author
  |> where([author], like(author.name, ^search))
  |> Repo.all()
end

Finally, what happens when the search is all lowercase or all capitals? Currently, the search is case-sensitive. Add the following test, and we’ll see that it fails.

test "list_authors/1 _ case insensitive match" do
  author = author_fixture(name: "Dennis E Taylor")
  assert Authors.list_authors("DENNIS") == [author]
  assert Authors.list_authors("dennis") == [author]
end

To make this test pass, we can use ilike/2, which is a case insensitive version of like/2.

# Lib/book_search/authors.ex

def list_authors(name) do
  search = "%#{name}%"
  Author
  |> where([author], ilike(author.name, ^search))
  |> Repo.all()
end

Congratulations! All tests pass. We’re all done.

$ mix test
...
25 tests, 0 failures

Context Vs Controller Tests

We chose to write the edge cases for our author search on the Authors context. However, we could have written them on the controller. Why didn’t we?

Generally, context tests run faster than controller tests. Controller tests require more setup and ceremony, so they are more verbose. It’s also easier to read the intent of a context test.

Controller tests are more comprehensive. They test your application more holistically rather than testing a single function. As a result, controller tests (depending on how they are written) generally provide more confidence than context tests.

However, this question doesn’t have a simple answer! In this case, we’ve decided to comprehensively test the context because they are fast and easy to write. We then have fewer controller tests to ensure the controller and context integrate correctly. However, different situations require different styles of tests.

Further Reading

Consider the following resources to deepen your understanding of the topic.

Commit Your Progress

DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.

Run git status to ensure there are no undesirable changes. Then run the following in your command line from the curriculum folder to commit your progress.

$ git add .
$ git commit -m "finish BookSearch: Authors reading"
$ git push

We’re proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.

We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.

Navigation

Home Report An Issue Blog: PostsBlog: Search