The Conversion of a Unit Test Denier or …
How I Learned to Stop Worrying and Love Unit Testing
Introduction
Unit testing was a waste of time. At best, unit tests only confirmed the code that I already knew was working. At worst, they took time to write, and they tended to be brittle. They often stopped working after I updated the implementation. Maintaining the tests wasn’t worth the additional effort.
I don’t think I was unique with this opinion. Unit testing has been and continues to be a contentious topic in our industry. Some love it. Some hate it. There’s not much middle ground.
I was chatting with a former colleague recently. He told me that his shop is advocating Test-Driven Development (TDD). He said they were getting pushback from some developers. I chimed in, “Let me guess. The developers fighting the policy are the more experienced ones, right?” His expression indicated agreement. “Yes. The senior developers don’t want to do it. The junior developers are more willing to consider it.” No one seemed enthusiastic about it.
I bucked the trend of my friend’s senior developers’ opinions. Late in my career, I had a complete about-face and embraced unit testing. I not only embraced the practice, but I created and presented unit testing training to other developers at my company.
This blog will serve two purposes:
- Tell a bit of my story from unit test denier to convert.
- Be a springboard for future unit test related blogs.
Grand Topic
Automated testing is grand topic. Whole books have been devoted to it. I can’t fit it all into one blog. I have more to say about testing. I will follow up with a series of blogs about different aspects of automated testing. I’ll provide a link for each on this page when completed:
- Attributes of Effective Unit Tests - Unit Test properties that make them more useful than not
- Basic Elements of Automated Unit Tests - Elevating automated tests to first-class citizen status
- Test Doubles - Emulate dependencies without depending upon dependencies
- Suril, the Semaphore and Me - When the theory became practice for me
- Test-Driven Development - Writing Tests Before the Implementation - I know it sounds completely backwards, but please give it some consideration
- Yuri, the Programming Assignment and Me - My evening introducing Test-Driven Development to a young Computer Science student
- Be On Your Best Behavior - Test-Driven Development - How to create tests; Behavior-Driven Development - What tests to create
- What is Behavior in Behavior-Driven Development - More details about the nature of behavior
- Testing Benefits - Spoiler Alert – It’s not really about testing the code
- Testing Concerns - Test concerns may be a result of previous bad test experiences; there are ways to accommodate them
Formal Proofs
Unit testing was not presented in my academic career. I suspect this was because modern unit testing practices were still a decade or two in the future, and the technology didn’t support it.
Our programming assignments in my first two years in college were on punch cards and executed in batch mode. The time from feeding your card deck into the card reader until we got our printout back was usually 5 to 10 minutes. Then we’d look at the output, which was usually wrong, figure out what needed to be changed, type up new cards, insert them into our deck, avoid dropping the deck on the walk back to the card reader and start all over again. Turnaround time for the entire cycle could easily be 20 to 30 minutes.
Our instructors taught us how to prove that our programs were correct using invariants. We learned how to prove the correctness of recursive programs using strong mathematical induction. Our instructors also showed us techniques that can best be described as a debugger on paper while we executed our code in our brains. We’d think through the code while keeping track of the state using pencil and paper. I usually wrote my programming assignments out completely by hand before ever venturing to the Computer Lab.
I still use some of these techniques informally while thinking about my code.
Formal proofs are necessary for algorithms and data structure correctness. But if they really worked for code, then the Computer Lab would not have been more active than the bars downtown at 3:00 AM. Our proven code should have worked on the first syntax-free execution.
Our testing consisted of running our programs on the assignment data until it produced the desired results. Once we saw it work, we’d grab our results, pack up our gear, head home and hope for a few hours of sleep before the next day of classes began.
Unit … Well Really System Testing
Things didn’t change too much once I started my career, except for fewer late nights. Waterfall was all the rage several decades ago. We’d document our analysis and design in massive three-ring binders, killing a lot of trees, and then finally we’d implement. We’d perform some manual testing for basic sanity, and then we’d hand it over to QA. They’d find any real problems with the code, right?
Even basic sanity testing could be difficult. It was often end-to-end testing, sometimes in the lab. It could take a lot of time and effort to even get your code into a test environment, as I described in the Introduction of Dependency Injection. Confirming specific behaviors could be difficult, especially edge cases. When the code didn’t work, it often became a major debugging effort. Did the problem reside in my code or some other part of the system that my code was calling?
Test Code, But I Wouldn’t Call It Unit Test Code
I created test code programs with limited success throughout the years. Most were not automated. I had to run them manually. Often, I had to temporarily update the Factories in the implementation to return Test Doubles and then change it back again. See Dependency Injection Introduction for additional details.
The tests rarely told me anything that I didn’t already know. Once I started using Design Patterns in my designs and implementations, my error rate dropped significantly. I was happy with the quality of my code with or without automated tests. The tests were redundant at best, and they took time away from writing implementation code. That was part of my rationalization, since the tests tended to break as more code was modified.
It’s only in hindsight that I know what I was doing wrong with these earlier testing attempts:
- Tests were written after the code had been written.
- Each test confirmed as much behavior as possible.
- Test environment included system dependencies.
- Setting up a test took a long time, often due to the dependencies.
- Executing a test could take a long time, often due to the dependencies.
- Tests changed state in such a way that they induced dependencies upon one another. I.e., Tests could not be run in isolation, and changing a test could cause others to fail.
- Tests generated extensive and detailed reports, which I had to read and evaluate to determine whether they passed or failed.
- When a test failed, I was never quite sure if the issue was in my code or a dependency.
- As failing tests accumulated, I usually stopped running them. I knew the code was working. Fixing the tests was too much effort.
The Technical Book Club at Work
I was part of a weekly technical book club at the office.
The Forgotten Unit Testing Book
We read a Unit Testing book, whose title and author I don’t remember. It didn’t convince me that I should be writing unit tests. I think this may have been the first place where I read about the unit testing process of writing a failing test before implementing the code. Write a test before writing the code? That’s weird. And then make the failing test pass by the quickest means possible, such as hardcoding what the test expects. That makes even less sense.
The only part that made an impression on me was the section on Test Doubles.
Working Effectively with Legacy Code
Then we read Working Effectively with Legacy Code by Michael Feathers. There are many definitions for legacy code:
- Code that’s complicated and difficult to understand
- Code no one wants to touch
- Code without an official owner
- Code that works, is used by the customer and responsible for most of our paychecks
- The only true source of the behavior of our products, even if no one knows exactly what it is or where to find it
I’m currently of the opinion that code becomes legacy as soon as it’s been committed to the main branch.
Feathers defines legacy code as code without unit tests. He describes the typical process of updating legacy code as edit and pray. I could relate to that.
Feathers devotes the first five chapters of his book to the value of unit tests, which can basically be summarized as moving the legacy code modification process from edit and pray to cover and modify. Unit tests provide a safety net so that changes can be made confidently. I think unit tests do much more than that, but I was just starting to gain a new appreciation for unit testing at the time.
Legacy code is obdurate to having unit tests added to it. We often must modify legacy code before it can accommodate unit tests. We can’t confidently update legacy code without unit tests, and we can’t add unit tests without updating the legacy code. We have a Catch-22 situation.
Feathers devotes the remaining 20 chapters of his book describing techniques that refactor legacy code with minimal risk, so that the legacy code will be more accommodating to additional unit tests.
My unit test epiphany came in Chapter 13: I Need to Make a Change, but I Don’t Know What Tests to Write where Feathers wrote:
Automated tests are a very important tool, but not for bug finding—not directly, at least. In general, automated tests should specify a goal that we’d like to fulfill or attempt to preserve behavior that is already there. In the natural flow of development, tests that specify become tests that preserve.
Automated tests aren’t about testing the code. They’re about specifying and preserving behavior. I can document behavior, assumptions, invariants, etc. via an automated test. They will be confirmed each time the tests are executed. Any deviation will be immediately obvious in a failing test.
A failed automated test indicates an inconsistency between the test and implementation. The inconsistency may be due to a recently updated implementation that violates current behavior, assumption or invariant. Or sometimes it may be a new or updated test for new or updated behavior, et al., that is not supported by the implementation.
When failing, either the implementation or the test must be updated to resolve the inconsistency whether it’s new or updated code that violates a behavior, et al., or the behavior, et al., that no longer applies for an outdated test.
Regardless, the failing test cannot be ignored. This automated inconsistency verification is something our volumes of tree-killing analysis and design documents could never do.
Clean Coder Videos
Soon after reading Working Effectively … I watched Bob Martin’s Clean Coder Video Series on O’Reilly. Bob devotes several videos to unit testing where he provides more arguments for Unit Testing and details on how to create unit tests.
Bob reinforced what I was starting to appreciate from Michael Feathers.
Put the Tests to the Test
I felt I had a reasonable understanding of unit test techniques. I tried the techniques for some Design Pattern example code I had been writing. I couldn’t believe how quickly I could proceed. I even changed the design midway through, and I was able to pivot seamlessly.
I started to use the techniques with project code going into production. Even though I was learning the techniques, I was happy with the results. I felt a new sense of confidence that I had never felt before.
I was sold.
Summary
I saw the light. I, a Unit Test Denier, became a Unit Test Evangelist. I created a Unit Test Introduction course at work and presented it well over a dozen times.
References
Working Effectively with Legacy Code by Michael Feathers:
Bob Martin’s Test Related Videos:
- TDD Part, Part 1: Clean Coders, O’Reilly
- TDD Part, Part 2: Clean Coders, O’Reilly
- Advanced TDD‚ Part 1: Clean Coders, O’Reilly
- Advanced TDD‚ Part 2: Clean Coders, O’Reilly
- Clean Tests: Clean Coders, O’Reilly
- Test Design: Clean Coders, O’Reilly
- Test Process: Clean Coders, O’Reilly
- Mocking: Part 1: Clean Coders, O’Reilly
- Mocking: Part 2: Clean Coders, O’Reilly
- Transformation Priority Premise‚ Part 1: Clean Coders, O’Reilly
- Transformation Priority Premise‚ Part 2: Clean Coders, O’Reilly