On Unit Testing and Object Coupling
Most software developers have heard about unit testing, where you create test code for all bits of application code. The idea is that if the tests are written well, you can catch most application bugs by just running the unit tests. Most developers agree this is a good idea, but in the end, most developers don't write unit tests.
The most common objection I hear goes something like this: "it's a great idea, but I don't see how my application can be broken down into small chunks for effective unit testing." Ah, and there's the rub: most existing apps are not designed so they can be easily unit tested. But the common mistake is to assume that most apps cannot be designed for easy unit testing. In fact, I'm pretty sure that most can.
The problems with existing code usually revolve around object coupling, where software objects rely on other objects to do their job. What follows is a discussion of how unit testing (and especially test-first practices) affects software design and the relationships of software objects.
I'll skip the basics of unit testing -- many people have covered that more eloquently than I could. The main principle is that unit tests are very focused, testing one bit of functionality at a time. Several software methodologies rely heavily on unit testing, including Extreme Programming (XP) and Test-Driven Development (TDD). See "Test Driven Development" (Kent Beck) as essential reading. The basic principle of TDD is this: write the unit tests first. Only once you have tests in place, write the application code that makes the test pass. Then refactor to eliminate duplication in your app code.
I define object coupling as the dependencies between software components. Usually these are classes, but even in non-OOP languages the principle is the same: if you can't use component A without B and C also being in place, then A is coupled to B and C. There are different levels of coupling, i.e. does A work at all without B and C? If so, I'd call A "loosely coupled" to B and C. If A doesn't work at all, I'd call it "tightly coupled."
The Usual Way Things Work
Most of us have seen class diagrams like this:
You can't use the Manager class without its full class hierarchy, plus you need SalaryAccount and ExpenseAccount and their hierarchy, plus that uses TransactionManager, which uses NetworkManager, and so on. Trying to unit test any class in here would be a real mess.
Furthermore, the design isn't very flexible. Say an employee also buys something from the company, thereby becoming both a customer and an employee. How's that represented? Or what if a programmer goes on a trip and needs an expense account? Now the class diagram above is a contrived example, but I'm sure you can think of examples from your own experience where your big class hierarchy broke down at a later point -- requirements changed and the hierarchy could no longer model everything it needed to.
How Does Writing Tests First Help?
I have found that when you write unit tests before the app code, the class you're working on comes out much more self-sufficient than when you do the Big Design Up Front. Your mental focus is on making A work before you connect it to B and C. Going back to the diagram, let's say I write a unit test:
Person person; person.addToPaycheck(5000); assert(person.paycheckBalance() == 5000);
That's easily modeled with one class (Person), one field (paycheck), and a couple methods. So do that. Then the application needs to handle billing a person for something. So here's a test:
Person person; person.addToBill(100); assert(person.billingBalance() == 100);
Again, another field and a couple more methods. Then you need to track account history. Accounts don't really need people for things like this, so maybe you create an Account class:
Account account; account.addDeposit(100); account.addDeposit(100); assert(account.numEntries() == 2); assert(account.balance() == 200);
So then you implement the Account class. Once those tests are working, you notice that there's a good bit of duplication between Person's paychecks, billing, and this new class. Now it's most logical to make Person reference some Accounts. Let's rewrite the first two tests like this:
Person person; Account paychecks; Account billing; person.addAccount("paycheck", &paychecks); person.addAccount("billing", &billing); person.account("paycheck").deposit(5000); assert(person.account("paycheck").balance() == 5000); person.account("billing").withdraw(100); assert(person.account("billing").balance() == -100); assert(person.totalAssets() == 5000); assert(person.totalLiabilities() == 100); assert(person.netWorth() == 4900);
So Why Is This Better?
Again, this is a contrived example, but observe:
- We have unit tests for all classes and methods, valuable for verifying functionality and later regression testing.
- The core functions of Account can be tested and verified without a Person object.
- Non-accounting methods of Person can also be tested independent of Account.
- Person now has a flexible number of accounts. The same person can get a paycheck, and also be billed as a customer.
- New accounts can be added at will, e.g. giving a programmer an expense account when needed.
- Added simplicity and flexibility makes for a less-fragile system when requirements change down the road.
Developing in this manner tends to create objects that have loose, or even optional, coupling to other objects and resources. Avid readers of "Design Patterns" (Gamma, et. al.) will notice the parallel between this trend and Section 1.6 of Design Patterns: we are favoring object composition, aka "black-box reuse." Developing test-first naturally drives towards these kinds of objects because they're easier to test.
This design has so many advantages -- some discussed here, even more in Design Patterns -- that it leads the authors to declare it their second principle of object-oriented design: "Favor object composition over class inheritance."
Simulating External Resources
Notice in our example that we lost the TransactionManager class, which talked to some external system over the network. Before this app is done, it'll need to use that external system. The best way to do this is starting with a mock version of TransactionManager that fakes the transactions internally. This buys you some important features:
- Testing can be done at any time without requiring or affecting the external system.
- It's easy to simulate failure cases.
- Tests can run faster.
Keep this mock version around for unit testing and only switch to the "real" version for tests directly requiring the external system.
While perhaps a long entry for a blog, I've given only a brief examination of how test-first programming can affect your designs for the better. In a recent project I sketched out the Big Design Up Front to appease my coworkers, but then used unit tests to drive the implementation. The end design was both simpler and more flexible than my original design. Plus, no bugs. I was able to deliver core functionality quickly, then add requested features without breaking anything. There have been zero defects reported against the project.