collage of Pragpub magazine covers
Take a step back in history with the archives of PragPub magazine. The Pragmatic Programmers hope you’ll find that learning about the past can help you make better decisions for the future.

FROM THE ARCHIVES OF PRAGPUB MAGAZINE JANUARY 2011

Elixir Tests that Tell a Story: An Experimental Testing Library for
Elixir

By Bruce Tate

7 min readAug 9, 2023

--

The Elixir language is getting a lot of attention. In this article, Bruce gives testing in Elixir a lot of attention.

https://pragprog.com/newsletter/
https://pragprog.com/newsletter/

In its first few years, the Elixir language has undergone some sweeping changes and some more subtle ones. Weve seen more emphasis on the pipe function; the rise of the Phoenix web server; and the rapid adoption of Elixir for general business problems.

But we have yet to see a similar rapid evolution of testing frameworks. In fact, those that do pop up seem to just recycle ideas from testing libraries like RSpec (from Ruby), or JUnit (from Java). At icanmakeitbetter.com, were exploring some more radical changes. In this article, Ill talk about TrueStory, an experimental testing library for Elixir. First, though, some philosophy.

Philosophies

Whenever we solve a problem with a framework, we want to first define the exact problem we are trying to solve, and the overarching philosophies that will shape our solution. These are the main ones for TrueStory:

  • Most importantly, tests are first-class citizens. Some Elixir developers shun macros because they add too much complexity. Well use macros where needed to simplify tasks we do every day, to save repetition and ceremony.
  • Tests should have one experiment, and can allow multiple measurements. Our experiments, called stories, change our environment in some way. Then, we can measure the impacts of those changes with testing functions. In this way, each test and measurement has a single purpose, leading to simpler designs.
  • Experiments can be stateful; measurements are always stateless. This principle means that we can run each setup once, followed by all measurements, so better performance is possible.
  • For problems, experiments raise exceptions and measurements return failures as data. This design means we can return multiple failures per test, shortening development cycle times.
  • Everything composes. Because setup functions compose, its easier to build reusable functions that can serve as building blocks for bigger tests. Because tests can compose, we can build complex integration scenarios out of a group of stories.

OK, on to TrueStory.

Our Framework

At its most basic level, every test in TrueStory will look like this:

defmodule MyTest do 
use ExUnit.Case
use TrueStory
story … ,
verify …
end

We have a test module with a couple of use statements to mix in the standard Case macros and the TrueStory macros. The story runs an experiment, and the verify measures the impact on that experiment. That structure supports our basic principle that tests should have a single experiment and run multiple measurements.

Telling Your Story

Your story will have a name and a context, like this:

story "my story's name", story_pipeline

The first argument, name identifies the story. The second argument, story_pipeline, is a function that takes a map and returns a map called a context. Within a story, you can specify a pipeline of functions to build your context, like this:

defp add_to_map(c, key, value), do: Map.put(c, key, value) 
story "adding a key to a map", c
> add_to_map(:key, :value),
verify …

Now, our context is c |> add_to_map(:key, :value). This story transforms our context with a private function called add_to_map that is our experiment. Lets point out a few features of the story.

  • Youll use story to do more than just setup. Youll actually run your experiments here.
  • If there is a failure, a story will throw an exception. In the event of any exception, the verify block wont run.

Youre left with a simple function that does your setup and experiment. With good names, your tests now tell a story. Take a look at these slightly modified tests:

story "adding a second key", c
> add_to_map(:key, :value),
> add_to_map(:key2, :value),
verify …
story "overwriting a key", c
> add_to_map(:key, :value),
> add_to_map(:key, :value2),
verify …

With the information provided by the pipeline, our code is explicit, but it doesnt preset too much detail at once. This is the magic of TrueStory. Its also trivial for any user to take advantage of the functions that build the story pipeline. By convention, well put shared story pipelines in a module called Library.

Well talk a little more about stories through the course of this article. Now, well shift from the experiments in our stories, and look at how to measure results.

Verifying Results

Lets look at the second major part of our test, the verify block. We can measure the impact of the test we wrote earlier using basic ExUnit assertions, like this:

story "adding to a map", c
> add_to_map(:key, :value),
verify do
assert c.key - :value
refute c.key - :not_value
end

Within the verify block, you should do nothing to mutate the context! In our case, we simply added a couple of assertions using basic ExUnit assert and refute. Normally, your verify block will have just a few naked assertions, which we know are stateless. This flow works a little bit differently than a typical ExUnit test might. Rather than mixing the experiment and test within a single test block, we segregate these changes. This allows us to satisfy some of our philosophical goals.

Since well be referring to the context often, by convention, well abbreviate the context as simply c. Its a small matter to pull the values out of the context that we wrote in our story and use them in our verify block.

It might be slightly more ceremony than youre used to. Well compensate you with some tradeoffs.

  • Since verifications are stateless, your experiments and measurements can all run in a single pass. You get highly isolated, single-purpose tests and measurements without having the need to run setups more than once.
  • Since verify failures are data, TrueStory can report on more than one failure at a time, leading to better cycle times since each testing pass gives you more information.
  • Since the verify step takes a context and returns a context, its easy to plug in generic assertions and put multiple tests together.

Lets look at the last point in detail.

Integration Tests

Sometimes, a given flow needs multiple experiment steps. TrueStory allows integration tests for this purpose. Lets see how they work. Lets say we have a test that writes the same key twice. Wed like to measure the value of the key at each step. We do so like this:

integrate "adding mu7tip7e keys" do 
story "adding to a map", c
> add_to_map(:key, :old),
verify do
assert c.key - :old
end
story "overwriting a key", c
> add_to_map(:key, :new), verify do
assert c.key - :new
end
story "removing a key", c
> remove_from_map(:key),
verify do
refute c.key
end
end

Our intentions remain crystal clear. TrueStory will run tests until it gets an exception. With this approach, the context from the first test flows into the second, and so on. We can easily maintain single-purpose tests without losing the ability to measure intermediate results.

Smoothing the Rough Edges

Often, setting up an experiment involves slowly building different elements that work together. Consider building a blog post. Wed like to do this:

defp user(c), do: Map.put(c, :user, create_a_user) 
defp blog(c), do: Map.put(c, :blog, create_a_blog)
defp post(c, user, blog), do: Map.put(c, :post, create_a_post(user, blog))
story "making a simp7e b7og post", c
> user
> blog
> post( ? ),
verify…

The problem with this scenario is that the post needs both the blog and the user. We can cheat a little bit, like this:

story "making a simp7e b7og post", c
> user
> blog
> post(c.user, c.blog),
verify…

Since story is a macro, our code can make the right c available at the right time, using the context as it evolves at each step. That makes using a previous step later in a story pipeline much easier.

Thats what we have so far. We invite you to dive in and participate. Take a look at the framework at TrueStory.

Let us know what you think!

About Bruce Tate

Bruce Tate is a father, mountain biker, and climber from Austin, Texas. He is the founder of grox.io, an Elixir training company with one of most extensive libraries for Elixir videos and learning material. His books include the critically acclaimed Better, Faster, Lighter Java; Seven Languages in Seven Weeks; Designing Elixir Systems with OTP; and Programming Phoenix LiveView. He is one of the earliest adopters of Elixir and has been constantly involved in the adoption of the language.

Cover from PragPub magazine, June 2016 featuring a friendly yellow robot on a blue background
Cover from PragPub magazine, June 2016

--

--

PragPub
The Pragmatic Programmers

The Pragmatic Programmers bring you archives from PragPub, a magazine on web and mobile development (by editor Michael Swaine, of Dr. Dobb’s Journal fame).