Good code tells a story.
Some code describes entities—perhaps you're designing objects and classes, or perhaps you're declaring data structures and types. Your code describes what you're working with, its essential attributes, and what you expect to do with it.
Other code describes rules and interactions. Perhaps you're writing methods or business rules or even grammars. Your code demonstrates what you're doing to your entities and, if you're very good, why you're doing it. Great programmers aspire to writing descriptive code, where what happens and why is so obvious that even novices can understand the details at a high level.
If you're like me, you might prefer to let high-level design emerge from the interaction of smaller components. You may have heard of the rule "Once, twice, refactor", which suggests that when you notice you're writing similar code for the third time, you unify all three elements into a single abstraction you can reuse. I try to follow that rule, because usually the requirements are solid enough at that point that I can extract a useful and usable abstraction from the concrete implementations.
I try to let that rule guide my designs in the large—not that I ignore large designs (I have lots of experience from which to draw, of course)—but that every problem is a little bit different. In a sense, my experience is a catalog of ways I've developed the APIs I use to solve each individual problem. Software design at that level is an exercise in producing usable APIs tuned to the specific problem domain.
(Software patterns, in the original sense, are a way of identifying the similar elements while allowing for local differences because of individual details.)
The same strategy applies to tests.
Where the basic unit of computation may be something like an if
condition (give me an if
statement and a way to read from and
write to memory and I can eventually recreate a useful programming language),
the basic unit of testing is probably the ok()
function. It's a
boolean assertion. It's true or false. Either the test passes or it
doesn't.
Everything built on top of that ok()
or
assertTrue()
or whatever your preference is an abstraction, and
abstractions await discovery.
When Schwern and I discovered Test::Builder, we
extracted that central behavior—ok()
—from multiple
places into a single entity of abstraction that many multiple places could
share. When I use Test::Class or Test::Routine to share
behavior between individual tests, I do that because it affords
abstraction.
For the same reason I've invested time recently in making my individual tests read as clearly as possible—as clear as the rest of my code, if not clearer:
#!/usr/bin/env perl
use Modern::Perl;
use Test::More;
use MyApp::App::DedupEntries;
use MyApp::States ':entry';
use lib 't/lib';
use TestDB qw( init_entry get_schema );
exit main( @ARGV );
sub main
{
test_entry_dedup_all_dupes();
test_entry_dedup_some_dupes();
test_entry_dedup_near_dupes();
test_entry_dedup_far_dupes();
done_testing;
return 0;
}
I wrap individual tests in functions (or methods, depending on the test library). Each group of assertions has a name. Every assertion has a description. I often/usually extract setup and teardown code from test functions and methods into helpers so as to produce a named API for individual tests.
This makes tests easier to write and to manage, but it also makes them easier to write and to maintain and to debug.
(I've given tests to other developers to show them how to use APIs I've developed in the regular code.)
Testing has helped me improve my code measurably over the past decade in terms of quality and efficacy. Treating test code like I'd treat any other code has made a difference in my ability to write and maintain coherent, useful, and usable tests. It's perhaps the most effective way to sharpen your tools.
(You do need to recognize a greater need for simplicity in your tests, however, but that's a subject for another article.)