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 test
, assert
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!