It’s a high compliment when users ask for new features. They’re saying they can get even more value from our software if it did even more. We want to satisfy that kind of request.
If software is used it will, inevitably, need to be changed. But software isn’t always written in a way that makes it easily modifiable, so it turns out to be difficult and expensive to add new features. How expensive? A National Institute of Standards and Technology study found that it costs twice as much to make minor enhancements to a system than it cost to build the system in the first place.
Why is it so expensive to add features to existing software? In a nutshell, it’s because we don’t value writing changeable code, or we think that writing changeable code will take much longer to write. Changeable code is software that when we go back to add features, it doesn’t cause other parts of the system to break. When working with changeable code, it’s cost-effective to go in and add features.
Writing good, changeable code doesn’t take longer, but it does require paying attention to certain things when building a system. First and foremost, we want to make sure our system is understandable. We want to have a strong metaphor for the system we’re building, one that reflects how we interact with it.
Good design patterns, principles, and practices can help as well. If we use abstractions and encapsulation to hide variations and other implementation details so they’re straightforward to change later, we can drop the cost associated with changing existing software and make it far more economically feasible to add features to existing systems.
Understanding the importance of testability and code quality is critical, too. Creating independent, testable behaviors in the system allows us to write automated acceptance tests so we can automatically validate that our software still works as expected.
One very important aspect of writing changeable code that is often overlooked is having a good suite of unit tests that support us in refactoring the code when needed. Unfortunately, the way most developers practice test-driven development (TDD), the unit tests they write get in the way of refactoring rather than providing a safety net. Features are often intertwined with other code, making it difficult to test independently. Then, when one piece of code is changed, it can cause bugs in seemingly unrelated parts of the system.
To prevent these types of errors, test all the features of a system when any change is made. But if testing is a manual process, that will get expensive and will tend to be put off until late in a project’s lifecycle. So instead, adopt automated testing, which can be done continuously. Automated testing reduces the impact of making changes to a system by quickly identifying hidden dependencies that can then be resolved as they are being discovered.
When doing TDD, we want to write tests against behaviors, not the way we implement those behaviors. Unit tests should treat behaviors as a “black box” and not depend on the way those behaviors are implemented. When we test behaviors instead of the way we do those behaviors, it gives us the freedom to refactor our code and change those implementations without breaking our tests, as long as the behaviors themselves don’t change.
When we use unit tests and TDD to specify behaviors, they become a form of documentation that shows us how our behaviors are consumed. This reduces the need for internal documentation. I think of my tests as living documentation because I can run my tests and prove they’re up to date. With a specification document, I’m never sure if it’s up to date or not.
Using TDD to specify behaviors can be very helpful in keeping us focused on fulfilling acceptance criteria, writing independently testable code, and providing living documentation, but TDD doesn’t replace quality assurance (QA). We still need to go back, think about what can go wrong, and write additional tests to cover those situations. We often need to cover aspects of our code that aren’t covered by our unit tests. This can include functional and integration tests as well as testing for security, performance, and some edge cases.
We don’t want developers to do this kind of testing when doing TDD. The QA mindset is to look for things that could go wrong, but that isn’t the TDD mindset. It’s the same for writing prose. If you’ve taken any kind of writing class in school, then you’ve probably gotten the advice not to edit yourself as you’re writing. That’s a recipe for writer’s block. It’s better to write without worrying about spelling, grammar, etc., and then go back later and edit your work.
When developers “put on their QA hats” when doing TDD, it’s easy to get stuck. They can write too many tests and implementation-dependent tests that make it harder to refactor code in the future. TDD can help development in many ways, but it doesn’t replace QA.
Having a suite of regression tests built using TDD helps QA because they don’t have to spend their time manually doing regression testing. Repeated manual testing of code is highly inefficient and something we want to avoid. The software industry is all about automating other industries. We should take our own advice and automate the parts of our industry that can be automated.
Writing changeable code isn’t difficult or time-consuming. We just have to understand some basic object-oriented design skills and how to build code that’s straightforward to change. A focus on quality can take some effort to learn and skill to apply, but it’s worth the dedication.
Some good points here, e.g., "We want to have a strong metaphor for the system we’re building". Indeed, this implies a design - an intention. And indeed, too many unit tests in a poorly designed system can be a huge impediment to change: every little change requires updating countless tests.
It is also a good point that behaviors should be tested. However, I disagree somewhat on the level. Unit testing is at a granular level: the behavior of individual functions. In contrast, appication behavioral testing ("black box testing") now known as "behavior-driven development" (BDD) is at the outermost level - either a user level or a published API level. Such tests can be very high coverage; and it is even possible to measure code coverage for behavioral tests.
The question of unit testing is stronly impacted by the programming language. I have found that when I refactor a Ruby or Python application, I introduce a-lot of errors, and only comprehensive unit tests save me. However, I have refactored very large Java applications and introduced zero errors: zero. It is the strong type safety of the language. Thus, I would contend that if one uses a type-safe language, the cost/benefit of unit tests as a strategy for having changeable code is greatly reduced to a point where I question the utility of unit tests for such languages. I long ago decided to completely forego unit tests when I write Java, using BDD tests instead. It has worked really well. Thus, for typesafe languages I advocate what David advocates but using behavioral tests at the outermost levels. And if you are writing code in a large organization that others might have to maintain, I strongly urge against using languages that are not typesafe.
Thank you for your thoughtful comment. I agree. I think that the value of good unit tests increases in non-type-safe languages but I wouldn't say the value of good unit tests in type-safe languages like Java is zero.
Generally, I find the more I refactor the better I get at it and the fewer mistakes I make. I'm still glad to have a suite of unit tests watching my back. My sense is that you're an advanced developer so you may not need a safety net as much as other less experienced developers but remember, we work on teams and others could benefit from having a safety net.
Perhaps I write fewer unit tests when I do TDD than I used to because I focus on testing behaviors and not how I implement those behaviors when doing TDD. This helps support me when I go to refactor my code later since I don't have implementation-dependent tests failing. I still need to backfill with QA tests.
Regression is just one of the benefits of doing TDD. Focusing on intentions, encapsulating implementation details, making code more testable, and enabling emergent design are some other benefits of TDD. It's true that we can figure a lot of this out without doing TDD but TDD helps and I think it's essential for building an industry of able developers.