Adding Test Data Through Metaprogramming

Note: Contrary to what you are about to read, I am still against metaprogramming on the whole, as it adds an unnecessary amount of magic that may confuse other developers. That being said, I would hate working in a language without it. Use sparingly, like junk food. "With great power…" Yadda yadda. Enjoy the article.

I am currently writing a gem to wrap the Cleanspeak API and I was using the JSON examples in my test cases that they supply in their API docs. I wrote the test cases like so:

class SystemApplicationTest < Test::Unit::TestCase
  def test_to_payload
    @obj_under_test = SystemApplication.new
    payload = JSON.parse(Data::JSON)
    assert_equal payload, @obj_under_test.to_payload
  end

  class Data
    JSON = <<-HEREDOC
      ... omitted ...
    HEREDOC
  end
end

After the 5th or 6th class that contains this same boilerplate duplication, my code started to smell and I wondered if there was a better way to load test data. Some of the problems I had with it are:

  • Fragile Tests: The HEREDOC clause must be carefully crafted. I'm imposing my fragile structure on novice developers, who can break the entire project with one typo.
  • No Separation of Concerns: Ruby and JSON are mixed into in the same file. This is a very smelly code smell.
  • Fragile Data: The JSON is never validated.
  • Unclear Specs: The JSON data lacks any syntax highlighting.
  • High Cognitive Load: Depending on the JSON being tested, the test file can get very long. No matter how readable the code is, a long file can become tiring for any developer to read.

What I wanted to do was separate the JSON into a separate file (with a .json extension, so that it can be syntax highlighted) and then, in my unit tests, call it in a similar manner without worrying about reading in a file. I also don't want to incur too much of an I/O penalty reading in the myriad JSON files needed by this gem.

This can be achieved through metaprogramming by creating constants dynamically as another module is included. First I create a directory called test/data that will contain the JSON files.

$ cd cleanspeak-ruby
$ mkdir test/data
$ cat > system_application.json <<EOF
... copy/paste the JSON data from inside the HEREDOC ...
EOF
$

Then I create a test/test_data.rb file to hold the metaprogramming. The TestData module has an ::included method that is called whenever another module uses include TestData in its body.

# test/test_data.rb
module TestData
  DATA_DIR_NAME = 'data'
  TEST_DATA_DIR = File.join(File.dirname(File.expand_path(__FILE__)), DATA_DIR_NAME)

  def TestData.included (mod)
    Dir.glob(File.join(TEST_DATA_DIR, '/*.json')).each do |file|
      name = File.basename(file, '.json')
      json = open(file).read
      TestData.const_set(name.upcase, json)
    end
  end
end

The design of the included method works like this:

  1. It looks for a data subdirectory and pulls a list of all the files with a .json file extension. With each file in the list of files…
  2. It reads in the contents, and
  3. Stores it in a constant on the TestData scope. The name of the constant is the name of the file in uppercase letters (i.e. system_application.json becomes TestData::SYSTEM_APPLICATION).

Then in my test/test_helper.rb file I include the TestData module into my Cleanspeak module. When the Ruby parser reaches the include statement, it will kick off the loading of JSON files into the module.

# test/test_helper.rb
require 'test/unit'

module Cleanspeak
  include TestData
end

Lastly, I can update the test cases to use the new test data module. I can also remove the Data class.

class SystemApplicationTest < Test::Unit::TestCase
  def test_to_payload
    payload = JSON.parse(TestData::SYSTEM_APPLICATION)
    # ... omitted ...
  end
end

As an added benefit, I can now use a tool like jsonlint to validate the JSON file.

$ npm install jsonlint
$ jsonlint -q test/data/system_application.json   # no message means OK
$

…or for those that don't like yak-shaving, here is the validation in pure Ruby.

$ ruby -e "require 'json'; JSON.parse(open('./test/data/system_application.json').read)"

Now go forth and separate your test data! Just remember: please drink metaprogram responsibly.