Flow style tests

An argument for using assertions that can be arranged in a pipeline, so that a value-under-test flows through a series of them. A single test is improved. Two packages are pointed to.

TL;DR

I like test code like this:

VM.ServiceGap.accept_form(…)
|> ok_content(Changeset)

|> assert_valid
|> assert_changes(id: 1,
                  in_service_datestring: @iso_date_1,
                  out_of_service_datestring: @iso_date_2,
                  reason: "reason")

I have two Hex packages that support it: flow_assertions and ecto_flow_assertions.

Changesets

If you know Ecto, you don't need to read this section.

A Changesetdescribes proposed changes to some underlying data, typically a named structure. In a typical use, some code takes an original version of the structure and some HTTP form data describing a new version of the structure. The code populates the changes field (a map) with the values that differ from the original. It will also mark the valid? field true if all the changes pass validation tests. If any fail, the errors keyword list is populated with error messages.

In this example, the changeset is expected to be valid.

The starting test

This is a pretty typical Elixir test:

assert {:ok, changeset} = VM.ServiceGap.accept_form(…)
assert changeset.valid?
      
changes = changeset.changes
assert changes.id == 1
assert changes.in_service_datestring == @iso_date_1
assert changes.out_of_service_datestring == @iso_date_2
assert changes.reason == "reason"

VM.ServiceGap.accept_form (line 1) is the code under test. It accepts some parameters (not shown) and, given those parameters, should a standard Erlang/Elixir :ok tuple containing a changset.

On line 2, we check that the changeset is valid.

Then (line 4) we bind a variable changes to the changes field in the changeset. The remainder of the test asserts that all the changes are as expected.

assert_fields

My first improvement uses assert_fields to collapse four assertions into one (lines 5 and following):

assert {:ok, changeset} = VM.ServiceGap.accept_form(…)
assert changeset.valid?
      
changeset.changes
|> assert_fields(id: 1,
                 in_service_datestring: @iso_date_1,
                 out_of_service_datestring: @iso_date_2,
                 reason: "reason")

I think this is more important than it seems because of a fact about how people read tests: they skim. Typically, you'll approach a test with a specific question you want to answer as quickly as possible, like "The change I made to the code broke this test but not others. What's special about this one?"

The code shown above makes the actual fields being tested stand out more. First, they're colorized so they jump to the eye more quickly than they did in the previous version. Second, the lack of assert changes. means they're more obviously vertically aligned. Humans can skim obvious vertical lists more quickly than they can pick related words from the middle of vertical text, or - for that matter - can skim horizontal lists.

I also converted changeset.changes into the head of an Elixir |> pipeline. That makes it stand out more. We're used to reading a pipeline as being "about" the head of the pipeline, so what's being tested is now more clear.

Flow style

... Except this test isn't really about the changes within a changeset. It's about the changeset itself. Here's a fix:

assert {:ok, changeset} = VM.ServiceGap.accept_form(…)
     
changeset

|> assert_valid
|> assert_changes(id: 1,
                  in_service_datestring: @iso_date_1,
                  out_of_service_datestring: @iso_date_2,
                  reason: "reason")

Line 3 now signals that. Line 4 says that the changeset must be valid, and line 5 says what must be true of the valid changes.

Notice that assert_fields has turned into assert_changes. The latter is just a thin wrapper over the former.

All that's required for this kind of flow-style asserting is that every assertion return its first argument. The flow_assertions package contains a function, defchain, that makes that trivial.

Adding the :ok tuple to the pipeline

The :ok tuple line is pretty verbose for the information it conveys:

assert {:ok, changeset} = VM.ServiceGap.accept_form(…)

It checks for a "shape" that's pretty strongly implied by the rest of the test, it binds a variable that it will turn out we don't really need, and it shoves the information of what's being tested (accept_form) off to the right where it doesn't catch the eye. Let's fix that:

VM.ServiceGap.accept_form(…)
|> ok_content
|> assert_valid
|> assert_changes(id: 1,
                  in_service_datestring: @iso_date_1,
                  out_of_service_datestring: @iso_date_2,
                  reason: "reason")

Now the function under test has pride of place at the top of the pipeline (line 1). ok_content is a combined assertion (that we have one of those typical :ok tuples) and it returns the content of the tuple so that the rest of the test can work with it.

Then the rest of the test concerns itself with what must be true of that content.

One last wafer-thin tweak

accept_form is a function that's common to my emerging style of handling form input. I sometimes have trouble remembering whether it returns a changeset or the Ecto schema underlying the changeset. The test above doesn't help: it's completely lost the word "changeset" when the explicit binding went away. That's usefully fixed on line 2 below:

VM.ServiceGap.accept_form(…)
|> ok_content
(Changeset)
|> assert_valid
|> assert_changes(id: 1,
                  in_service_datestring: @iso_date_1,
                  out_of_service_datestring: @iso_date_2,
                  reason: "reason")

This variant of ok_content adds another assertion: that the content within the :ok tuple is a Changeset structure.

Conclusion

I find this style of test more readable than the original:

assert {:ok, changeset} = VM.ServiceGap.accept_form(…)
assert changeset.valid?
      
changes = changeset.changes
assert changes.id == 1
assert changes.in_service_datestring == @iso_date_1
assert changes.out_of_service_datestring == @iso_date_2
assert changes.reason == "reason"

It should be easier to create (fewer words), but I haven't yet persuaded my editor to automatically lay out keyword arguments in pipelines the way I show above, so I (gasp!) have to add indentation myself.

The examples used the flow_assertions and ecto_flow_assertions packages. The first depends only on the Elixir kernel. The second depends on Ecto and contains assertions about changesets and Ecto schema structs. Both packages use the Unlicense, so there's no barrier to copying the assertions you like and leaving the rest behind. I welcome contributions.

I went to some trouble to make the error output as good as the default ExUnit messages. It was straightforward, in contrast to other libraries I've tried to layer custom assertions on top of. My compliments to the ExUnit authors.

Last updated