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:
- 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… - It reads in the contents, and
- 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
becomesTestData::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.