Test-Driven Development

save your time, save your sanity, write great code fast

Yeah, but TDD…

… adds meaningless overhead

… interrupts my workflow

… slows me down

… anyway, I already have regression tests

Ah, But the Data!

So much data!

Just not in this talk.

Yeah, but TDD buggy, untested code

… adds useless overhead

… interrupts my workflow

… slows me down

anyway, I already have forces me to write regression tests for code I don't necessarily remember

So much overhead


npm install --save-dev jest
        

It's at least that easy in your development environment of choice.

There's a framework for COBOL.

What is a Unit Test?

A unit test is an automated piece of code that invokes a unit of work in the system and then checks a single assumption about the behavior of that unit of work.
(Roy Osherove)

The TDD Cycle

  1. Write a test that exposes the next piece of missing or incorrect functionality.
  2. Write the simplest production code that makes this test pass.
  3. Confirm all tests pass
  4. Refactor until you like the code (Osherove again)

A "simple" first test



test('high noon', () => {
    const time = '12:00:00';
    const expected = [
        [
            "*it*", "*is*", "half", "ten"
        ],
        [
            "quarter", "twenty"
        ],
        [
            "five", "minutes", "to"
        ],
        [
            "past", "one", "three"
        ],
        [
            "two", "four", "five"
        ],
        [
            "six", "seven", "eight"
        ],
        [
            "nine", "ten", "eleven"
        ],
        [
            "*twelve*", "*o'clock*"
        ]
    ];

    expect(
        clockwork.highlightTime(time))
        .toStrictEqual(expected);
});

Make the test pass as simply as possible

No, really.


highlightTime() {
    return [
        [
            "*it*", "*is*", "half", "ten"
        ],
        [
            "quarter", "twenty"
        ],
        [
            "five", "minutes", "to"
        ],
        [
            "past", "one", "three"
        ],
        [
            "two", "four", "five"
        ],
        [
            "six", "seven", "eight"
        ],
        [
            "nine", "ten", "eleven"
        ],
        [
            "*twelve*", "*o'clock*"
        ]
    ];
}
        

Was that a waste of time? No.

I don't like this interface.

I certainly don't like testing it.

Writing tests means more calls, sooner, to the method in question.

Little annoyances become noticeable right away - this technical debt had a lifespan of minutes.

One thing at a time

Only test the time-to-words conversion for now, don't worry about the display.


const time = '12:00:00';
const expected = [
    "it", "is", "twelve", "o'clock"
];

expect(clockwork.timeWords(time))
    .toStrictEqual(expected);
        

One thing at a time

Only test the time-to-words conversion for now, don't worry about the display.


module.exports = {
    timeWords() {
        return [
            "it", "is", "twelve", "o'clock"
        ];
    }
}
        

One more interface adjustment


--- a/trivial.test.js
+++ b/trivial.test.js
@@ -1,7 +1,7 @@
 test('high noon', () => {
-    const time = '12:00:00';
+    const time = '12:00';
        

Let's get slightly less dumb


test('another hour', () => {
    const time = '10:00';
    const expected = [
        "it", "is", "ten", "o'clock"
    ];

    expect(clockwork.timeWords(time))
        .toStrictEqual(expected);
});
        

FAIL

Fix the new test, don't break anything


const hours = [
    "twelve", "one", "two", "three",
    "four", "five", "six", "seven",
    "eight", "nine", "ten", "eleven"
];

module.exports = {
    timeWords(timestring) {
        const parts = timestring.split(':');
        const hour = Number(parts[0]);
        const hourStr = hours[hour % 12];

        return [
            "it", "is", hourStr, "o'clock"
        ];
    }
}
        

On green days, we refactor


function timeWords(timestring) {
    const parts = parseTime(timestring);
    const hour = parts[0];

    const hourStr = hours[hour % 12];

    return [
        "it", "is", hourStr, "o'clock"
    ];
}

function parseTime(timestr) {
    return timestr
        .split(':')
        .map(numstr => Number(numstr));
}

module.exports = {
    timeWords
}
        

Add some coverage


test('midnight', () => {
    const time = '00:00';
    const expected = [
        "it", "is", "twelve", "o'clock"
    ];
//...
test('handle PM times', () => {
    const time = '22:00';
    const expected = [
        "it", "is", "ten", "o'clock"
    ];
        

Let's Fail Some More!


const time = '12:05';
const expected = [
    "it", "is", "five", "minutes", "past", "twelve"
];
        

FAIL

Handle minutes, with minimal changes


-    return [
-        "it", "is", hourStr, "o'clock"
-    ];
+    if (minute == 0)
+    {
+        return [
+            "it", "is", hourStr, "o'clock"
+        ];
+    }
+    else {
+        const minuteStr = numberWords[minute];
+
+        return [
+            "it", "is", minuteStr, "minutes", "past", hourStr
+        ];
+    }
             

But is this a general fix?


test('twenty past the hour', () => {
    const time = '12:20';
    const expected = [
        "it", "is", "twenty", "minutes", "past", "twelve"
    ];
        
Failing test, expected 'twelve' saw 'undefined'

Fix that, quick 'n dirty.


function numberWords(num) {
    const words = [
        "twelve", "one", "two", "three",
        "four", "five", "six", "seven",
        "eight", "nine", "ten", "eleven"
    ];

    if (num == 20) {
        return "twenty";
    }

    return words[num];
}
        

We should only think in fives


    const time = '12:06';
    const expected = [
        "it", "is", "five", "minutes", "past", "twelve"
    ];
        

FAIL

Can we fix it?


const minute = roundMinutes(parts[1]);
// ...
function roundMinutes(minutes) {
    return minutes - minutes % 5;
}
        

Yes we can!

What about quarter past?


test('quarter past the hour', () => {
    const time = '12:15';
    const expected = [
        "it", "is", "quarter", "past", "twelve"
    ];

    expect(clockwork.timeWords(time)).toStrictEqual(expected);
});
        

Umm, actually, "quarter" is not even a number.

More corners! More cases!


function minuteWords(minutes) {
    if (minutes == "20") {
        return ["twenty", "minutes"];
    }
    else if (minutes == 15) {
        return ["quarter"];
    }
    else {
        return [numberWords(minutes), "minutes"];
    }
}
        

Half-past?


const time = '12:30';
const expected = [
    "it", "is", "half", "past", "twelve"
];
        

FAIL

More like ALL PASSED


     else if (minutes == 15) {
         return ["quarter"];
     }
+    else if (minutes == 30) {
+        return ["half"];
+    }
     else {
         return [numberWords(minutes), "minutes"];
     }
             

25 past


test('twenty-five past the hour', () => {
    const time = '12:25';
    const expected = [
        "it", "is", "twenty", "five", "minutes", "past", "twelve"
    ];

    expect(clockwork.timeWords(time)).toStrictEqual(expected);
});
        

FAIL

Surprise!


     else if (minutes == 15) {
         return ["quarter"];
     }
+    else if (minutes == 25) {
+        return ["twenty", "five", "minutes"];
+    }
     else if (minutes == 30) {
         return ["half"];
     }
        

Sometimes things happen BEFORE the hour


test('five to one', () => {
    const time = '12:55';
    const expected = [
        "it", "is", "five", "minutes", "to", "one"
    ];

    expect(clockwork.timeWords(time)).toStrictEqual(expected);
})
        

FAIL

Handled


function timeWords(timestring) {
    const parts = parseTime(timestring);
    let hour = parts[0] % 12;
    let minute = roundMinutes(parts[1]);

    if (minute == 0)
    {
        return [
            "it", "is", numberWords(hour), "o'clock"
        ];
    }
    else if (minute > 30) {
        ++hour;
        minute = 60 - minute;

        const minuteStr = minuteWords(minute);

        return ["it", "is"]
            .concat(minuteStr)
            .concat(["to", numberWords(hour)]);
    }
    else {
        const minuteStr = minuteWords(minute);

        return ["it", "is"]
            .concat(minuteStr)
            .concat(["past", numberWords(hour)]);
    }
}
        

Let's tidy up a bit


function timeWords(timestring) {
    const parts = parseTime(timestring);
    let hour = parts[0] % 12;
    let minute = roundMinutes(parts[1]);

    if (minute == 0)
    {
        return [
            "it", "is", numberWords(hour), "o'clock"
        ];
    }
    else {
        let direction = "past";

        if (minute > 30) {
            direction = "to";
            ++hour;
            minute = 60 - minute;
        }

        return ["it", "is"]
            .concat(minuteWords(minute))
            .concat([direction, numberWords(hour)]);
    }
}
        

That's nice, but...

Remember this?

We want stars


test('highlight in order', () => {
    const timewords = ["it", "is", "twelve", "o'clock"];
    const expected = [
        ["*it", "*is", "half", "ten"],
        ["quarter", "twenty"],
        ["five", "minutes", "to"],
        ["past", "one", "three"],
        ["two", "four", "five"],
        ["six", "seven", "eight"],
        ["nine", "ten", "eleven"],
        ["*twelve", "*o'clock"]
    ];

    expect(highlights(timewords)).toStrictEqual(expected);
});
        

FAIL

All good on stars, thx.


const theBoard = [
    ["it", "is", "half", "ten"],
    ["quarter", "twenty"],
    ["five", "minutes", "to"],
    ["past", "one", "three"],
    ["two", "four", "five"],
    ["six", "seven", "eight"],
    ["nine", "ten", "eleven"],
    ["twelve", "o'clock"]
];

function highlights(timeWords) {
    const result = [];
    const remaining = timeWords.slice(0);
    let nextWord = remaining.shift();

    theBoard.forEach((row) => {
        const newRow = [];

        row.forEach((word) => {
            if (word === nextWord) {
                newRow.push(`*${word}`);
                nextWord = remaining.shift();
            }
            else {
                newRow.push(word);
            }
        });

        result.push(newRow);
    });

    return result;
}
        

Real talk. How do we use this?

Some simple client code:

Client code


setInterval(() => {
    const now = new Date();
    const timeStr = `${pad(now.getHours)}:${pad(now.getMinutes())}`;
    const words = timeWords(timeStr);
    const highlighted = highlights(words);

    showWords(highlighted);
}, 100);

function showWord(word, line) {
    let newWord = null;

    if (isHighlighted(word)) {
        newWord = document.createElement('b');
        newWord.innerHTML = getText(word);
    }
    else {
        newWord = document.createTextNode(getText(word));
    }

    line.appendChild(newWord)
}        

How should isHighlighted work?

Well, when something is highlighted...


test('is highlighted', () => {
    expect(isHighlighted('*foo')).toBe(true);
});
        

FAIL

We get to be dumb again


function isHighlighted() {
    return true;
}
         

Until we don't


test('is not highlighted', () => {
    expect(isHighlighted('foo')).toBe(false);
});
        

FAIL

Easy enough


function isHighlighted(word) {
    return word.slice(0, 1) === '*';
}
         

getText() is trivial...

(for non-highlighted text)


test('get non-highlighted text', () => {
    expect(getText('foo')).toEqual('foo');
});
        

function getText(word) {
    return word;
}
       

Only slightly more difficult...


test('get highlighted text', () => {
    expect(getText('*foo')).toEqual('foo');
});
        

FAIL

...to work in either case


function getText(word) {
    if (isHighlighted(word)) {
        return word.slice(1);
    }
    else {
        return word;
    }
}
        

Up and running!

Don't make the client think

We add a formatTime() function, and don't make the client code do the work


test('format noon', () => {
    const noon = new Date();
    noon.setHours(12);
    noon.setMinutes(0);

    expect(formatTime(noon)).toEqual('12:00');
});
        

function formatTime(dateTime) {
    return `${pad(dateTime.getHours())}:${pad(dateTime.getMinutes())}`;
}

function pad(num) {
    return `0${num}`.slice(-2);
}
        

Yeah, but...

None of this helps me make broad changes.

You could not be more wrong. You could try, but you would not be successful.

¡tengo una idea!

Let's build a factory


+const testClock = GetClockwork('en');

//...

-    expect(highlights(timewords))
-       .toStrictEqual(expected);
+    expect(testClock.highlights(timewords))
+       .toStrictEqual(expected);
        

Let's build a factory


     module.exports = {
-        timeWords,
-        highlights,
-        isHighlighted,
-        getText,
-        formatTime
+        GetClockwork(language) {
+            return {
+                timeWords,
+                highlights,
+                isHighlighted,
+                getText,
+                formatTime
+            };
+        }
     };
         

Time to fail


const testClock = GetClockwork('es');

test('noon', () => {
    const time = '12:00';
    const expected = [
        "son", "las", "doce"
    ];

    expect(testClock.timeWords(time)).toStrictEqual(expected);
});
        

FAIL

Be moderately brave


-function timeWords(timestring, numberText) {
+function timeWords(timestring, numberText, prefix, suffix) {
     const parts = parseTime(timestring);
     let hour = parts[0] % 12;
     let minute = roundMinutes(parts[1]);

     if (minute === 0) {
-        return [
-            "it", "is", numberWords(hour, numberText), "o'clock"
-        ];
+        const results = prefix
+            .concat(numberWords(hour, numberText));
+
+        if (suffix) {
+            return results.concat(suffix);
+        }
+        else {
+            return results;
+        }
     }
     else {
         let direction = "past";
@@ -138,9 +142,43 @@ function GetClockwork(language) {
             "eleven"
     ];

+    let prefix = ["it", "is"];
+    let suffix = ["o'clock"];
+
+    if (language === "es") {
+        numberText = [
+            "doce",
+            "uno",
+            "dos",
+            "tres",
+            "cuatro",
+            "cinco",
+            "seis",
+            "siete",
+            "ocho",
+            "nueve",
+            "diez",
+            "once"
+        ];
+
+        theBoard = [
+            ["es", "son", "la", "las", "uno"],
+            ["dos", "tres", "cuatro"],
+            ["cinco", "seis", "siete"],
+            ["ocho", "nueve", "diez"],
+            ["once", "doce", "y", "menos"],
+            ["cuarto", "media"],
+            ["cinco", "diez", "veinte"],
+            ["veinticinco"]
+        ];
+
+        prefix = ["son", "las"];
+        suffix = null;
+    }
+
     return {
         timeWords: (timestr) => {
-            return timeWords(timestr, numberText);
+            return timeWords(timestr, numberText, prefix, suffix);
         },
        

Quantities of time

Spanish has singular/plural time distinctions that English does not.

Son las doce, but es la una. (roughly "they are twelve", "it is one"). We don't know how to do that yet.


test('one is prefixed with "la"', () => {
    const time = '1:00';
    const expected = [
        "es", "la", "una"
    ];
    expect(testClock.timeWords(time))
        .toStrictEqual(expected);
        

FAIL

Now we do.


let prefixes = [
    ["it", "is"]
];

if (language === "es") {
    prefixes = [
        ["es", "la"],
        ["son", "las"]
    ];
}

// ...

function timeWords(timestring, numberText, prefixes, suffix) {
    // ...
    const singular = 0;
    const plural = prefixes.length === 1 ? 0 : 1;
    const usedPrefix = hour == 1 ? singular : plural;
        

Minutes come in different orders


test('1:10 es la una y diez', () => {
    const time = '1:10';
    const expected = [
        "es", "la", "una", "y", "diez"
    ];
        

FAIL

Fix the minutes-after case


-   let direction = "past";
+   let direction = "y";

    if (minute > 30) {
-       direction = "to";
+       direction = "menos";
        ++hour;
        minute = 60 - minute;
    }

    return this.prefixes[usedPrefix]
-       .concat(this.minuteWords(minute))
-       .concat([direction, this.numberWords(hour)]);
+       .concat([this.numberWords(hour), direction])
+       .concat(this.minuteWords(minute));
        

And before the hour?


test('12:45 es la una menos quarto', () => {
    const time = '12:45';
    const expected = [
        "es", "la", "una", "menos", "cuarto"
    ];

     expect(testClock.timeWords(time)).toStrictEqual(expected);
 });
        

FAIL


let direction = "y";
if (minute > 30) {
    direction = "menos";
    ++hour;
    minute = 60 - minute;
}

        

etc...


test('half past the hour', () => {
    const time = '12:30';
    const expected = [
        "son", "las", "doce", "y", "media"
    ];

    expect(testClock.timeWords(time)).toStrictEqual(expected);
});

test('twenty-five past the hour', () => {
    const time = '12:25';
    const expected = [
        "son", "las", "doce", "y", "veinticinco"
    ];

    expect(testClock.timeWords(time)).toStrictEqual(expected);
});
        

FAIL x 2

A quick fix and we're all set


     numberWords(num) {
         if (num === 20) {
-            return "twenty";
+            return "veinte";
         }
         else if (num === 15) {
-            return "quarter";
+            return "cuarto";
+        }
+        else if (num === 25) {
+            return "veinticinco";
+        }
+        else if (num === 30) {
+            return "media";
         }

         return this.numberText[num];
     }

     minuteWords(minutes) {
+       return [this.numberWords(minutes)];
     }
        

Put it all to use

In our demo web page, switch languages each time we click.


board.addEventListener('click', () => {
    english = !english;
    clockwork = GetClockwork(english ? 'en' : 'es');
    showTime();
});

        

Like so

Deeper Dives

Slides and Codes and Further Discussions