Traditional TDD dicates that you write code like this:
- Write the smallest test possible that fails
- Write code to make the test pass
- Refactor while the test still passes
- Repeat
In practice, the TDD process doesn't always work out. You might waste too much time writing tests that don't actually matter. You might spend too much time mocking data. You might not actually know what test to write.
In my mind, there are several possibilities why TDD might not work for you:
- You don't know the question that you're trying to answer
- You can't mimick the setting necessary for meaningful tests
- You don't know what to test
The TDD process sounds good in theory, but maybe not in practice. This is my spin on practical TDD.
Please excuse any typos in this gist. :)
Whatever the case, it all comes down to the same point. To break away from a TDD blockage, you must modularize.
Now, "modular" is a word which you hear a lot, to the point where its meaning isn't necessarily consistent. Here, when I say "modular," I mean:
- Each component of your code does one thing
- They each do that one thing very reliably and accurately
- And they each component is small
This should remind you of two different schools of thought: the Unix philosophy and functional programming.
The Unix philosophy emphasizes compartmentalizing code by functionality. Discrete compartments of functionality are like building blocks. It's easier to build with robust blocks than with shapeless debris. Your code should resemble robust blocks, not shapeless debris. Each block (figuratively, not defined by white space or curly braces).
Functional programming emphasizes functions as tools for abstraction. Typically, when a programmer hears "functional programming," they think about functional purity. A pure function always gives the same outputs given the same inputs, and it doesn't change any values. Yes, that's important and relevant for testing. It actually makes testing much easier. However, the key idea is abstraction. If you can properly test a function, then you can simply treat that function as a block of functionality which can interact frictionlessly with the rest of your code. It aids with the Unix philosophy.
This means that modularity can be achieved by revolving your testing around functions. While this works best in languages that support first-class and higher-order functions, I don't mean to be firmly prescriptive about testing around functions. That's up for you to decide, but I'd still encourage function testing.
I love using food for my code examples. I'll continue that trend and use an example with a grocery shopping list. We're going to make a calculator which can tell us the price or weight of the groceries, optionally filtered by various criteria. I'll call it calculate
. We'll start with calculate
and the groceries
list.
var calculate = function() {
// Magic to be created!! Stay tuned.
};
var groceries = [
{ // total price: 3, total weight: 4.5
name: 'apples',
unit_price: 0.50,
unit_weight: 0.75,
units: 6,
category: 'fruit',
},
{ // total price: 3, total weight: 2
name: 'a dozen eggs',
unit_price: 3,
unit_weight: 2
units: 1,
category: 'protein'
},
{ // total price: 6, total weight: 4.5
name: 'asparagus',
unit_price: 2,
unit_weight: 1.5,
units: 3,
category: 'vegetable'
},
{ // total price: 8, total weight: 12
name: 'broccoli',
unit_price: 2,
unit_weight: 3,
units: 4,
category: 'vegetable'
}
];
Real value is created in integration tests, not unit tests. Unit tests are helpful, but the end-user ultimately wants software to work they way it's expected to work. In software, user experience is everything. So, our very first test should be concerned with addressing the user's goals.
This is where I defer from traditional TDD processes. I don't write a small, incremental test to fail. I write larger-scale "integration tests" which will fail. These integration tests show all use cases.
var total_cost = calculate(groceries,
['map', 'multiply by unit_price'],
['reduce', 'sum unit_price']
);
total_cost.should.equal(20);
var veggies_weight = calculate(groceries,
['filter', 'category is vegetable'],
['map', 'multiply by unit_weight'],
['reduce', 'sum weight']
);
veggies_weight.should.equal(16.5);
var cheap_food = calculate(groceries,
['map', 'multiply by unit_price'],
['filter', 'price less-than 4'],
['map', 'return just name']
);
cheap_food[0].should.equal('apples');
cheap_food[1].should.equal('a dozen eggs');
Immediately, several guiding thoughts should come to mind, just by looking at the tests:
- By writing large-scale integration tests, I can determine an api more naturally than by writing small-picture unit tests
- The only time I ever use 'reduce' is to find the sum, so that can be abstracted out (but this is beyond the scope of this gist)
- These tests naturally form a to-do list for unit tests to write
These tests won't pass for a while. I might comment them out until I'm ready for them to pass.
In my integration tests, I decided that the first arg of calculate
would be the collection of data, and the rest of them would be arrays with data to customize my calculations. You might think that the next part to test is the chain of custom functions, since visually that seems to be the next part in the flow of calculate
.
var result = calculate(mock_data,
['mock function 1'],
['mock function 2']
);
result.should.equal('something');
Yikes! Looks like maybe we don't know enough to actually test that either. This example unit test doesn't even come close to testing a chain. Our problem is that there's no simpler part we can test at this level. Now's a good time to go simple.
This is a good place to write a small unit test which will fail at first but can easily pass. Now, with some readme-driven-development in place, we can write purposeful unit tests. NOW is the time to go into traditional TDD.
var map = function() {
// Stay tuned!
};
var test_data = [
{ // total price: 16, total weight: 12
name: 'chicken breast',
unit_price: 4,
unit_weight: 3,
units: 4,
category: 'protein'
},
{ // total price: 6, total weight: 16
name: 'watermelon',
unit_price: 0.75,
unit_weight: 2,
units: 8,
category: 'fruit'
}
];
var total_price = map(test_data, 'multiply by unit_price');
total_price.should.have.length(1);
total_price[0].should.equal(22);
var total_weight = map(test_data, 'multiple by unit_weight');
total_weight.should.have.length(1);
total_weight[0].should.equal(28);
var categories = map(test_data, 'return just category');
categories.should.have.length(2);
categories[0].should.be('protein');
categories[1].should.be('fruit');
Nice. Notice that I write a handful of tests, not one. Those are a handful questions that I definitely want answered. In my opinion, there's little to no value in writing only one unit test at a time. In some cases, it's actually more helpful to write several unit tests and write code to make them all pass. The advantage is that you see more of the final picture.
Additionally, this list of unit tests is afforded by our readme-level tests. Cool! Those tests helped us effortlessly pick out some units.
var _ = require('underscore');
var map = function(collection, command) {
var words = command.split(' ');
var op = words[0];
var attr = words[2];
var iterator;
if (op === 'multiply') {
var newAttr = {
'unit_price': 'price',
'unit_weight': 'weight'
}[attr] || 'product';
iterator = function(item) {
item[newAttr] = item[attr] * item.units;
return item;
};
} else if (op === 'return') {
iterator = function(item) {
return item[attr];
};
} else {
iterator = _.identity;
}
var cloneIterate = _.compose(iterator, _.clone);
// cloning is to avoid mutating the input data
return _.map(collection, cloneIterate);
};
That should pass the map
tests. Now, there's a chance that you just won't like my implementation of map
at all. That's fine! Now's the point to refactor. Refactor to your heart's content so long that the tests still pass. This is finally a separate block of functionality which stands alone.
Like how we addressed map
, we can start addressing filter
.
var filter = function() {
// Stay tuned!
};
var cheap = filter(test_data, 'unit_price less-than 2');
cheap.should.have.length(1);
cheap[0].should.have.property('name', 'watermelon');
var heavy = filter(test_data, 'unit_weight greater-than 2');
heavy.should.have.length(1);
heavy[0].should.have.property('name', 'chicken breast');
var fruit = filter(test_data, 'category is fruit');
fruit.should.have.length(1);
heavy[0].should.have.property('name', 'watermelon');
These assertions should all fail. Now, let's flesh out filter
.
var filter = function(coll, command) {
var words = command.split(' ');
var attr = words[0];
var op = words[1];
var val = words[2];
var iterator;
if (op === 'greater-than') {
iterator = function(item) {
return item[attr] > val;
};
} else if (op === 'less-than') {
iterator = function(item) {
return item[attr] < val;
};
} else if (op === 'is') {
iterator = function(item) {
return item[attr] == val;
};
} else {
iterator = _.identity;
}
var cloneIterate = _.compose(iterator, _.clone);
// cloning is to avoid mutating the input data
return _.filter(collection, cloneIterate);
};
All the tests should pass now.
Now, to write the unit tests for reduce
.
var reduce = function() {
// Stay tuned!
};
var sum = reduce(test_data, 'sum units');
sum.should.equal(12);
And the code to make the code pass.
var reduce = function(coll, command) {
var words = command.split(' ');
var attr = words[1];
var iterator = (memo, item) {
return item[attr] + memo;
};
var cloneIterate = _.compose(iterator, _.clone);
return _.reduce(coll, cloneIterate);
};
Notice how my implementations of map
and filter
are very similar. If you wanted to make your code dryer, this could be a good opportunity to refactor. Go nuts.
Also notice that, in our current 'api' for calculate
, we only use reduce to find sums. If you'd like, you can reimplement the api accordingly. Here too, go nuts.
Now, that we have map
, filter
, and reduce
, we can flesh out the last test for calculate
which we commented out.
var functions = {
'map' : map,
'filter' : filter,
'reduce' : reduce,
};
var calculate = function(coll /*, fnCommands = function commands */) {
var fnCommands = _.rest(arguments);
var chain = _.reduce(fnCommands, function(target, fnInputs) {
var fnName = fnInputs[0];
var command = fnInputs[1];
var fn = functions[fnName];
return fn(target, command);
}, coll);
return chain();
};
This should pass all our integration tests! :P Success!!
I don't care what anybody else says about TDD. TDD is supposed to be a tool to help you. It's not supposed to be a process to hinder you. If it doesn't help you, don't use it. But if you're struggling to find how it can be useful for you, hopefully this can help.
The first objective to helpful TDD is, again, modularity. Everything should be a separate testable component, hopefully adhering to Unix philosophy and functional programming ideologies.
Next, write some high-level "readme-driven development" tests, some integration tests.
After all that, you can finally get into the traditional TDD steps. With a mindset for modularity and a roadmap with readme-level tests, TDD should be much easier.