Subscribe now

Testing with ExUnit [02.13.2017]

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