Mix.install([
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"},
{:tested_cell, github: "brooklinjazz/tested_cell"},
{:utils, path: "#{__DIR__}/../utils"}
])
Ensure you type the ea
keyboard shortcut to evaluate all Elixir cells before starting. Alternatively, you can evaluate the Elixir cells as you read.
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.
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
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
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
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.
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
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
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
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
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"
.
Manually create a new author on http://localhost:4000/authors/new to verify the behavior of our successful test above.
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>
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
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.
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
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.
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!
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
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
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
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.
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"
Previous | Next |
---|---|
Portfolio Auth Blog Page | Portfolio Blog Search |