Skip to content

Latest commit

 

History

History
1114 lines (775 loc) · 36.2 KB

book_search_authors.livemd

File metadata and controls

1114 lines (775 loc) · 36.2 KB

BookSearch: Authors

Mix.install([
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"},
  {:tested_cell, github: "brooklinjazz/tested_cell"},
  {:utils, path: "#{__DIR__}/../utils"}
])

Navigation

Return Home Report An Issue

Setup

Ensure you type the ea keyboard shortcut to evaluate all Elixir cells before starting. Alternatively, you can evaluate the Elixir cells as you read.

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

Add the authors to our routes.

scope "/", BookSearchWeb do
  pipe_through :browser

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

All tests should pass.

$ mix test

Start the server.

$ mix phx.server

ConnCase & ConnTest

Phoenix automatically generates controller tests to ensure we can perform CRUD (Create, Read, Update, Delete) actions for the generated resource.

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.

flowchart

ControllerTest --> ConnCase
ControllerTest --> ConnTest
ConnTest --> get/3
ConnTest --> post/3
ConnTest --> delete/3
ConnTest --> put/3
ConnTest --> put/3

click get/3 href "https://hexdocs.pm/phoenix/Phoenix.ConnTest.html#get/3" _blank
click post/3 href "https://hexdocs.pm/phoenix/Phoenix.ConnTest.html#post/3" _blank
click put/3 href "https://hexdocs.pm/phoenix/Phoenix.ConnTest.html#put/3" _blank
click delete/3 href "https://hexdocs.pm/phoenix/Phoenix.ConnTest.html#delete/3" _blank
Loading

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:  "<!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    <meta name=\"csrf-token\" content=\"JWQEbh8hXx0ELGUTKk4CKEkrUEQsehcBw-BWKpmy7M2eZwAI9mg4YHXh\">\n<title data-suffix=\" · Phoenix Framework\">BookSearch · 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\"></script>\n  </head>\n  <body>\n    <header>\n      <section class=\"container\">\n        <nav>\n          <ul>\n            <li><a href=\"https://hexdocs.pm/phoenix/overview.html\">Get Started</a></li>\n\n              <li><a href=\"/dashboard\">LiveDashboard</a></li>\n\n          </ul>\n        </nav>\n        <a href=\"https://phoenixframework.org/\" class=\"phx-logo\">\n          <img src=\"/images/phoenix.png\" alt=\"Phoenix Framework Logo\">\n        </a>\n      </section>\n    </header>\n<main class=\"container\">\n  <p class=\"alert alert-info\" role=\"alert\"></p>\n  <p class=\"alert alert-danger\" role=\"alert\"></p>\n<h1>Search Authors</h1>\n\n<table>\n  <thead>\n    <tr>\n      <th>Name</th>\n\n      <th></th>\n    </tr>\n  </thead>\n  <tbody>\n\n  </tbody>\n</table>\n\n<span><a href=\"/author/new\">New Author</a></span>\n</main>\n  </body>\n</html>"
     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 <h1>Search Authors</h1> in the test failure output.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="csrf-token" content="JWQEbh8hXx0ELGUTKk4CKEkrUEQsehcBw-BWKpmy7M2eZwAI9mg4YHXh">
    <title data-suffix=" · Phoenix Framework">BookSearch · Phoenix Framework</title>
    <link phx-track-static rel="stylesheet" href="/assets/app.css">
    <script defer phx-track-static type="text/javascript" src="/assets/app.js"></script>
</head>

<body>
    <header>
        <section class="container">
            <nav>
                <ul>
                    <li><a href="https://hexdocs.pm/phoenix/overview.html">Get Started</a></li>
                    <li><a href="/dashboard">LiveDashboard</a></li>
                </ul>
            </nav> <a href="https://phoenixframework.org/" class="phx-logo"> <img src="/images/phoenix.png"
                    alt="Phoenix Framework Logo"> </a>
        </section>
    </header>
    <main class="container">
        <p class="alert alert-info" role="alert"></p>
        <p class="alert alert-danger" role="alert"></p>
        <h1>
            Search Authors</h1>
        <table>
            <thead>
                <tr>
                    <th>Name</th>
                    <th></th>
                </tr>
            </thead>
            <tbody>
            </tbody>
        </table><span><a href="/author/new">New Author</a></span>
    </main>
</body>

</html>

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

<main class="container">
  <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
  <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
  <%= @inner_content %>
</main>

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.

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 %>

    <div>
      <%= submit "Search" %>
    </div>
  </.form>
<%= 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 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.

Commit Your Progress

Run the following in your command line from the beta_curriculum folder to track and save your progress in a Git commit.

$ git add .
$ git commit -m "finish book search authors section"

Up Next

Previous Next
Portfolio Auth Blog Page Portfolio Blog Search