Test Driven Development — a personal account

Juan Gutierrez
Ordergroove Engineering
8 min readApr 15, 2020

This is not a post on the benefits of TDD. This is not a post on why you should use TDD. This post is meant to be more of a story of how I picked up a tool once again and finally appreciated how to use it. So…what happened?

I didn’t actually learn about unit testing until I was a year or so into my first job out of school. The big selling point was how it made your life easier through automation — we are software engineers; automation is kind of our thing. The primary selling point: automate the manual tests you made against some feature you built to ensure expected functionality against use cases. An additional useful side-effect: when making changes in the region of this feature, you’ll know whether or not your changes play nice with said feature.

There was a more attractive selling point that drew me to automated testing in general. When developing a feature, you typically have a depth of familiarity and understanding for expected behavior, based on discussions you’ve had with your team, customers, etc. As time goes on, that familiarity fades; it’s natural. When you need to make a change or an addition to this feature six months later, it stands to reason your ability to manually test won’t be to the same caliber and rigor from that first round. I’ll be the first to admit: I forget things. A lot of things.

The first draw for automated testing means I can do less in the long run and this second point means I don’t have to remember everything, all the time. All the while, there was this phrase of “Test Driven Development” that I kept seeing in conjunction with many articles: “Write the test first before you write the code.”

o_0

I gave it a try…and then I gave up. It was hard. Really hard. It was a very different and foreign way of thinking, let alone coding. I didn’t really see the point and anyway: I had a deadline, I needed to get my feature out and move on. I was writing tests where there were none before. I hit the “good enough” mark. So…let’s fast forward a few years…

When I first joined Ordergroove, there were only a handful of us in the company, only two of us on the engineering team and we were doing it all in the engineering space: database administration, infrastructure management, project planning, development, QA, deployment. There weren’t any tests and as you might expect, we manually tested any and all changes. The code base was small enough and we were a small enough team that this actually worked. Until…

One day, we realized there was one email not being sent to our customers…for a week…because of a change I had introduced. I commented out a line that logged an event which triggered said email. Why? When I was manually testing locally, an exception kept being thrown about how I didn’t have something or other configured; I found it to be that line, commented it out, tested the feature, deemed it valid, committed and pushed it up.

Uh oh….

Notice how I didn’t say anything about reverting back the commented line back to it’s original state? Whoops…

The process aficionados out there will call out “But what about the code review process!?” — We didn’t have that either. Nowadays, any changes require unit tests, code review, approval, and we automate Jenkins builds against pull requests. We didn’t even have Jenkins! This was like 9 years ago! But, I digress…

After getting burned like this, I made it a point to cover, at the bare minimum, the business essential features with SOME set of unit tests. It certainly wasn’t exhaustive of all positive and negative use cases, but it was enough to cover me, or anyone else, from making such a simple and silly one line blunder again.

“Cool unit testing story bro, but uh…what does this have to do with TDD???”

Fair enough: that story is more about “this is why tests good” not “why TDD good.” This was, however, one of the pivotal points in my career that made me commit to writing tests. It made me appreciate how useful they were. With that appreciation came the desire to improve.

There are many leaders in the industry that have written at length regarding testing and TDD — “Uncle Bob” Martin, Martin Fowler, Kent Beck, and the list goes on. I’ve been following them for years and this TDD stuff was inescapable: they kept bringing it up; I kept trying; I still didn’t get it; I’d drop it but still keep writing tests; repeat this cycle for a number of years. But one day, I stumbled on a talk by Ian Cooper. This talk was what changed everything for me. I related to it deeply because it distilled my multi-year endeavor of writing tests by showing me what I had experienced, why, and what I was missing.

One point he makes early on is that we as engineers don’t typically stick around long enough to see applications or features we’ve built come back to haunt us. I’ve been at Ordergroove for nearly 10 years now. I’ve been here long enough to see features I’ve written come back and bite me. I’ve seen them bite others. This pain typically manifests in the form of a feature requiring a refactor and then having to wrestle with the confusion from the original tests: what is this doing? why is it doing that? what is this even testing? You can imagine how one’s thought process may be extremely different when you’re looking at someone else’s code versus what you yourself have sown.

“Code” in this quote also applies to tests. I could write beautiful feature code and dumpster fire worthy unit tests. I found myself face-palming and cringing in pain while trying to make my legacy unit tests works. The aforementioned “psychopath” maintaining my code would be less forgiving.

OK — but why? Why all this pain? This touches on my second takeaway from Mr. Cooper’s talk: I was testing feature implementation, not feature behavior. This insight nearly knocked me off of my chair. Mind. Blown.

Code will inevitably need to be refactored and that’s fine. However, refactoring code means changing the internal plumbing of how functionality is executed (implementation), but it’s typically with the intention of creating optimizations without changing the expected outcome (behavior). An example here with some code might prove useful.

Let’s say we write a function to multiply two integers and a corresponding test (pseudocode warning!!!):

def mult(a, b):
return a * b
@mock('*')
def test_multiply_operator_is_called(self, mock_multiply_operator):
self.assertTrue(mock_multiply_operator.called)
def test_2_times_2_returns_4(self):
self.assertEqual(2*2, mult(2, 2))
def test_2_times_4_returns_8(self):
self.assertEqual(2*4, mult(2, 4))

SIDE NOTE: I’m not knocking mocks here. Mocks have a place in testing. However, speaking from personal experience, I have abused mocking such that I was binding my tests to one and only one implementation which is what I wish to convey with my simple example above.

So looking at what we have: we were asked to write a feature to multiply some numbers, we wrote a number of tests, they pass. But that first test — that’s the “implementation” test. The others are behavioral. What happens when I refactor “mult” to use a super-optimal library I found?

from awesome.package import optimized_multdef mult(a, b):
return optimized_mult(a, b)

Whoops! One of my tests just failed.

Now this example is both painfully obvious and simple but this is on purpose. From my own experience, the majority of my pain and time spent on refactors are due to the fact that I’ve sewn together implementation and behavior into one test. I have to spend energy untangling this, which requires remembering why I even did that in the first place. With that, I just defeated one of the advantages of testing:

When you need to make a change or an addition to this feature 6 months later, it stands to reason your manual tests won’t be to the same caliber and rigor from that first round.

At this point you could turn around and say “OK — lesson learned; just don’t write tests like that.” Fair enough, but I’ve found that TDD actually gives me a process to prevent me from writing implementation tests in the first place. It forces me to ask questions in the context of behavior.

When I give you X, what do I expect back? I don’t care how you got there.

I use this thought process to write clear (and sometimes long) test names with a simple expectation that fails. I’ll then write the code to get that test passing. Repeat until feature complete! Test driven development helps prevent your test from being biased about “how you get there” — the code doesn’t exist yet! This cycle I referred to above is typically described in the following way: red/green/refactor.

  • (RED) Write a failing test
  • (GREEN) Write just enough code to make the test pass, committing any and all coding sins necessary along the way.
  • (REFACTOR) Clean up the code you wrote so the psychopath that’s going to maintain this doesn’t have a reason to come and visit you where you live.

I’ll close with this, however: TDD is not a silver bullet. It’s another tool in the toolkit. It’s all about how you use the tool. There was a fascinating debate between Jim Coplien and Bob Martin which brought to my attention some ideas that I wasn’t aware of in TDD. One in particular that I do not agree with is that architecture should be driven by TDD. I think that idea falls outside of the scope of TDD’s responsibilities. Architecture should be planned in advance, providing some boundary definitions of the application, thus framing a set of rules of how features might be expected to behave within these boundaries. TDD’ing a feature that assumes access to the database, even though the business requires that all data is to be retrieved via web requests to some other remote service won’t do anyone much good.

A perfect example of this: we’ve been actively working on a project — of which I’ll be writing another blog article about, so stay tuned !— that requires the behavior of an application stay the same, but much of the work can and should be processed offline, asynchronously. The architecture change is re-framing the boundaries of the application, which in turn, has a ripple effect of changing the allowed implementation rules. Though, once again, the expected outcomes of the features hasn’t changed. Time to re-write some tests…

Fortunately, architecture changes are kind of a big deal so in that case, re-writing tests isn’t the worst thing in the world. Also, the amount of re-writing can be mitigated with “good” tests because they, in theory, should only need to update what the boundary looks like. We’ll see how I feel about these tests in the next few years…

>:)

--

--