Tip 3Design with Tests
Brown Belt[​​Brown Belt] You may not start designing new code right up front, but you will soon enough.

Where our previous tip, Tip 2, Insist on Correctness, focused on making sure your code does what it’s supposed to do, here we focus on the meta-question, “What should this code do?”

On the surface, it would seem puzzling that a programmer would write code without knowing, well ahead of time, what it’s supposed to do. Yet we do it all the time. Faced with a problem, we charge off writing code and figure things out as we go. Programming is a creative act, not a mechanical one, and this process is akin to a painter charging off on a blank canvas without knowing exactly what the finished painting will look like. (Is this why so much code resembles a Jackson Pollock painting?)

Yet programming also requires rigor. Testing gives you tools for both design and rigor at the same time.

Designing with Tests

Thanks to frameworks for test doubles, discussed in Interactions, you can start with a big programming problem and start attacking it from whatever angle makes sense first. Perhaps your program needs to grab an XML file with customer statistics, wade through it, and produce summary stats of the data. You’re not sure offhand how to parse the XML, but you do know how to calculate the average customer age. No problem, mock the XML parsing and test the calculation:

AverageCustomerAge.rb
 
class​ TestCustomerStats < Test::Unit::TestCase
 
def​ test_mean_age
 
data =
 
[{:name => ​'A'​, :age => 33},
 
{:name => ​'B'​, :age => 25}]
 
CustomerStats.expects(:parse_xml).returns(data)
 
File.expects(:read).returns(nil)
 
 
stats = CustomerStats.load
 
assert_equal 29, stats.mean_age
 
end
 
end

Now you can write that code:

AverageCustomerAge.rb
 
class​ CustomerStats
 
def​ initialize
 
@customers = []
 
end
 
 
def​ self.load
 
xml = File.read(​'customer_database.xml'​)
 
stats = CustomerStats.new
 
stats.append parse_xml(xml)
 
stats
 
end
 
 
def​ append(data)
 
@customers += data
 
end
 
 
def​ mean_age
 
sum = @customers.inject(0) { |s, c| s += c[:age] }
 
sum / @customers.length
 
end
 
end

Confident that you have that part nailed, you can move on to parsing XML. Take a couple of entries out of the huge customer database, just enough to make sure you have the format right:

data/customers.xml
 
<customers>
 
 
<customer>
 
<name>​Alice​</name>
 
<age>​33​</age>
 
</customer>
 
 
<customer>
 
<name>​Bob​</name>
 
<age>​25​</age>
 
</customer>
 
</customers>

Next, here’s a simple test to validate the parsing:

AverageCustomerAge.rb
 
def​ test_parse_xml
 
stats = CustomerStats.parse_xml(
 
canned_data_from ​'customers.xml'​)
 
assert_equal 2, stats.length
 
assert_equal ​'Alice'​, stats.first[:name]
 
end

From there you can start picking apart the XML:

AverageCustomerAge.rb
 
def​ self.parse_xml(xml)
 
entries = []
 
doc = REXML::Document.new(xml)
 
 
doc.elements.each(​'//customer'​) ​do​ |customer|
 
entries.push({
 
:name => customer.elements[​'name'​].text,
 
:age => customer.elements[​'age'​].text.to_i })
 
end
 
 
entries
 
end

You have the flexibility to design from the top down, bottom up, or anywhere in between. You can start with either the part that’s riskiest (that is, what you’re most worried about) or the part you have the most confidence in.

Tests are serving several purposes here: first, they’re allowing you to move quickly since you can do hand-wavy mocking for your code’s interactions with outside components. “I know I’ll need to get this data from XML, but let’s assume some other method did that already.” Second, the tests naturally drive a modular style of construction—it’s simply easier to do it that way. Last, the tests stick around and ensure that you (or a future maintainer) don’t break something on accident.

Tests As Specification

At some point you have a good idea of what each function should do. Now is the time to tighten down the screws: what precisely should the function do in the happy path? What shouldn’t it do? How should it fail? Think of it as a specification: you tell the computer—and the programmer who needs to maintain your code five years from now—your exact expectations.

Let’s start with an easy example, a factorial function. First question: what should it do? By definition, factorial n is the product of all positive integers less than or equal to n. Factorial of zero is a special case that’s one. These rules are easy enough to express as Ruby unit tests:

Factorial.rb
 
def​ test_valid_input
 
assert_equal 1, 0.factorial
 
assert_equal 1, 1.factorial
 
assert_equal 2, 2.factorial
 
assert_equal 6, 3.factorial
 
end

In choosing values to test, I’m testing the valid boundary condition (zero) and enough values to establish the factorial pattern. You could test a few more, for the sake of illustration, but it’s not strictly necessary.

The next question to ask is, what’s invalid input? Negative numbers come to mind. So do floats. (Technically there is such a thing as factorial for noninteger numbers and complex numbers,[5] but let’s keep this simple.) Let’s express those constraints as well:

Factorial.rb
 
def​ test_raises_on_negative_input
 
assert_raise(ArgumentError) { -1.factorial }
 
end
 
 
def​ test_factorial_does_not_work_on_floats
 
assert_raise(NoMethodError) { 1.0.factorial }
 
end

I chose to raise an ArgumentError exception for negative integers and let Ruby raise a NoMethodError for calling factorial on objects of any other type.

That’s a reasonably complete specification. In fact, from there the code itself pretty much writes itself. (Go ahead, write a factorial function that passes the tests.)

Over-Testing

When programmers start unit testing, a common question arises: what are all the values I need to test? You could test hundreds of values for the factorial function, for example, but does that tell you anything more? No.

Therefore, test what’s needed to specify the behavior of the function. That includes both the happy path and the error cases. Then stop.

Aside from wasting time, do additional tests do any harm? Yes:

  • Unit tests are valuable as a specification, so additional clutter makes it hard for the reader to discern the important parts of the specification from needless fluff.

  • Every line of code is potentially buggy—even test code. Debugging test code that doesn’t need to be there is a double waste of time.

  • If you decide to change the interface to your module, you have more tests to change as well.

Therefore, write only the tests you need to verify correctness.

Further Reading

Growing Object-Oriented Software, Guided by Tests [FP09] has extensive coverage of the design process with TDD and mocking.

As before, Ruby programmers will benefit tremendously from The RSpec Book [CADH09].

If it occurred to you that “tests as specifications” sounds an awful lot like inductive proofs, you’re right. You can read a lot more about inductive proofs in The Algorithm Design Manual [Ski97].

Actions

In the beginning of this tip, we used some data encoded in XML. This is a very common task in industry, so it’s useful to practice with loading and saving XML.

Start with a very simple structure, like the previous customer list snippet. Use a prebuilt parser, like REXML for Ruby, for the actual parsing, because you’ll want to stick to the issues of what to do with the parser’s results. Before you run off and write any code, think of tests you’d construct for a function that loads that XML:

  • What happens when there are no customers in the list?

  • How should you handle a field that’s blank?

  • What about invalid characters, like letters in the age field?

With those questions answered and expressed as tests, now write the loader function.

Bonus round: build some tests for manipulating the customer list and saving it back to a file. You can use an XML generator like Builder for Ruby.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.222.32.67