Integration Tests

We’ve begun by testing our contexts. Since our contexts deal with database-backed applications, those tests checked the way we created, deleted, fetched, and updated data from the database. We also paid special attention to how we processed changes and errors. Our context API exposed those features through changesets.

Now it’s time to shift to integration tests. One of our basic principles for testing is isolation, but that doesn’t mean that the most extreme isolation is always the right answer. The interactions among parts of your software are the very things that make it interesting. When you test your Phoenix applications, getting the right level of isolation is critical. Sometimes, a function is the perfect level of isolation. Sometimes, though, you’ll want to run a test that encompasses multiple layers of your application. This is the realm of the integration test.

Fortunately, we have a natural architectural barrier that enforces the perfect balance. We’re going to fully test the route through the endpoint, as a real web request will do. That way, we’ll execute each plug and pick up all of the little transformations that occur along the way. We won’t have to do any complex test setup, and we won’t have any mismatch between the ways the tests and production server use our application. We’ll make sure our controller actions return success, redirect, or error codes as they should. We will test the behaviors we expect for authorization. To top it off, testing through the endpoint is superfast, so we pay virtually no penalty.

Warming Up with the Page Controller

Let’s get started. Start by opening test/rumbl_web/controllers/page_controller_test.exs to take another look:

 defmodule​ RumblWeb.PageControllerTest ​do
 use​ RumblWeb.ConnCase
 
  test ​"​​GET /"​, %{​conn:​ conn} ​do
  conn = get conn, ​"​​/"
  assert html_response(conn, 200) =~ ​"​​Welcome to Rumbl.io!"
 end
 end

This test is pretty sparse, but let’s see what we can glean. Notice RumblWeb.ConnCase. Phoenix adds a test/support/conn_case.ex file to each new project. That file extends Phoenix.ConnTest to provide the services your test suite will need to run locally. It will help your tests set up connections, call your endpoints with specific routes and the like. Open RumblWeb.ConnCase to see what’s provided by default:

 defmodule​ RumblWeb.ConnCase ​do
 use​ ExUnit.CaseTemplate
 
  using ​do
 quote​ ​do
 # Import conveniences for testing with connections
 use​ Phoenix.ConnTest
  alias RumblWeb.Router.Helpers, ​as:​ Routes
 
 # The default endpoint for testing
  @endpoint RumblWeb.Endpoint
 end
 end
 
  setup tags ​do
 :ok​ = Ecto.Adapters.SQL.Sandbox.checkout(Rumbl.Repo)
 
 unless​ tags[​:async​] ​do
  Ecto.Adapters.SQL.Sandbox.mode(Rumbl.Repo, {​:shared​, self()})
 end
 
  {​:ok​, ​conn:​ Phoenix.ConnTest.build_conn()}
 end
 end

As you’d expect, we use Phoenix.ConnTest to set up that API. Next, it imports convenient aliases we’ll use throughout our tests. Finally, it sets the @endpoint module attribute, which is required for Phoenix.ConnTest. This attribute lets Phoenix know which endpoint to call when you directly call a route in your tests.

Also notice our setup block. It sets up the Ecto Sandbox, as in DataCase, but the last line here is different. It returns {:ok, conn: ...}, which places a base conn into our test metadata, which flows into our page_controller_test as an optional second argument to the test macro.

These small bits of code let Phoenix tests use real endpoints, pipelines, and Plug.Conn connections that pass through your application code, just as the Phoenix framework would. After all, these are integration tests that should use the same paths production code uses whenever possible.

For example, in your page_controller_test, we called our controller with get conn, "/" rather than calling the index action on our controller directly. This practice ensures that we’re testing the router and pipelines because we’re using the controller the same way Phoenix does.

Phoenix also gives us some helpers to test responses and keep our tests clean, such as the assertion from page_controller_test:

 assert html_response(conn, 200) =~ ​"​​Welcome to Rumbl.io!"

These functions pack a lot of punch in a single function call. The simple statement html_response(conn, 200) does the following:

  • Asserts that the conn’s response was 200
  • Asserts that the response content-type was text/html
  • Returns the response body, allowing us to match on the contents

If our request had been a JSON response, we could have used another response assertion called json_response to match on any field of a response body. For example, you might write a json_response assertion like this:

 assert %{​user_id:​ ^user_id} = json_response(conn, 200)

Keep in mind RumblWeb.ConnCase is just a foundation. You can personalize it to your own application as needed. Let’s learn more about integration tests by writing our own VideoController tests from scratch, starting with the actions available while logged out.

Testing Logged-Out Users

We will need to create data so we will add factory helpers to our application so we can use user_fixture and video_fixture. Add import Rumbl.TestHelpers to your ConnCase using block to bring in our helpers in all our connection-related tests, like this:

1: using ​do
quote​ ​do
# Import conveniences for testing with connections
use​ Phoenix.ConnTest
5: import​ Rumbl.TestHelpers
alias RumblWeb.Router.Helpers, ​as:​ Routes
# The default endpoint for testing
@endpoint RumblWeb.Endpoint
10: end
end

With our fixture functions accessible, we can start testing our VideoController. Create a file called test/rumbl_web/controllers/video_controller_test.exs and make it look like this:

 defmodule​ RumblWeb.VideoControllerTest ​do
 use​ RumblWeb.ConnCase, ​async:​ true
 
  test ​"​​requires user authentication on all actions"​, %{​conn:​ conn} ​do
  Enum.each([
  get(conn, Routes.video_path(conn, ​:new​)),
  get(conn, Routes.video_path(conn, ​:index​)),
  get(conn, Routes.video_path(conn, ​:show​, ​"​​123"​)),
  get(conn, Routes.video_path(conn, ​:edit​, ​"​​123"​)),
  put(conn, Routes.video_path(conn, ​:update​, ​"​​123"​, %{})),
  post(conn, Routes.video_path(conn, ​:create​, %{})),
  delete(conn, Routes.video_path(conn, ​:delete​, ​"​​123"​)),
  ], ​fn​ conn ->
  assert html_response(conn, 302)
  assert conn.halted
 end​)
 end
 end

Since our video controller is locked behind user authentication, we want to make sure that our authentication pipeline halts every action. Since all of those tests are the same except for the routes, we use Enum.each to iterate over all of the routes we want, and we make the same assertion for each response. Since we’re verifying a halted connection that kicks logged-out visitors back to the home page, we assert a html_response of 302.

Let’s try our tests out:

 $ ​​mix​​ ​​test​​ ​​test/rumbl_web
 ....
 
 Finished in 0.1 seconds
 4 tests, 0 failures

And they pass. Now that we’ve tested all routes as logged-out users, we need to check the behavior as logged-in users.

Preparing for Logged-In Users

You might be tempted to place the user_id in the session for the Auth plug to pick up, like this:

 conn()
 |> fetch_session()
 |> put_session(​:user_id​, user.id)
 |> get(​"​​/videos"​)

This approach is a little messy because it assumes an implementation. We don’t want to store anything directly in the session, because we don’t want to leak implementation details. Alternatively, we could do a direct request to the session controller every time we want to log in. However, this would quickly become expensive, because most tests will require a logged-in user. There’s a better way.

Instead, we choose to test our login mechanism in isolation and build a bypass mechanism for the rest of our test cases. We simply pass any user through in our conn.assigns as a pass-through for our Auth plug. Update your web/controllers/auth.ex, like this:

 def​ call(conn, _opts) ​do
  user_id = get_session(conn, ​:user_id​)
 
 cond​ ​do
  conn.assigns[​:current_user​] ->
  conn
 
  user = user_id && Rumbl.Accounts.get_user(user_id) ->
  assign(conn, ​:current_user​, user)
 
  true ->
  assign(conn, ​:current_user​, nil)
 end
 end

We’ve rewritten our call function using cond to check for multiple conditions, with our new condition at the top. Its sole job is to match on the current_user already in place in the assigns. If we see that we already have a current_user, we return the connection as is.

Let’s be clear. What we’re doing here is controversial. We’re adding this code to make our implementation more testable. We think the trade-off is worth it. We are improving the contract. If a user is in the conn.assigns, we honor it, no matter how it got there. We have an improved testing story that doesn’t require us to write mocks or any other elaborate scaffolding.

Now, all of our tests for logged-in users will be much cleaner.

Testing Logged-In Users

Now, we’re free to add tests. We add a new test for /videos to test/rumbl_web/controllers/video_controller_test.exs, like this:

 setup %{​conn:​ conn, ​login_as:​ username} ​do
  user = user_fixture(​username:​ username)
  conn = assign(conn, ​:current_user​, user)
 
  {​:ok​, ​conn:​ conn, ​user:​ user}
 end
 
 test ​"​​lists all user's videos on index"​, %{​conn:​ conn, ​user:​ user} ​do
  user_video = video_fixture(user, ​title:​ ​"​​funny cats"​)
  other_video = video_fixture(
  user_fixture(​username:​ ​"​​other"​),
 title:​ ​"​​another video"​)
 
  conn = get conn, Routes.video_path(conn, ​:index​)
  assert html_response(conn, 200) =~ ​~r/Listing Videos/
  assert String.contains?(conn.resp_body, user_video.title)
  refute String.contains?(conn.resp_body, other_video.title)
 end

In our setup block, we seed a user to the database by using our user_fixture helper function. ConnCase takes care of running our tests in isolation. Any seeded fixtures in the database will be wiped between test blocks.

However, our new setup block causes the previous tests to break, because they expect a connection without a logged-in user. To fix our failing tests, let’s use describe blocks and tags.

Using Tags

Some of our tests require logging in and some don’t. Let’s wrap our new test case in a describe block to allow setup for only logged-in users. We’ll also use a :login_as tag to specify which user we’d like to log in. Tagging allows you to mark specific tests with attributes you can use later. You can access these attributes from the test context blocks. Tests outside of the describe block will then skip the login requirement:

1: describe ​"​​with a logged-in user"​ ​do
setup %{​conn:​ conn, ​login_as:​ username} ​do
user = user_fixture(​username:​ username)
5:  conn = assign(conn, ​:current_user​, user)
{​:ok​, ​conn:​ conn, ​user:​ user}
end
10:  @tag ​login_as:​ ​"​​max"
test ​"​​lists all user's videos on index"​, %{​conn:​ conn, ​user:​ user} ​do
user_video = video_fixture(user, ​title:​ ​"​​funny cats"​)
other_video = video_fixture(
user_fixture(​username:​ ​"​​other"​),
15: title:​ ​"​​another video"​)
conn = get conn, Routes.video_path(conn, ​:index​)
response = html_response(conn, 200)
assert response =~ ​~r/Listing Videos/
20:  assert response =~ user_video.title
refute response =~ other_video.title
end
end

We wrapped our setup block and video listing tests in a new describe block. Then, on line 10, we add a :login_as tag with our username. We consume that :login_as tag on line 3. Since Ex::Unit passes tags along with the test context, we can simply match on the tag, grabbing the value opposite the :login_as tag as username.

The tag module attribute accepts a keyword list or an atom. Passing an atom is a shorthand way to set flag style options. For example @tag :logged_in is equivalent to @tag logged_in: true. We rewrite our setup block to grab the config map, which holds our metadata with the conn and tags which we use to populate our user fixture.

Our tests now pass, because they only seed the database when necessary. We can also use the tags to run tests only matching a particular tag, like this:

 $ ​​mix​​ ​​test​​ ​​test/rumbl_web​​ ​​--only​​ ​​login_as
 Including tags: [:login_as]
 Excluding tags: [:test]
 
 .
 
 Finished in 0.1 seconds
 5 tests, 0 failures, 4 skipped

Perfect. In short, we’ll use tags anywhere we want to mark attributes for a block of tests and describes to scope setups to a block of tests. Our tests now exercise the video listing, but we still haven’t used the controller to create a video. Let’s build a test to create a video, making sure to define the new code inside our logged-in describe block like this:

 alias Rumbl.Multimedia
 
 @create_attrs %{
 url:​ ​"​​http://youtu.be"​,
 title:​ ​"​​vid"​,
 description:​ ​"​​a vid"​}
 @invalid_attrs %{​title:​ ​"​​invalid"​}
 
 defp​ video_count, ​do​: Enum.count(Multimedia.list_videos())
 
 @tag ​login_as:​ ​"​​max"
 test ​"​​creates user video and redirects"​, %{​conn:​ conn, ​user:​ user} ​do
  create_conn =
  post conn, Routes.video_path(conn, ​:create​), ​video:​ @create_attrs
 
  assert %{​id:​ id} = redirected_params(create_conn)
  assert redirected_to(create_conn) ==
  Routes.video_path(create_conn, ​:show​, id)
 
  conn = get conn, Routes.video_path(conn, ​:show​, id)
  assert html_response(conn, 200) =~ ​"​​Show Video"
 
  assert Multimedia.get_video!(id).user_id == user.id
 end
 
 @tag ​login_as:​ ​"​​max"
 test ​"​​does not create vid, renders errors when invalid"​, %{​conn:​ conn} ​do
  count_before = video_count()
  conn =
  post conn, Routes.video_path(conn, ​:create​), ​video:​ @invalid_attrs
  assert html_response(conn, 200) =~ ​"​​check the errors"
  assert video_count() == count_before
 end

In this example, we want to test the successful and unsuccessful paths for creating a video. To keep things clear and easy to understand, we create some module attributes for both valid and invalid changesets. This touch keeps our intentions clear. With one tweak, we can keep our tests DRY so changes in validations require only trivial adjustments to our controller tests. We’ll have another set of tests we can use to fully handle our changesets, but for now this strategy will work fine.

Next, we create the test case for the successful case. We use the create route with our valid attributes and then assert that we’re returning the right values and redirecting to the right place. Then, we confirm that our test impacts the database in the ways we expect. We don’t need to test all of the attributes, but we should pay attention to the elements of this operation that are likely to break. We assert that our new record exists and has the correct owner. This test makes sure that our happy path is indeed happy.

Writing negative integration tests is a delicate balance. We don’t want to cover all possible failure conditions, as those must be fully covered when unit testing the context. Instead, we’re handling concerns we choose to expose to the user, especially those that change the flow of our code. We test the case of trying to create an invalid video, the redirect, error messages, and so on.

Our other persistence tests will follow much the same approach. You can find the full CRUD test listing in the downloadable source code for the book.[25]

As you recall, we left a hole in our code coverage when we worked around authentication. Let’s shift gears and handle the authorization cases of our controller. We must test that other users cannot view, edit, update, or destroy videos of another user. Crack open our test case and key this in. Remember, since we’re not logged in, we want to add this test outside of our logged-in describe block:

 test ​"​​authorizes actions against access by other users"​, %{​conn:​ conn} ​do
  owner = user_fixture(​username:​ ​"​​owner"​)
  video = video_fixture(owner, @create_attrs)
  non_owner = user_fixture(​username:​ ​"​​sneaky"​)
  conn = assign(conn, ​:current_user​, non_owner)
 
  assert_error_sent ​:not_found​, ​fn​ ->
  get(conn, Routes.video_path(conn, ​:show​, video))
 end
  assert_error_sent ​:not_found​, ​fn​ ->
  get(conn, Routes.video_path(conn, ​:edit​, video))
 end
  assert_error_sent ​:not_found​, ​fn​ ->
  put(conn, Routes.video_path(conn, ​:update​, video, ​video:​ @create_attrs))
 end
  assert_error_sent ​:not_found​, ​fn​ ->
  delete(conn, Routes.video_path(conn, ​:delete​, video))
 end
 end

That test does a lot, so let’s break it down. First we create a new user to act as the owner for a video. Then, we set up our conn to log in a newly created user named sneaky, one that doesn’t own our existing video. Using descriptive variable names in tests can provide that extra bit of documentation to make your test’s intentions clear.

We use the same approach we used when we tested the basic path without logging in. In this case, the context is raising the Ecto.NoResultsError, since there is no video with the given ID associated to the given user. Instead of letting this error blow up in the user’s face, there is a protocol between Plug and Ecto where Plug is told to treat all Ecto.NoResultsError as a 404 response status, which we can also refer to as :not_found. We use a new function called assert_error_sent to test precisely that an error happened but it became a 404 when handled by Phoenix.

Though we don’t cover every controller action, these test cases provide a pretty good cross section for the overall approach. For practice, you can use these techniques to round out our integration tests.

As we work from the top down, we have one plug that we extracted into its own module, since it plays a critical role across multiple sections of our application. We’ll test that plug next, in isolation. We’re going to adhere to our principle for getting the right level of isolation.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.16.137.38