Discover Elixir & Phoenix

Back to All Courses

Lesson 20

Docs & testing

Documenting and testing your app is an important part of the development process, and Elixir has taken some great steps to make sure that we don't overlook it! Let's take a closer look!

Installing ExDoc

ExDoc is an official Elixir project that makes generating good documentation a breeze. We can use it in our Messengyr application by adding it as a dependency:

# mix.exs

defmodule Messengyr.Mixfile do
  
  # ...
  
  defp deps do
    
     # ...
     
     {:guardian, "~> 0.14"},
     {:ex_doc, "~> 0.14", only: :dev}] # Add this line!
  end

  # ...
  
end

Note that we specify only: :dev since we're never going to need to generate documentation in a production environment.

To install it, simply run mix deps.get.

Generating documentation

Are you ready to see something cool? Now that we have ExDocs, we can run the following command to generate our documentation automatically:

mix docs

You should see a message saying that your docs were successfully generated and ready to be viewed.

Now go to doc/index.html and open that file in your browser.

How pretty!

ExDocs has gone through all the modules and functions in our application and listed them here as an HTML page! Cool!

Now that we have an overview, we can see that we clearly need to do a better job at documenting our app. The default Phoenix modules like ErrorHelpers and Gettext all have a description, but the ones we've generated (like RoomController) are completely blank.

We should document all these modules!

Documenting our code

In Elixir, documentation is a first-class citizen, which makes it very easy to both read and write. There are a few rules and recommendations about how you should document your code that you can read about, but first, let's jump straight into an example to see why this feature is so valuable!

We'll start by documenting the ChatController. To do this, we simply use the @moduledoc attribute right after we define the module, followed by three quotation marks (") at the beginning and end of our descriptive text.

# lib/messengyr_web/controllers/chat_controller.ex

defmodule MessengyrWeb.ChatController do
  @moduledoc """
  The controller for the Messaging page, 
  available only for logged-in users
  """

  use MessengyrWeb, :controller

  # ...

end

Now we run mix docs again and refresh the documentation page.

Tada! There's the documentation for ChatController!

Being able to write the documentation right in module file instead of manually maintaining a separate HTML (or Markdown) file is a real time-saver.

Let's check out another example. This time we'll add some documentation to the RoomView module.

# lib/mesengyr_web/views/api/room_view.ex

defmodule MessengyrWeb.RoomView do
  @moduledoc """
  Renders Room structs in a given format.
  """

  use MessengyrWeb, :view

  # ...

end

In addition to documenting modules, we can also document the functions that it contains! Let's document the render/2-function using the @doc attribute:

# lib/mesengyr_web/views/api/room_view.ex

defmodule MessengyrWeb.RoomView do
  
  # ...

  @doc """
  Renders one or multiple Rooms in JSON format.
  """
  def render("show.json", %{room: room, me: me}) do
    %{
      room: room_json(room, %{me: me})
    }
  end

  def render("index.json", %{rooms: rooms, me: me}) do
    %{
      rooms: Enum.map(rooms, fn(room) -> room_json(room, %{me: me}) end)
    }
  end

  # ...

end

As you can see, we have two declarations of the render/2 function -- one that pattern matches "show.json" and one for "index.json". Therefore, when we describe how the function works with @doc, we must obviously take both of these cases into account (even though the @doc annotation is only added on top of the *first *function definition).

Another cool thing is that we can use Markdown in our documentation! This comes in handy when you want to display the list of parameters along with the description. Let's do that for render/2:

# lib/mesengyr_web/views/api/room_view.ex

defmodule MessengyrWeb.RoomView do
  
  # ...

  @doc """
  Renders one or multiple Rooms in JSON format.

  ## Parameters

    - *template*: either `"show.json"` (for one) or `"index.json"` (for multiple)
    - *assigns*: a map that must contain the following keys-value pairs:
    - `:room` (or `:rooms`) => one or multiple `Room` structs
    - `:me` => a `User` struct
  """
  def render("show.json", %{room: room, me: me}) do
    %{
      room: room_json(room, %{me: me})
    }
  end

  # ...

end

We're just using standard Markdown syntax, which includes headers (#), list items (-), and code snippets (```). If we run mix docs again, these will be generated into their corresponding HTML tags:

Refresh the page, click on "Messengyr.Web.RoomView" and scroll down to the list of functions to see it.

Fixing the autogenerated tests

Now that we've gone through some of the basics of documentation, let's take a look at testing!

As you know, all Mix projects come with a test folder for writing automatic tests. You might also have noticed that Phoenix sometimes generates test files for us when we run certain mix phx.gen commands. That's why, if you open the test folder, you'll see that we actually have a few tests already! Let's run them all and see what happens with this command:

mix test

Uh-oh. 4 failures!

It's worth noting that Phoenix's autogenerated tests mostly serve as a guidance, but they're not always necessary. Out of the tests that failed now, 3 out of 4 are related to the RoomChannel. If we open the room_channel_test.exs, we realise that they fail because they all try to connect to "room:lobby" (which we're not using in our app). Therefore, let's just delete the tests in that file:

# test/messengyr_web/channels/room_channel_test.exs

defmodule MessengyrWeb.RoomChannelTest do
  use MessengyrWeb.ChannelCase
end

Now we only have 1 failure :-)

The last failing test comes from the PageControllerTest module. This one reads the HTML content from our landing page and looks for the string "Welcome to Phoenix". Obviously that string is nowhere to be found in our template, since we removed it in one of the first chapters, which is why the test fails. To fix it, let's instead look for the string "Welcome to Messengyr", which does exist on the landing page.

# test/messengyr_web/controllers/page_controller_text.exs

defmodule MessengyrWeb.PageControllerTest do
  use MessengyrWeb.ConnCase

  test "GET /", %{conn: conn} do
    conn = get conn, "/"
    assert html_response(conn, 200) =~ "Welcome to Messengyr!"
  end
end

Now all our tests should pass! Hooray!

Adding our own tests

These autogenerated tests can be a good start, but in this case, they really don't do much. What usually require a lot of testing are the parts of the app that are susceptible to user input.

One example that comes to mind is the registration process -- we want to make sure that users can't sign up unless they've filled in all the required fields. Let's create a test module and a function that checks that!

Since the create_user/1-function is in the file lib/messengyr/accounts/accounts.ex, our test-file should be in test/accounts/accounts.ex. It's usually a good idea if the folder structure in lib/messengyr and test mirror each other, so that it's easy to see which test modules correspond to your app's modules. Note that all your test modules must end in _test.exs, otherwise they will be ignored when you run mix test.

# test/accounts/accounts_test.exs

defmodule Messengyr.AccountsTest do
  
  # (no content here yet)

end

In order to be able to use testassert and other handy test-related functions in this file, we should use one of the provided test cases from test/support. There are 3 to choose from: ChannelCase (for testing channels), ConnCase (for testing requests and rendering) or DataCase (for testing your schemas and changesets) . DataCase seems to be the right fit for this module.

Next, we simply create a test where we call the create_user/1-function with some parameters (we'll leave out the email-field on purpose). We then pattern match the response against the result we expected with assert.

Let's first pattern match against {:ok, user} to see what happens. If our assumptions are correct, this test should fail, because we've left out the email-field in our parameters and should therefore get a tuple with an :error instead of an :ok:

# test/accounts/accounts_test.exs

defmodule Messengyr.AccountsTest do
  use Messengyr.DataCase

  alias Messengyr.Accounts

  test "create_user/1 with missing data returns error changeset" do
    params = %{
      "username" => "tristan",
      "password" => "pa55w0rd",
    }

    assert {:ok, _user} = Accounts.create_user(params)
  end

end

Yup, just like we thought!

Our test informs us that it cannot pattern match, because it got an :error and a changeset in the tuple. Let's now make our test pass by pattern matching correctly:

# test/accounts/accounts_test.exs

defmodule Messengyr.AccountsTest do
  
  # ...

  test "create_user/1 with missing data returns error changeset" do
    # ...

    # Change this line:
    assert {:error, %Ecto.Changeset{}} = Accounts.create_user(params)
  end

end

There we go!

We should also write another test that does send all the necessary parameters to the function, and make sure that it returns {:ok, %User{}} instead:

# test/accounts/accounts_test.exs

defmodule Messengyr.AccountsTest do
  
  # ...
  
  test "create_user/1 with missing data returns error changeset" do
    # ...
  end

  # Add this test:
  test "create_user/1 with valid data creates a user" do
    params = %{
      "username" => "tristan",
      "password" => "pa55w0rd",
      "email" => "tristan@ludu.co",
    }

    assert {:ok, %User{}} = Accounts.create_user(params)
  end

end

It works!

Doctests

You've got a glimpse of how tests work in the test-folder, but here's a cool thing -- if you want, you can actually write tests right in the documentation of your app modules instead!

To try this out, we'll open our app's UserView module and add some documentation for the render/2-function:

# lib/mesengyr_web/views/api/user_view.ex

defmodule MessengyrWeb.UserView do
  use MessengyrWeb, :view

  @doc """
  Renders a user in JSON format.

  ## Example

      iex> alias Messengyr.Accounts.User
      iex> user = %User{
      ...>   id: 1,
      ...>   username: "test", 
      ...>   email: "test@example.com"
      ...> }
      iex> MessengyrWeb.UserView.render("show.json", %{user: user})
      %{
        user: %{
          id: 1, 
          username: "test", 
        }
      }
  """
  def render("show.json", %{user: user}) do
    %{
      user: user_json(user)
    }
  end

  # ...
  
end

As you can see, we've written our documentation using the @doc argument asusual, but we've also written how one can try out the module function directly in IEx and what we expect it to return! By indenting that part 4 spaces further in than the rest of the documentation, we've marked it as a** code block**. You can run mix docs to see what this looks like in HTML.

Glorious!

In addition to being handy for your collaborators, your documented IEx examples can also be used as tests! Think about how cool that is -- no longer will you have to write documentation describing how to use a function (and what output to expect) plus write a test for that very same example! Instead, it can all be done in one place, right where your original function is.

To use the IEx example as a test, simply create the corresponding test-file (user_view_test.exs), and specify which module you want to execute the doctests for:

# test/messengyr_web/views/user_view_test.exs

defmodule MessengyrWeb.UserViewTest do
  use MessengyrWeb.ConnCase, async: true

  # Here's how we specify that we want to use doctests:
  doctest MessengyrWeb.UserView
end

Run mix test, and you'll see that we now have 7 tests running instead of the usual 6!

Unfortunately our test fails! But we can clearly see why: we forgot to add the avatarURL in our expected result from the IEx command. Let's put that in!

# lib/messengyr_web/views/api/user_view.ex

defmodule MessengyrWeb.UserView do
  
  # ...

  @doc """
  Renders a user in JSON format.

  ## Example

      iex> alias Messengyr.Accounts.User
      iex> user = %User{
      ...>   id: 1,
      ...>   username: "test", 
      ...>   email: "test@example.com"
      ...> }
      iex> MessengyrWeb.UserView.render("show.json", %{user: user})
      %{
        user: %{
          id: 1, 
          username: "test", 
          avatarURL: "http://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0",
        }
      }
  """
  def render("show.json", %{user: user}) do
    # ...
end

And now the test passes!

We've now generated both a new test and some awesome documentation for our UserView.render/2-function, without having to duplicate anything!

Despite how awesome this is, this doesn't mean that you should convert *all *your tests to doctests. You can think about it like this -- when it's easy to try a function out in IEx and you know exactly what value it should return, then you could use a doctest!

The way Elixir has carefully thought through testing and documentation is definitely one of my favourite features of the language. It encourages developers to document and test their code, and with the addition of doctests, it makes sure that your documentation and your code **stay in sync **so that the docs never lag behind!