ExUnit π‘
ExUnit which is a unit testing framework bundled with Elixir.
All general testing best practices and Web testing best practices apply to ExUnit but working with a framework adds another superset of conventions to follow.
Naming & Structure
-
Put all tests in the
testdirectory with the same structure as the application.lib βββ crawler β βββ keywords.ex βββ crawler_web β βββ controllers β β βββ keyword_controller.ex test βββ crawler β βββ keywords_test.exs βββ crawler_web β βββ controllers β β βββ keyword_controller_test.exs -
Add the suffix
*_test.exsto all test files./test/crawler/keywords.ex /test/crawler_web/controller/keyword_controller.ex/test/crawler/keywords_test.exs /test/crawler_web/controller/keyword_controller_test.exs -
Whenever possible, always run the tests concurrently by setting
async: trueto theExUnit.Casein the test suite setup.defmodule Crawler.Keywords do use Crawler.DataCase, async: true # Test codes end -
In situations where it is impossible to run the test asynchronously, explicitly state
async: falserather than leaving it blank. Prefer to add awhycomment.defmodule Crawler.Keywords do use Crawler.DataCase # Test codes enddefmodule Crawler.Keywords do @moduledoc """ This module needs to run as `async: false` because it contains the `Application.put_env` """ use Crawler.DataCase, async: false setup_all do Application.put_env(:crawler, :balance_resource, Balances) on_exit(fn -> Application.put_env(:crawler, :balance_resource, BalancesMock) end) end # Test codes end
Formatting
-
Use the function name with arity as a message of
describe/2block and order in the corresponding order in the module.# Keywords application module defmodule Crawler.Keywords do def list(user) do # ... end def list(user, filter_params) do # ... end end # Keywords test module defmodule Crawler.KeywordsTest do describe "list/1" do # ... end describe "list/2" do # ... end end -
Put the precondition(s) for a test before the expected value in the message for the
testblock. The precondition should always start withwith,givenorwhen, end with a comma, and be in the form of a sentence with proper grammar.test "updates user with valid params" do "" end test "updates user given the actor has admin permissions" do "" end test "renders error when user is not authenticated" do "" endtest "with valid params, updates user" do "" end test "given the actor has admin permissions, updates user" do "" end test "when user is not authenticated, renders error" do "" end -
Put the expression being tested to the left of the operator, and the expected value to the right.
assert "elixir" == String.downcase("ELIXIR")assert String.downcase("ELIXIR") == "elixir" -
Prefer to use
==over pattern matching for assertions to ensure an exact match thus avoiding false positives with partial matches.test "returns keyword" do # ... assert %{type: "keyword", title: "rocket"} = created_keyword endtest "returns keyword" do # ... assert created_keyword == %{type: "keyword", title: "rocket"} endHowever, use pattern matching for assertions when a partial match makes sense. For instance:
- Making an assertion on a part of a string
# Validating if the generated uid prefix with "keyword" assert "keyword" <> _ = created_keyword.uid- Validating the order of the items in a list
# Validating if keywords are returned in alphabetical order assert [%{title: "apple"}, %{title: "rocket"}] = keywords -
For asserting a
nilorfalsevalue, prefer to useassert/1with==overrefute/1to ensure exact value of the expression sincerefute/1expect fornilandfalse.refute false_value refute nil_valueassert false_value == false assert nil_value == nil
Fixtures
Factories
Use ExMachina to create a factory module for generating data. Prefer to define one factory module per one schema.
- Defines a factory module.
defmodule Crawler.Keyword do
# Keyword Schema
end
defmodule Crawler.KeywordFactory do
defmacro __using__(_opts) do
quote do
def keyword_factory do
%Crawler.Keyword{
# Keyword attributes
}
end
end
end
end
- Add each factory to the main factory module.
defmodule Crawler.Factory do
use ExMachina.Ecto, repo: Crawler.Repo
use Crawler.KeywordFactory
use Crawler.UserFactory
end
Stubbing Network Requests
-
Stub all the request responses when the test includes the external HTTP requests. Prefer to use ExVCR.
describe "fetch_user_detail/1" do test "returns fetched user detail" do use_cassette "get_user_detail_success" do # Test codes assert Users.fetch_user_detail("user_uid") == user_detail end end end -
Put all the recorded request responses in the
support/fixturesdirectory (for theExVCRcalled βvcr_cassetteβ)test βββ support β βββ fixtures β β βββ vcr_cassettes β β β βββ get_user_detail_success.json
Module Mocking
-
Prefer to use
Mimic#expectoverMimic#stubto make sure the function is called.defmodule ProjectName.CalculatorTest do describe "age/1" do test "returns age given a specific naive datetime" do birth_date = ~N[1990-10-15 08:00:00.000000] expect(NaiveDateTime, :utc_now, fn -> ~N[2021-01-15 08:00:00.000000] end) assert Calculator.age(birth_date) == 30 verify!() end end end
Phoenix Testing
When testing a Phoenix controller and isolate the controller functionality, split the testing into two parts, a controller test and a request test.
lib
βββ crawler_web
β βββ controllers
β β βββ keyword_controller.ex
test
βββ crawler_web
β βββ controllers
β β βββ keyword_controller_test.exs
β βββ requests
β β βββ keyword_request_test.exs
Controller test
-
Test only controller functionality without any plug included. The Phoenix controller provides the
call/2function to call the controller directly.defmodule CrawlerWeb.KeywordControllerTest do describe "index/2" do test "renders keywords for the given user", %{conn: conn} do # Test codes conn = CrawlerWeb.KeywordController.call(conn, :index) assert html_response(conn, 200) =~ "#{keyword.title}" end end endWhen making an assertion, it should validate a controller response, an error handling, or result from a database operation. Below are examples to illustrate the main ideas:
-
Assertion when testing a keyword creation, the response contains the keyword title, and it persists the valid keyword.
assert html_response(conn, 201) =~ "#{keyword_title}" [keyword_in_db] = Repo.all(Keyword) assert keyword_in_db == keyword_title -
When testing a JSON response, assert on the complete controller response.
assert json_response(conn, 404) == %{ "code" => "not_found", "details" => %{}, "message" => "Keyword not found", "object" => "error", "type" => "keyword_not_found" }
-
-
Use action name with arity as a message of a
describe/2blockdefmodule CrawlerWeb.KeywordControllerTest do describe "index/2" do # ... end describe "show/2" do # ... end end
Request test
-
Request test covers the controller behavior with plugs included by making an HTTP request, so the request will go through all the plugs to the tested controller. The
Phoenix.ConnTestmodule provides a helper function to make an HTTP request to a specific route (e.g.get/3,post/3). In the assertion, validating response status is sufficient as most of the controller functionalities are tested in the controller test.defmodule CrawlerWeb.KeywordRequestTest do describe "get /keyword" do test "returns 200 status if called with valid access token", %{conn: conn} do # Test codes conn = conn |> Conn.put_req_header("authorization", "bearer: " <> valid_token) |> get(Routes.keyword_path(conn, :index)) assert conn.status == 200 end test "returns 401 status if called with invalid access token", %{conn, conn} do # Test codes conn = conn |> Conn.put_req_header("authorization", "bearer: " <> invalid_token) |> get(Routes.keyword_path(conn, :index)) assert conn.status == 401 end end end -
Use an HTTP method with the route as a message of a
describe/2blockdescribe "get /keywords" do # ... end describe "get /keywords/:id" do # ... end describe "post /keywords" do # ... end