ExUnit 🌑

Hero image for ExUnit 🌑

ExUnit which is a unit testing framework bundled with Elixir.

Naming & Structure

  • Put all tests in the test directory 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.exs to all test files.

    # Bad
    /test/crawler/keywords.ex
    /test/crawler_web/controller/keyword_controller.ex
    
    # Good
    /test/crawler/keywords_test.exs
    /test/crawler_web/controller/keyword_controller_test.exs
    
  • Whenever possible, always run the tests concurrently by setting async: true to the ExUnit.Case in the test suite setup.

    defmodule Crawler.Keywords do
      use Crawler.DataCase, async: true
    
      # Test codes
    end
    
  • In cases where we cannot run the test asynchronously, explicitly state async: false rather than leaving it blank.

    # Bad
    defmodule Crawler.Keywords do
      use Crawler.DataCase
    
      # Test codes
    end
    
    # Good
    defmodule Crawler.Keywords do
      use Crawler.DataCase, async: false
    
      # Test codes
    end
    

Formatting

  • Use the function name with arity as a message of describe/2 block 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
    end
    
      describe "list/2" do
        # ...
      end
    end
    
  • Put the expression being tested to the left of the operator, and the expected value to the right.

    # Bad
    assert "elixir" == String.downcase("ELIXIR")
    
    # Good
    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.

    # Bad
    test "returns keyword" do
      # ...
    
      assert %{type: "keyword", title: "rocket"} = created_keyword
    end
    
    # Good
    test "returns keyword" do
      # ...
    
      assert created_keyword == %{type: "keyword", title: "rocket"}
    end
    

    However, 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 nil or false value, prefer to use assert/1 with == over refute/1 to ensure exact value of the expression since refute/1 expect for nil and false.

    # Bad
    refute false_value
    refute nil_value
    
    # Good
    assert 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.

    # Good
    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/fixtures directory (for the ExVCR called β€œvcr_cassette”)

    test
    β”œβ”€β”€ support
    β”‚   β”œβ”€β”€ fixtures
    β”‚   β”‚   β”œβ”€β”€ vcr_cassettes
    β”‚   β”‚   β”‚   └── get_user_detail_success.json
    

Module Stubbing

Always stub other modules to isolate the test using Mimic when the tested module interacts or depends on other modules.

describe "age/1" do
  test "returns age" do
    birth_date = ~N[1990-10-15 08:00:00.000000]
    stub(NaiveDateTime, :utc_now, fn -> ~N[2021-01-15 08:00:00.000000] end)

    Human.age(birth_date) == 30
  end
end

The above code example is to test age calculation from the given birth date with the current date. It is a good idea to stub the current DateTime to avoid unexpected behavior. For example, the calculated age can be different based on the current date.

Phoenix Testing

When testing a Phoenix controller and to 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/2 function 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
    end
    

    When 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/2 block

    defmodule 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.ConnTest module 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(conn, "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(conn, "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/2 block

    describe "get /keywords" do
      # ...
    end
    
    describe "get /keywords/:id" do
      # ...
    end
    
    describe "post /keywords" do
      # ...
    end