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: SearchReview 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 - Get Started
\n\n - LiveDashboard
\n\n
\n \n \n \n \n \n \n\n \n \nSearch Authors
\n\n\n \n \n Name \n\n \n \n \n \n\n \n
\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
in the test failure output.Search Authors
BookSearch · Phoenix Framework
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.