We've briefly covered tests in previous episodes, but I've never explained it in
depth. Today we're going to look at writing unit tests and learn a bit more
about
ExUnit
. Let's get started.
Introduction
Testing is one of the most important tools in a software developer's arsenal; it's also one of the most underused, historically (although things seem to be getting a lot better on that front lately).
Today, we're going to cover:
- What is testing, and what is good for.
- ExUnit, Elixir's built-in Unit Testing framework.
- Building an example module via TDD.
- A great feature Elixir provides called doctests
What is Testing
People often see testing's value in avoiding regressions, and that's a fantastic reason to test. Tests allow you to refactor your code without concern that you've accidentally changed behaviour in some subtle way.
I see an even greater value in testing as the way to plan the code you're going to write. Testing is how I think now, and I find it easiest to build a system by gradually writing and satisfying a series of unit tests. Test-Driven Development also provides valuable feedback that helps you know when a module in your system is getting too complex -- if the tests are getting hard to write, the odds are your code is doing too much.
The time between when I learned how to test in Erlang and I built a working chat server was just a few hours.
Unit Testing - system under test
Unit testing is a means of testing just a single 'unit' in your system. This can be contrasted with an acceptance test, which tests the behaviour of a system as a whole, and verifies that it satisfies the overarching requirements.
Unit tests, on the other hand, are focused on a single portion of the system, so that a single portion of the system can be verified on its own.
ExUnit
Elixir comes with a built in tool for writing unit tests, called
ExUnit
.
You've already seen
ExUnit
test cases in previous episodes. Let's start a new
project and talk through a bit more:
mix new schizo
cd schizo
vim test/schizo_test.exs
Defining Tests
Here we can see a test case that was already created for us.
defmodule SchizoTest do
use ExUnit.Case
doctest Schizo
test "the truth" do
assert 1 + 1 == 2
end
end
This test is fairly self explanatory. It has the name "the truth", and it asserts that 1 + 1 is equal to 2. Let's talk about assertions.
Assertions
assert
is a macro provided by ExUnit to describe the intended behaviour of
your system.
What happens when an assertion fails?
test "one is two" do
assert 1 == 2
end
When you run the suite, this test will fail:
1) test one is two (SchizoTest)
test/schizo_test.exs:13
Assertion with == failed
code: 1 == 2
left: 1
right: 2
stacktrace:
test/schizo_test.exs:14: (test)
The inverse of
assert
is
refute
. These two macros provide most of what you'll
need to write your tests. There are a few others provided, and you can check
them out in
ExUnit's Assertions
documentation
Examples
To get comfortable testing in Elixir, we're going to create a module of our own using Test-Driven-Development, or TDD. If you're unfamiliar with this concept, it's basically the idea that you write your tests first, then write just enough code to make them pass, and no more. That is, you let the tests "drive" the development of your codebase.
We're going to test-drive our
Schizo
module. It's going to provide two
functions:
uppercase
and
unvowel
. These functions will uppercase every other
word, and remove the vowels from every other word, respectively.
Since we'll be using TDD, we'll start with the tests. Let's define some
behaviour for our first function,
uppercase.
defmodule SchizoTest do
use ExUnit.Case
test "uppercase doesn't change the first word" do
assert Schizo.uppercase("foo") == "foo"
end
test "uppercase converts the second word to uppercase" do
assert Schizo.uppercase("foo bar") == "foo BAR"
end
test "uppercase converts every other word to uppercase" do
assert Schizo.uppercase("foo bar baz whee") == "foo BAR baz WHEE"
end
end
Now let's just start implementing, making tests pass one by one as we go. We'll start by making the uppercase function. Our first test says it doesn't change the first word, so let's make the simplest thing that will work:
defmodule Schizo do
def uppercase(string) do
string
end
end
If we run the tests, the first test passes. Now we can move on to the second test case - the second word should be uppercased. We'll split the input string on space and uppercase every item in the list to start, joining it back together with a space:
defmodule Schizo do
def uppercase(string) do
string
|> String.split(" ")
|> Enum.map(fn(word) -> String.upcase(word) end)
|> Enum.join(" ")
end
end
We're using
Enum.map/2
to map each item in our list through a function. We
really only want to map every other word. There's a function for this,
Enum.map_every/3
. Let's use it instead:
defmodule Schizo do
def uppercase(string) do
string
|> String.split(" ")
|> Enum.map_every(2, fn(word) -> String.upcase(word) end)
|> Enum.join(" ")
end
end
This is close, but it's actually exactly the opposite of what we want - we're
mapping the odd words, and we want to map the even words. So we can't use this.
We
can
use another function from
Enum
though,
Enum.with_index/2
.
with_index
will convert an
Enumerable
into a
List
of 2-tuples, where the
first element is the data and the second element is the index in the
Enumerable
. We can then only apply our function to every other word:
defmodule Schizo do
def uppercase(string) do
string
|> String.split(" ")
|> Enum.with_index
|> Enum.map(fn({word, index}) ->
if rem(index, 2) == 0 do
word
else
String.upcase(word)
end
end)
|> Enum.join(" ")
end
end
With that, our tests pass.
Now that we've implemented uppercase, let's implement unvowel. First, we write some tests:
test "unvowel doesn't change the first word" do
assert Schizo.unvowel("foo") == "foo"
end
test "unvowel removes the second word's vowels" do
assert Schizo.unvowel("foo bar") == "foo br"
end
test "unvowel removes every other word's vowels" do
assert Schizo.unvowel("foo bar baz whee") == "foo br baz wh"
end
Once again, we run the tests and start implementing, step-by-step, until they pass.
First, we'll add the function:
def unvowel(string) do
string
end
We find ourselves in the same situation as before. Let's just copy and paste the
code from
uppercase
and change the inner function to remove vowels, since
they're very similar:
def unvowel(string) do
string
|> String.split(" ")
|> Enum.with_index
|> Enum.map(fn({word, index}) ->
if rem(index, 2) == 0 do
word
else
# We can just use a regular expression to remove the vowels
Regex.replace(~r/[aeiou]/, word, "")
end
end)
|> Enum.join(" ")
end
So that works, and we're done right?
TDD consists of "red, green, refactor." So far, we've just done "red, green." There's a lot of duplication here, and removing it will teach us some fun stuff about elixir, so let's go ahead and refactor this until we're happy with it. The whole point of the tests is that we can do this without fear.
We'll start by adding a new function,
every_other_word/2
, that takes a string
and a function to perform every other word, and we'll just use it on
uppercase
to start:
defmodule Schizo do
def every_other_word(string, fun) do
string
|> String.split(" ")
|> Enum.with_index
|> Enum.map(fn({word, index}) ->
if rem(index, 2) == 0 do
word
else
fun.(word)
end
end)
|> Enum.join(" ")
end
def uppercase(string) do
every_other_word(string, fn(word) -> String.upcase(word) end)
end
# ...
end
Then we can do the same for
unvowel
:
def unvowel(string) do
every_other_word(string, fn(word) -> Regex.replace(~r/[aeiou]/, word, "") end)
end
So this works. There's still a bit we could do to clean it up though. Elixir
provides an operator,
&
, that makes it a bit easier to produce anonymous
functions from existing functions. Let's use it to extract
String.upcase
to
see how it works:
def uppercase(string) do
every_other_word(string, &String.upcase/1)
end
And we can use a pipe to make this read even nicer:
def uppercase(string) do
string
|> every_other_word(&String.upcase/1)
end
We can't just extract
Regex.replace/4
though, because it doesn't take a single
string argument. Instead, we have a few options. You can produce an anonymous
function with
&
and pass numbered arguments into it, like so:
def unvowel(string) do
string
|> every_other_word(&Regex.replace(~r/[aeiou]/, &1, ""))
end
However, if you don't love the way that looks you might find it nicer to just
extract that into its own function and use
&
to pass it in:
def unvowel(string) do
string
|> every_other_word(&remove_vowels/1)
end
def remove_vowels(word) do
Regex.replace(~r/[aeiou]/, word, "")
end
Either approach is acceptable, though the second is a bit more self-documenting. That might be nicer to people that are reading your code later, and I think it's a good idea to optimize code for easy reading since you spend a lot more time reading code than you do writing it.
At any rate, now we're left with something quite nice looking, and it's very easy to extend it with more functions along the same lines. We just need to define the transformation functions, and we're basically done.
Doctests
Elixir also ships with support for something called doctests. Basically, if you
place an example
iex
session in your module or function documentation, you can
easily verify its behaviour by specifying a doctest in your test case.
This was cribbed from Python, but coming from Ruby I never have had a chance to play with it before Elixir. It's amazing. Let's go ahead and add a doctest to the Schizo module so you can see how it works:
@moduledoc """
This is a module that provides odd behaviour for transforming every other word
in a string.
Here are some examples:
iex> Schizo.uppercase("this is an example")
"this IS an EXAMPLE"
iex> Schizo.unvowel("this is an example")
"this s an xmpl"
"""
To add these doctests to your test suite, open up the
SchizoTest
module and
just add the following line:
doctest Schizo
Then, run the tests again, and two new test cases have been added. Pretty cool, huh? Gone are the days of documentation that is subtly incorrect!
Summary
Today, we learned how to write unit tests, TDD a module from the ground up, and explored DocTests. Armed with the ability to TDD your code, you should be able to level up in Elixir substantially faster from here on out. See you soon!
Resources
- ExUnit Documentation
- ExUnit's Assertions documentation
- Exercism is a great online community for learning a language by satisfying test suites they provide, and getting feedback from other users. If you haven't seen it, you should check it out!
Comments for Testing with ExUnit