Picture of Jim Nanney

Jim Nanney

Perpetual Student of Software Craftsmanship

TDD Your Rakefile

TLDR - Rake Tasks can be broken into objects, objects can be easily TDD’d.

Sometimes I forget that my best work occurs when I forego the cowboy coding, or unplanned unit of work because I already know how I want to implement something. That stupid lesson I keep having to learn over and over again is about testing first, driving design from need instead of gut feeling.

Rakefiles tend to give me a junk drawer of untested code I use consistantly but without tests. (Thanks to @sarahmei for the perfect wording here)

The thing is that no matter what I allow my gut to feel, or my instinct to drive, I have to abide by processes. Processes provide repeatability, both in results and in how I accomplish those results. TDD provides an awesome process to drive a design. This doesn’t mean I should never deviate, but as Sandi Metz stated, follow the rules until you understand why you should not.

Rakefiles consists of Tasks and Rules, in this post I’ll address tasks. In a later followup, I’ll go into detail on testing rules.

Testing Tasks

So the thing that I was working on that brought this about was a single task. The task was to get a list of files that are different in my git repository from what is located in my work’s PVCS repository and insert it into a word document for our Configuration Management team. The task is repetitive and enough of a pain that once I was able to install Ruby on my work desktop, I immediately wanted to change it to a Rake task.

Before writing the tests I started with a spike (or for those who don’t know my code word, I did it without writing tests first).

Here was the task as it stood at first.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace :pvcs do
  desc "Get repo differences from pvcs files"
  task :localchanges do
    added, removed, modified = [[], [], []]
    `git --work-tree=#{filepath} status --short`.split("\n").each do |f|
      marker, file = f.split(" ", 2)
      added << file if marker === "D"
      removed << file if marker === "??"
      modified << file if marker === "M"
    end
    puts "Added\n#{added.join("\n")}\n\n" if added.any?
    puts "Modified\n#{modified.join("\n")}\n\n" if modified.any?
    puts "Removed\n#{removed.join("\n")}\n\n" if removed.any?
  end
end

Yes, this functions well, and doesn’t insert anything into a word document. This is the point where I realized what I was doing was in desperate need of TDD and not cowboy coding. So let’s try this. My first (unsuccessful) attempt was to reuse the above code and just write a test. Without going into too much depth, it is very hard to setup a test repository with a checked out working copy and then running rake to verify that the rake task works.

My next approach was a somewhat more sane approach. It dawned on me that everything within the task do end block needed to be broken out into it’s own object. Objects are infinitely more testable than testing the rake task. My side note here is that it also dawned on me that testing the fact that the task runs is similar to testing rails generators. Basically, rake has a maintainer, and it isn’t me.

So breaking down the task contents into a TDD’d object is a little simpler.

The TDD process is fairly simple: Write a test. Then write just enough code to make the test pass. Afterward refactor the code. And finally refactor the test. Below is the resulting tests.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
require 'minitest/autorun'
require_relative 'file_changes'

class FileChangesTest < MiniTest::Unit::TestCase
 def test_has_added
   classifier = FileChanges.new
   assert_respond_to(classifier, :added)
 end

 def test_has_removed
   classifier = FileChanges.new
   assert_respond_to(classifier, :removed)
 end

 def test_has_modified
   classifier = FileChanges.new
   assert_respond_to(classifier, :modified)
 end

 def test_classifies_added_files
   filelist = " D Rakefile\n D file_changes_test.rb"
   classifier = FileChanges.new
   classifier.classify(filelist)
   assert_equal classifier.added, ["Rakefile", "file_changes_test.rb"]
 end

 def test_classifies_removed_files
   filelist = "?? Rakefile\n?? file_changes_test.rb"
   classifier = FileChanges.new
   classifier.classify(filelist)
   assert_equal classifier.removed, ["Rakefile", "file_changes_test.rb"]
 end

 def test_classifies_modified_files
   filelist = "M Rakefile\nM file_changes_test.rb"
   classifier = FileChanges.new
   classifier.classify(filelist)
   assert_equal classifier.modified, ["Rakefile", "file_changes_test.rb"]
 end

 def test_classifies_nothing_when_empty
   filelist = ""
   classifier = FileChanges.new
   classifier.classify(filelist)
   assert_empty classifier.added
   assert_empty classifier.removed
   assert_empty classifier.modified
 end

 def test_classifes_nothing_when_unknown_marker
   filelist = " C Rakefile\n C file_changes_test.rb"
   classifier = FileChanges.new
   classifier.classify(filelist)
   assert_empty classifier.added
   assert_empty classifier.removed
   assert_empty classifier.modified
 end

end

And with these tests the new FileChanges class becomes a tested task I can add to the Rakefile.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class FileChanges
  attr_reader :modified, :removed, :added

  def initialize
    @modified = []
    @removed = []
    @added = []
  end

  def diff(filepath)
    `git --work-tree=#{filepath} status --short`.split("\n")
  end

  def self.find(path)
    FileChanges.new.tap do |m|
      m.diff(path).each {|f| m.classify(f)}
    end
  end

  def classify(line)
    marker, file = line.split(" ", 2)
    modified << file if marker === "M"
    added << file if marker === "D"
    removed << file if marker === "??"
  end

  def to_s
    puts "Modified:\n#{modified.join("\n")}\n\n" if modified.any?
    puts "Added:\n#{added.join("\n")}\n\n" if added.any?
    puts "Removed:\n#{removed.join("\n")}\n\n" if removed.any?
  end
end

And the resulting rake task is much simpler, and now has tested code driving it.

1
2
3
4
5
6
7
desc "PVCS related tasks"
namespace :pvcs do
  desc "List files that are different from what is stored in the PVCS reference Directory"
  task :localchanges do
    FileChanges.find("PVCS Path").to_s
  end
end

This makes the task a single line and gives me test coverage for the task in the Rakefile.

Hopefully this illustrates how I test rake tasks. Trying to test the functionality of rake along with the task is hard and a good indicator that I was testing the wrong thing.

The tests guided a refactor from an outside in approach. I could have improved upon the tests by stubbing the diff method, but I thought it better to show my honest work.