In the last article (Abstraction: The Goldilocks Principle), we took a look at some steps for determining the right level of abstraction for an application. We skipped a big reason why we might want to add abstractions to our code: testability. Some developers say that unit testing is optional (I used to be in this camp). But most developers agree that in order to be a true professional, we must unit test our code (and this is where I am now).
But this leads us to a question: Should we modify our perfectly good code in order to make it easily testable? I'll approach this from another direction: one of the qualities of "good code" is code that is easily testable.
We'll loop back to why I take this approach in just a moment. First, I want to describe my personal journey with unit testing.
A Rocky Start
Several years back, I didn't think that I needed unit testing. I was working on a very agile team of developers (not formally "big A" Agile, but we self-organized and followed most of the Agile principles). We had a very good relationship with our end users, we had a tight development cycle (which would naturally vary depending on the size of the application), we had a good deployment system, and we had a quick turnaround on fixes after an application was deployed. With a small team, we supported a large number of applications (12 developers with 100 applications) that covered every line of business in the company. In short, we were [expletive deleted] good.
We weren't using unit testing. And quite honestly, we didn't really feel like we were missing anything by not having it. But companies reorganize (like they often do), and our team became part of a larger organization of five combined teams. Some of these other teams did not have the same level of productivity that we had. Someone had the idea that if we all did unit testing, then our code would get instantly better.
So, I had a knee-jerk reaction (I had a lot of those in the past; I've mellowed a lot since then). The problem is that unit testing does not make bad code into good code. Unit testing was being proposed as a "silver bullet". So, I fought against it -- not because unit testing is a bad idea, but because it was presented as a cure-all.
After I have a knee-jerk reaction, my next step is to research the topic to see if I can support my position. And my position was not that unit testing was bad (I knew that unit testing was a good idea); my position was that unit testing does not instantly make bad code into good code. So, I started reading. One of the books I read was The Art of Unit Testing by Roy Osherove. This is an interesting read, but the book is not designed to sell you on the topic of unit testing; it is designed to show you different techniques and assumes that you have already bought into the benefits of unit testing.
Not a Silver Bullet
I pulled passages out of the book that confirmed my position: that if you have developers who are writing bad code, then unit testing will not fix that. Even worse, it is very easy to write bad unit tests (that pass) that give you the false impression that the code is working as intended.
But Good Practice
On a personal basis, I was looking into unit testing to see if it was a tool that I wanted to add to my tool box. I had heard quite a few really smart developers talk about unit testing and Test Driven Development (TDD), so I knew that it was something that I wanted to explore further.
TDD is a topic that really intrigued me. It sounded really good, but I was skeptical about how practical it was to follow TDD in a complex application -- especially one that is using a business object framework or application framework that supplies a lot of the plumbing code.
Actual Unit Testing
I've since moved on and have used unit testing on several projects. Now that I am out of the environment that caused the knee-jerk reaction, I fully embrace unit testing as part of my development activities. And I have to say that I see the benefits frequently.
Unit Tests have helped me to manage a code base with complex business rules. I have several hundred tests that I run frequently. As I refactor code (break up methods and classes; rearrange abstractions; inject new dependencies), I can make sure that I didn't alter the core functionality of my code. A quick "Run Tests" shows that my refactoring did not break anything. When I add new functionality, I can quickly make sure that I don't break any existing functionality.
And there are times when I do break existing functionality. But when I do, I get immediate feedback; I'm not waiting for a tester to find a bug in a feature that I've already checked off. Sometimes, the tests themselves are broken and need to be fixed (for example, a new dependency was introduced in the production code that the test code is not aware of).
I'm not quite at TDD yet, but I can feel myself getting very close. There are a few times that I've been writing unit tests for a function that I just added. As I'm writing out the tests, I go through several test cases and sometimes come across a scenario that I did not code for. In that case, I'll go ahead and write the test (knowing that it will fail), and then go fix the code. So, I've got little bits of TDD poking through my unit testing process. But since I'm writing the code first, I do end up with some areas that are difficult to unit test. I usually end up refactoring these areas into something more testable. With a test-first approach, I might have gotten it "right" the first time.
Over the next couple weeks, I'll be diving into a personal project where I'm going to use TDD to see how it will work in the types of apps that I normally build.
One thing to note: my original position has not changed. Unit Testing is a wonderful tool, and I believe that all developers should be unit testing at some level (hopefully, a level that covers the majority of the code). But unit testing will not suddenly fix broken code nor will it fix a broken development process.
Now that I've seen the benefits of unit testing firsthand, I've become a big proponent of it. And even though I didn't see the need for it several years back, I look back now and see how it could have been useful in those projects. This would include everything from verifying that automated messages are sent out at the right times to ensuring that business rule parameters are applied properly to verifying that a "cleaning" procedure on user data was running properly. With these types of tests in place, I could have made feature changes with greater confidence.
When a developer is using TDD, he/she takes the Red-Green-Refactor approach. There are plenty of books and articles that describe TDD, so I won't go into the details. Here's the short version: The first step (Red) is to write a failing test based on a function that is required in the application. The next step (Green) is to write just enough code for the test to pass. The last step (Refactor) comes once you're a bit further into the process, but it is the time to re-arrange and/or abstract the code so that the pieces fit together.
The idea is that we only write enough code to meet the functionality of our application (and no more). This gets us used to writing small units of code. And when we start composing these small units of code into larger components, we need to maintain the testability (to make sure our tests keep working). This encourages us to stay away from tight coupling and putting in abstraction layers as we need them (and not before). In addition, we'll be more likely to write small classes that work in isolation. And when we do need to compose the classes, we can more easily adhere to the S.O.L.I.D. principles.
Back to the Question
So this brings us back to the question we asked at the beginning: Should we modify our perfectly good code in order to make it easily testable? And the answer is that if we write our tests first, then well-abstracted testable code will be the natural result.
I've done quite a bit of reading and other study of Unit Testing. One of the things that I didn't like about some of the approaches is when someone "cracked open" a class in order to test it. Because the unit they wanted to test was inaccessible (perhaps due to a "protected" access modifier), they wrote a test class that wrapped the code in order to make those members visible. I never liked the idea of writing test classes that wrapped the production classes -- this means that you aren't really testing your production code.
However, if we take the approach of test-first development (with TDD or another methodology), then the classes we build will be inherently testable. In addition, if a member needs to be injected or exposed for testing, it grows more organically with the corresponding tests.
And back to the last article about abstractions: if we approach our code from the test side, we will add abstractions naturally as we need them. If we need to compose two objects, we can use interfaces to ensure loose-coupling and make it easier to mock dependencies in our tests. If we find that we need to inject a method from another class, we can use delegates (and maybe the Strategy pattern) to keep the separation that we need to maintain our tests.
Whole books have been written on unit testing (by folks with much more experience than I have). The goal here was to describe my personal journey toward making unit tests a part of my regular coding activities. I fought hard against it in one environment; this made me study hard so that I understood the domain; this led to a better understanding of the techniques and pitfalls involved. There are three or four key areas that I have changed my thinking on over the years (one of which is the necessity of unit testing). When I change my mind on a topic, it's usually due to a deep-dive investigation and much thought. Unit testing was no different.
You may be working in a group that is not currently unit testing. You may also think that you are doing okay without it. You may even think that it will take more time to implement. I've been there. Start looking at the cost/benefit studies that have been done on unit testing. The reality is that overall development time is reduced -- remember, development is not simply slapping together new code; it is also maintenance and bug-hunting. I wish that I had done this much sooner; I know that my past code could have benefited from it.
My journey is not yet complete; it will continue. And I expect that I
will be making the transition to TDD for my own projects very soon.
Maybe the other developers on your team don't see the benefits of unit testing, and maybe your manager sees it as an extension to the timeline. It's easy to just go along with the group on these things. But at some point, if we truly want to excel as developers, we need to stop being followers and start being leaders.