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
defmoduleis 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.
useis a keyword (specifically, it's a special form) that tells the compiler to
import ExUnit.Caseand 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: trueso 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 ✨🐶✨