DEV Community

Josh
Josh

Posted on • Edited on

Love Potion `number == 9` | How Elixir Completely Changed My Relationship With Testing

Back in 2012, I interviewed for a company that emphasized TDD as their primary feature development strategy. At that time, I had been professionally developing software for just under a year, was terrified of JavaScript and the ability one had to just do things with it the moment they opened up Developer Tools, had written five test assertions in total – if that – thanks to the very last lab module I had as part of my computer science minor, and definitely completely lied about knowing what TDD was. But I decided I wouldn't bring up how nervous I was about the fact that we were writing test cases before even knowing what the shape of the code would be. I was just going to power through the interview, and do my best to pick up on the patterns as I was exposed to them.

The day after my flight back home, the phone call came through: I'd gotten the job. And I was incredibly grateful that being able to "fake it til you make it" gave me the opportunity to learn from some of the most driven developers, the most diligent testers, the most dedicated support representatives, and the most detail-oriented designers I've worked with in my career.

The job I had working on Pivotal Tracker taught me so many important lessons, about code and about life, but I lament only letting one fall by the wayside: How to do test-driven development. It is a skill that requires constant practice and upkeep, and it's all too easy for people in our profession to forget the importance of keeping our goal in mind, in favor of hitting the ground running and building whatever we can.

That completely changed once I discovered Elixir. The other day, I implemented an answer to a daily coding challenge in the language. I'll copy the code I wrote for that challenge here, so as to keep the focus of this post all actually in this post:

defmodule W do
  @doc ~S"""
    Takes in a string and returns a list of variations of that string, each containing
  one capitalized letter from the string, in sequence, starting from the left.

  ## Examples

      iex>W.ave("hello.")
      ["Hello.", "hEllo.", "heLlo.", "helLo.", "hellO.", "hello."]

      iex>W.ave("Hello World!")
      ["Hello world!", "hEllo world!", "heLlo world!", "helLo world!", "hellO world!",
       "hello World!", "hello wOrld!", "hello woRld!", "hello worLd!", "hello worlD!"]
  """
  def ave(string) do
    string
      |> String.downcase
      |> String.to_charlist
      |> do_wave # private function
  end

  defp do_wave(charlist, waveform \\ [], waved \\ '') # no `do` block because this signature is used to define defaults
  # ...
end

This code takes an input string, downcases it, and then converts it to an older format used by Erlang known as a charlist (which are made using single-quotes), and processes that list to generate the series of strings that were asked for in the challenge description. Now, let's look at the testing code for this module:

# ./test/w_test.exs
defmodule WTest do
  use ExUnit.Case, async: true
  doctest W
end

Considerably less to look at, right? Let's break down what's going on here:

  • first, I'm declaring the test module name with defmodule. defmodule is kind of synonymous to class declarations in object-oriented languages, except since there are no objects in Elixir, all of the functions and macros you define in a module exist within, and are called directly from, the module itself, like String.length(string); you never call a method directly from any of your variables or data structures in Elixir! Anyway, it's convention to usually name your test modules "Test", to keep everything simple
  • The next line, use ExUnit.Case, async: true, pulls in code from one of the modules from ExUnit, Elixir's built-in testing framework. use is a keyword (specifically, it's a special form) that tells the compiler to import ExUnit.Case and load its functions and macros so they can be used in this module, as well as to run a block of code that performs some extra functions. We pass async: true so that these tests can run concurrently to ones in other testfiles, which speeds up test runs, and allows for a greater degree of random order of execution
  • The penultimate line in this file, doctest W, tells the test runner … 🤔


You know what? I've completely gotten ahead of myself 😐 let's run this testfile.

2019-07-07 19:07:07 ⌚ ruby 2.6.3p62 Newtons-Mac in ~/workspace/elixir/joywave
±  |master ✓| → mix test test/w_test.exs 
Compiling 1 file (.ex)
.

  1) doctest W.ave/1 (1) (WTest)
     test/ext/w_test.exs:3
     Doctest failed
     doctest:
       iex>W.ave("Hello.")
       ["Hello.", "hEllo.", "heLlo.", "helLo.", "hellO.", "hello."]
     code:  W.ave("Hello.") === ["Hello.", "hEllo.", "heLlo.", "helLo.", "hellO.", "hello."]
     left:  ["Hello.", "hEllo.", "heLlo.", "helLo.", "hellO."]
     right: ["Hello.", "hEllo.", "heLlo.", "helLo.", "hellO.", "hello."]
     stacktrace:
       lib/joywave/w.ex:8: W (module)

The magic you just witnessed, is called the examples in W.ave/1's documentation of how to invoke it were just run as actual tests. Elixir treats documentation as a first-class citizen of the language, and that's no more apparent than when the exact same usage examples you provide in your function documentation are unit specs that will be run as just another part of your suite.

This fact changed the entire way I've thought about unit specs, about documentation, and about test-driven development as a practice. Frameworks like Minitest and RSpec come with their own domain-specific language, which requires some time to learn and understand in and of themselves.

And yes, some of the more powerful features of those frameworks can't be replaced, but being able to demonstrate to consumers of your library how to use a given function, or a given language paradigm, at the same time that you're demonstrating to yourself that your code does what it's supposed to, eliminates so much friction when it comes to writing tests for your code. It made it actually possible for me to consider what the output of my code should be before thinking of the implementation. It made me care about when my documentation didn't align with the actual behavior of what the code was doing. It made me want to write documentation; it made me want to documentate ALL the APIs.

It made me want to tell spellchecker that it's not queen of the world, criticizing my awesome new word "documentate".

There are practices worth investing time in when it comes to anything you make your living doing. Test suites are essential for catching regressions, and TDD and exploratory testing help you solidify your user experience and harden your code against edge cases, before both become a soupy murk of uncertain call procedures and thickets upon thickets of uncharted behavior, respectively. Documentation may seem like a hassle, but if you ever want engineers to understand how to use your framework, library, or API, you need to be able to tell them how to use your framework, library, or API.

If you find yourself struggling with test-driven development, with testing in general, with writing documentation, or with understanding the nuances of your company's preferred testing framework, see if your language has a package or module for running doctests. If they don't, consider learning Elixir. It's got some incredibly fun features, some of the best reference material I've seen in my life, and it's powered by one of the best runtime environments for concurrent, fault-tolerant programming in the entire industry. On top of that, you'll probably find yourself learning some great patterns for building reliable, more readable code in all of your known languages.

Now go forth, and dev greatly ✨🐶✨

Top comments (2)

Collapse
 
erlangsolutions profile image
Erlang Solutions

This is a fantastic article Josh, we are sharing a link from our Twitter.

Collapse
 
cam profile image
Cam Stuart

This is a cool feature/way to work , and great article too :)