It’s time to test the M of the MVC, models. Phoenix generates a module in test/support/data_case.ex to serve as a foundation for your tests that interact with the database. In our case, Accounts and Multimedia contexts both work with the database. The data_case handles setup and teardown of the database and integrates with Ecto.Sandbox to allow concurrent transactional tests. Crack it open and import the fixtures we just defined:
1: | defmodule Rumbl.DataCase do |
- | use ExUnit.CaseTemplate |
- | |
- | using do |
5: | quote do |
- | alias Rumbl.Repo |
- | |
- | import Ecto |
- | import Ecto.Changeset |
10: | import Ecto.Query |
- | import Rumbl.DataCase |
- | import Rumbl.TestHelpers |
- | end |
- | end |
15: | |
- | setup tags do |
- | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Rumbl.Repo) |
- | |
- | unless tags[:async] do |
20: | Ecto.Adapters.SQL.Sandbox.mode(Rumbl.Repo, {:shared, self()}) |
- | end |
- | |
- | :ok |
- | end |
25: | |
- | def errors_on(changeset) do |
- | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> |
- | Regex.replace(~r"%{(w+)}", message, fn _, key -> |
- | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() |
30: | end) |
- | end) |
- | end |
- | end |
Let’s take a look in more detail.
On line 12 we import our test helpers inside the using block. The using block serves as a place for defining macros, common imports and aliases. We also see a setup block for handling transactional tests. A transactional test runs a test and rolls back any changes made during the test. This transactional technique allows tests to reset the database to a known state quickly between tests.
Phoenix also generates an errors_on function for quickly accessing a list of error messages for attributes on a given schema. You’ll see that function come into play as we write tests for our contexts.
Let’s start with user account registration. In truth, most context-related functionality will be tested with our integration tests as they insert and update records, but not all. Error and exception flows are some of the trickiest parts of our application to get right. We will explicitly try to catch some error conditions as close to the breaking point as possible. For us, since our context layer is the one that interacts directly with our database code, we’ll build such cases there. Create a new file test/rumbl/accounts_test.exs that looks like this:
1: | defmodule Rumbl.AccountsTest do |
- | use Rumbl.DataCase, async: true |
- | |
- | alias Rumbl.Accounts |
5: | alias Rumbl.Accounts.User |
- | |
- | describe "register_user/1" do |
- | @valid_attrs %{ |
- | name: "User", |
10: | username: "eva", |
- | password: "secret" |
- | } |
- | @invalid_attrs %{} |
- | |
15: | test "with valid data inserts user" do |
- | assert {:ok, %User{id: id}=user} = Accounts.register_user(@valid_attrs) |
- | assert user.name == "User" |
- | assert user.username == "eva" |
- | assert [%User{id: ^id}] = Accounts.list_users() |
20: | end |
- | |
- | test "with invalid data does not insert user" do |
- | assert {:error, _changeset} = Accounts.register_user(@invalid_attrs) |
- | assert Accounts.list_users() == [] |
25: | end |
- | |
- | test "enforces unique usernames" do |
- | assert {:ok, %User{id: id}} = Accounts.register_user(@valid_attrs) |
- | assert {:error, changeset} = Accounts.register_user(@valid_attrs) |
30: | |
- | assert %{username: ["has already been taken"]} = |
- | errors_on(changeset) |
- | |
- | assert [%User{id: ^id}] = Accounts.list_users() |
35: | end |
- | |
- | test "does not accept long usernames" do |
- | attrs = Map.put(@valid_attrs, :username, String.duplicate("a", 30)) |
- | {:error, changeset} = Accounts.register_user(attrs) |
40: | |
- | assert %{username: ["should be at most 20 character(s)"]} = |
- | errors_on(changeset) |
- | assert Accounts.list_users() == [] |
- | end |
45: | |
- | test "requires password to be at least 6 chars long" do |
- | attrs = Map.put(@valid_attrs, :password, "12345") |
- | {:error, changeset} = Accounts.register_user(attrs) |
- | |
50: | assert %{password: ["should be at least 6 character(s)"]} = |
- | errors_on(changeset) |
- | assert Accounts.list_users() == [] |
- | end |
- | end |
55: | end |
On line 2, we use Rumbl.DataCase to set up our DB dependent tests. We pass the async: true option so the test runs concurrently. Then, on lines 15 and 22, we build valid and invalid users and assert the expected results of trying to register a new user account with Accounts.register_user. In the remaining tests, on lines 37, 46, and 27, our error checking is a bit more intentional. We set a username that’s too long and assert that we got a specific error back. Likewise, we set a password that is too short and then test for a specific error.
We close by setting a password that is too short and then test for a specific error. For these tests, we use the errors_on function defined on Rumbl.DataCase. errors_on is convenient for quickly retrieving errors from the changeset. Keep in mind errors_on is just a function. You can create a custom version if you need to test custom behavior.
Now let’s run our tests:
| $ mix test test/rumbl/accounts_test.exs |
| ....... |
| |
| Finished in 0.1 seconds |
| 5 tests, 0 failures |
All green.
Next, let’s introduce a testing feature in ExUnit called the describe block. Sometimes, you need to apply the same setup code to many different tests. Describe blocks allow us to apply different test setups to a whole block of tests. For example, we need a user record to test Accounts.authenticate_by_username_and_pass, so we create a describe block with its own setup and three tests, like this:
1: | describe "authenticate_by_username_and_pass/2" do |
- | @pass "123456" |
- | |
- | setup do |
5: | {:ok, user: user_fixture(password: @pass)} |
- | end |
- | |
- | test "returns user with correct password", %{user: user} do |
- | assert {:ok, auth_user} = |
10: | Accounts.authenticate_by_username_and_pass(user.username, @pass) |
- | |
- | assert auth_user.id == user.id |
- | end |
- | |
15: | test "returns unauthorized error with invalid password", %{user: user} do |
- | assert {:error, :unauthorized} = |
- | Accounts.authenticate_by_username_and_pass(user.username, "badpass") |
- | end |
- | |
20: | test "returns not found error with no matching user for email" do |
- | assert {:error, :not_found} = |
- | Accounts.authenticate_by_username_and_pass("unknownuser", @pass) |
- | end |
- | end |
Let’s break it down. We start by defining a new describe block on line 4 which creates a user fixture having a hardcoded valid email and password.
Next, on line 8, we test that authenticate_by_username_and_pass returns our user when we provide a correct email and password. Following our valid authentication tests, we then test for the two possibile user authentication failures, a bad password or missing email on lines 15 and 20.
Now let’s run our tests again:
| $ mix test test/rumbl/accounts_test.exs |
| ....... |
| |
| Finished in 0.1 seconds |
| 8 tests, 0 failures |
We’re still happily green.
Let’s test the data access features in our Multimedia context. Create a new file at test/rumbl/multimedia_test.exs that looks like this:
| defmodule Rumbl.MultimediaTest do |
| use Rumbl.DataCase, async: true |
| |
| alias Rumbl.Multimedia |
| alias Rumbl.Multimedia.Category |
| |
| describe "categories" do |
| test "list_alphabetical_categories/0" do |
| for name <- ~w(Drama Action Comedy) do |
| Multimedia.create_category!(name) |
| end |
| |
| alpha_names = |
| for %Category{name: name} <- |
| Multimedia.list_alphabetical_categories() do |
| |
| name |
| end |
| |
| assert alpha_names == ~w(Action Comedy Drama) |
| end |
| end |
| end |
We programmatically create categories and later fetch them. Our tests verify that they are in alphabetical order. Now let’s run our tests:
| $ mix test test/rumbl/multimedia_test.exs |
| . |
| |
| Finished in 0.07 seconds |
| 1 test, 0 failures |
Success!
Everything looks good, so let’s now test the video functions of our Multimedia context, which is a little more involved than the previous tests:
1: | describe "videos" do |
- | alias Rumbl.Multimedia.Video |
- | |
- | @valid_attrs %{description: "desc", title: "title", url: "http://local"} |
5: | @invalid_attrs %{description: nil, title: nil, url: nil} |
- | |
- | test "list_videos/0 returns all videos" do |
- | owner = user_fixture() |
- | %Video{id: id1} = video_fixture(owner) |
10: | assert [%Video{id: ^id1}] = Multimedia.list_videos() |
- | %Video{id: id2} = video_fixture(owner) |
- | assert [%Video{id: ^id1}, %Video{id: ^id2}] = Multimedia.list_videos() |
- | end |
- | |
15: | test "get_video!/1 returns the video with given id" do |
- | owner = user_fixture() |
- | %Video{id: id} = video_fixture(owner) |
- | assert %Video{id: ^id} = Multimedia.get_video!(id) |
- | end |
20: | |
- | test "create_video/2 with valid data creates a video" do |
- | owner = user_fixture() |
- | |
- | assert {:ok, %Video{} = video} = |
25: | Multimedia.create_video(owner, @valid_attrs) |
- | |
- | assert video.description == "desc" |
- | assert video.title == "title" |
- | assert video.url == "http://local" |
30: | end |
- | |
- | test "create_video/2 with invalid data returns error changeset" do |
- | owner = user_fixture() |
- | assert {:error, %Ecto.Changeset{}} = |
35: | Multimedia.create_video(owner, @invalid_attrs) |
- | end |
- | |
- | test "update_video/2 with valid data updates the video" do |
- | owner = user_fixture() |
40: | video = video_fixture(owner) |
- | assert {:ok, video} = |
- | Multimedia.update_video(video, %{title: "updated title"}) |
- | assert %Video{} = video |
- | assert video.title == "updated title" |
45: | end |
- | |
- | test "update_video/2 with invalid data returns error changeset" do |
- | owner = user_fixture() |
- | %Video{id: id} = video = video_fixture(owner) |
50: | |
- | assert {:error, %Ecto.Changeset{}} = |
- | Multimedia.update_video(video, @invalid_attrs) |
- | |
- | assert %Video{id: ^id} = Multimedia.get_video!(id) |
55: | end |
- | |
- | test "delete_video/1 deletes the video" do |
- | owner = user_fixture() |
- | video = video_fixture(owner) |
60: | assert {:ok, %Video{}} = Multimedia.delete_video(video) |
- | assert Multimedia.list_videos() == [] |
- | end |
- | |
- | test "change_video/1 returns a video changeset" do |
65: | owner = user_fixture() |
- | video = video_fixture(owner) |
- | assert %Ecto.Changeset{} = Multimedia.change_video(video) |
- | end |
- | end |
We started by grouping our tests together with a new describe block for testing video functionality. On line 7, we create a video, picking off the id field with a pattern match. When we fetch a video, we verify correctness by matching against the id key of the Video record. Then, we do the same with a second video record. This test handles fetching a list of videos. Next, on line 15, we test the get_video function. We use the same technique to fetch a single video using get_video.
Next, on line 21 and 32 we test creation of videos with both valid and invalid user input. We verify a few attributes for the valid test and match against an error tuple for the invalid one. We’re specifically testing our change set functionality. We used a similar approach to test video updates on lines 38 and 47.
Next, we tested delete_video. We ran assertions to verify both the :ok tuple and that the video no longer exists on a subsequent fetch.
Finally, we checked out the ability to return a changeset for tracking video changes on line 64. These tests cover a lot of ground, but they’re quite simple.
Now let’s run our tests:
| $ mix test test/rumbl/multimedia_test.exs |
| ......... |
| |
| Finished in 0.2 seconds |
| 9 tests, 0 failures |
As expected, they are all green. Before we write more tests, let’s take a short break and talk about the Ecto Sandbox.
3.137.188.201