Growing Object-Oriented Software, Guided by Tests

Growing Object-Oriented Software, Guided by Tests

Testing software is good. Period. This book gives a practical approach to building software combining the principles of object-oriented programming with test-driven development. All the buzzwords might trigger your inner sceptic, but this book is really, really good. Highly recommended.

11 min read

Although I've never been a big fan of test-first approach of software development, on my current client project I decided to follow it for a change, and really enjoying it so far. To learn more about testing, I started reading this book last week. It's been a solid read, despite my initial scepticism of yet another testing book, and I wanted to share my learnings so far.

What follows is not my thoughts, interpretations, and opinions but a collection of my notes and highlights from the book. Although I don't agree with all the aspects of TDD, I think it's a useful technique that provides the discipline and pushes you to write tests proactively, instead of leaving them for later and never coming back to it.

Pair this book with this classic from David: TDD is dead. Long live testing and his RailsConf 2014 talk, which is absolutely fantastic, on so many levels.

If you enjoy this post, check out my notes on Martin Fowler's classic 'Refactoring':

Refactoring: Improving the Design of Existing Code (My Notes and Highlights)
I recently re-read Martin Fowler’s excellent book on refactoring. It’s about improving the design and quality of your code in small steps, without changing external behavior. The book contains detailed descriptions of refactorings, with motivation, mechanics, and an example for each. A must-read.

Alright, let's begin our read. If you take just one idea from the book, let it be the "walking skeleton".


Most software projects have elements of surprise.

Software developers learn on the job. They always have to work with unfamiliar tools and technologies. Everyone involved in a software project learns as it progresses.

For a project to succeed, the people involved have to understand what they’re supposed to achieve, and to identify and resolve misunderstandings along the way.

There will be changes, you just won’t know in advance what changes. To work with changes, you need a process that will help you cope with uncertainty - to anticipate unanticipated changes.

External Quality: How well the system meets the needs of its customers and users (is it functional, reliable, available, responsive, etc.)

Internal Quality: How well the software meets the needs of its developers and administrators (ease of understanding, maintainability, ease of change, etc.)

Apply feedback cycles at every level of development by organizing projects as a system of nested loops ranging from seconds to months: unit tests, integration tests, periodic meetings, pair programming, cycles, and so on.

Each feedback loop exposes your output to feedback to discover and correct any misconceptions.

To reliably grow a system and to copy with unanticipated changes, we need:

  • constant testing to catch regression errors - to add new features without breaking existing ones.
  • to keep the code as simple as possible - so it’s easier to understand and modify - developers spend far more time reading code than writing it.
  • to constantly refactor the code as it grows - simplify and improve the design, remove duplication, and ensure it expresses what it does.

The test suites protect us against our own mistakes as we introduce changes and refactor existing code.

When you’re not clear about the program, use tests to clarify your ideas about what you want the code to do.

💡
The cycle of Test-Driven Development: Write a test, make it pass by writing the code, refactor the code to be as simple an implementation as possible. Repeat.

The Bigger Picture

It’s tempting to start TDD by writing tests for individual classes and leave at that. Even though it’s better than nothing, a project with only unit tests is not good. You might write high-quality, well-tested classes that are not called from anywhere, or could not be integrated with the rest of the system.

What to do instead?

When you’re implementing a feature, start by writing an acceptance (integration) test, which tests the feature we want to build, not the class that implements it.

An integration test should exercise the system end-to-end without directly calling its internal code. It should interact with the system only from the outside; through the external endpoints, such as calling the web service.

Then write the unit tests to support the integration test. Fix the failing unit tests to implement each part of the feature, until the acceptance test passes.

Listening to the tests

When you find unit tests difficult to write or understand, either because the class is too tightly coupled to distant parts of the system, has implicit dependencies, or has too many responsibilities, listen to the feedback. Investigate why the test is harder to write and refactor the code to improve its structure.

Test-Driven Development with Objects

An object communicates by messages: receiving messages from other objects and sending messages to other objects.

An object-oriented system is a web of collaborating objects. A system is built by creating objects and plugging them together so that they can send messages to one another.

web of collaborating objects
web of collaborating objects

The behavior of the system is an emergent property of the composition of the objects - the choice of objects and how they are connected together.

You can change the behavior of the system by changing the composition of its objects. It’s easier to change the system’s behavior because we can focus on what we want it to do, not how.

Tell, Don’t Ask. The calling object should describe what it wants in terms of the role that its neighbour plays, and let the called object decide how to make that happen. The caller sees nothing of callee’s internal architecture or the structure of the rest of the system behind the role interface. This results in more flexible code because it’s easy to swap objects playing the same role.

Kick-Starting the Test-Driven Cycle

Trying to automate a process is one of the best ways to understand it better.

What about the very first feature, before we have any testing and deploying infrastructure?

As an acceptance test, it must run end-to-end to give us the feedback we need about the system’s external interfaces.

Deploying and testing right from the start of a project forces you to understand how the system fits into the world. It flushes out the ‘unknown unknowns’. We want to know as early as possible whether we’re moving in the right direction. Once we have the first test in place, subsequent tests will be much quicker to write.

First, test a walking skeleton

First, work out how to build, deploy, and test a “walking skeleton”, then use that infrastructure to write the acceptance tests for the first meaningful feature.

A ‘walking skeleton’ is an implementation of the thinnest possible slice of real functionality that we can automatically build, deploy, and test end-to-end. Keep the functionality so simple that it’s obvious and uninteresting, leaving us free to focus on infrastructure.

To design the initial structure, we have to have some understanding of the purpose of the system. We need a high-level view of the client’s requirements, both functional and non-functional.

Note: This is not waterfall. We are not doing detailed planning. Any ideas we have now are likely to be wrong. We’re only making the smallest possible decisions to kick-start the cycle, so we can start learning and improving from the real feedback.

Front-load the stress in a project by integrating it early. Projects with late integration start calmly but generally turn difficult towards the end as the team tries to pull the system together for the first time. Late integration is unpredictable because the team has to assemble a great many moving parts with limited time and budget to fix any failures.

As a project approaches delivery, the end-game should be a steady production of functionality, perhaps with a burst of activity before the first release.

project-delivery-activity
project-delivery-activity

The most important thing about a walking skeleton is to have a sense of direction and a concrete implementation to test our assumptions.

Often, we don’t have the luxury to build a new system from the ground-up. You will inherit some projects that must be maintained and evolved. We have to work with what already exists. To work with a legacy system, read Michael Feathers’ book "Working Effectively with Legacy Code".

The safest way to work with legacy systems is to automate the build and deploy process, and then add end-to-end tests that cover the areas of the code we need to change. With that protection, you can start to address internal quality issues with confidence, refactoring the code and adding unit tests as you add new features.

Maintaining the Test-Driven Cycle

Once we have kick-started the TDD process, we need to keep it running smoothly. We need to ensure that the tests continue to support change and do not become an obstacle to further development.

Start each feature with an acceptance test

The failing acceptance test demonstrates that the system does not yet have the feature we’re about to write and track our progress towards completion of the feature.

acceptance to unit
acceptance to unit

Write the acceptance test using the terminology from the application’s domain, not from the underlying technologies.

Not only this helps us understand what the system does without getting bogged down with the implementation details, it also protects the acceptance test from changes to the system’s technical infrastructure.

Benefits:

  • Clarifies what we want to achieve and uncover implicit assumptions.
  • Uncover implicit assumptions.
  • Remain focused on implementing the limited set of feature it describes.
  • Forces you to look at the system from the user’s point of view, rather than the implementer’s point of view.
  • A regular cycle of passing acceptance tests gives you a measure of the progress.

Write the test that you’d want to read

You want each test to be as clear as possible. It should describe the behavior to be performed by the system or object.

At the beginning, ignore the fact that the test won’t run, or even compile, and just concentrate on the text. Act as if the supporting code to run the test already exists.

When you have a test that reads well, implement the feature to make the test pass. You’ll know you’ve implemented enough of the supporting code when the test fails in the way you’d expect, with a clear error message describing what needs to be done.

Develop from the inputs to the outputs

Consider the events coming into the system that will trigger the new behavior. For example, the incoming HTTP request or a web hook call received by the controller.

Work your way through the system: from the objects that receive external events, through the intermediate layers, to the central domain model, and then on to other boundary objects that generate an externally visible response.

Listen to the tests

When writing tests, stay alert for areas of code that are difficult to test. When you find a feature that’s difficult to test, ask why it’s difficult to test, before how to test it.

Regularly reflect on how well tests are working for you, identify any weaknesses, and adapt your testing strategy.

Object-Oriented Style

Always design a thing by considering it in its larger context - a chair in a room, a room in a house, a house in an environment, an environment in a city plan.

As the code scales up, we can continue to understand and maintain it by structuring the functionality into objects, objects into packages, packages into programs, and programs into systems.

The best way to deal with complexity is by working at a higher level of abstraction. You can get more done if you program by combining components of useful functionality rather than manipulating variables and control flow.

Encapsulation ensures that the behavior (or the internal state) of an object can only be affected through its API (public methods).

Information hiding lets us work with higher abstractions by ignoring lower-level details unrelated to the task at hand.

Each object should provide a coherent abstraction with a clear API. It should encapsulate access to its internals through its API and hide these details from the rest of the system.

Try to create a valid object. If you have dependencies, pass them in through the constructor. Partially creating an object and then finishing it off by setting properties is brittle as the client (programmer) has to remember to set all the dependencies.

Composition: Most objects are composed of other objects. The object’s API must hide the existence of its component parts and the interactions between them, and expose a simpler abstraction to its peers.

A mechanical clock has two/three hands for output, and a pull-out wheel for input, while hiding the internal moving parts.

Achieving Object-Oriented Design

Find the right boundaries for an object so that it plays well with its neighbours - a caller wants to know what an object does and what it depends on, but not how it works.

Don’t be afraid to create new types, even for value concepts. Once we have a type to represent a concept, you can add behavior to it, instead of scattering related behavior across the codebase.

  • When the code in an object or a function starts becoming complex, that’s often a sign that it’s implementing multiple concerns. Break out coherent units of behavior into helper types.
  • When you want to mark a new domain concept in the code, introduce a placeholder type that wraps a single field, or maybe has no fields at all. As the code grows, fill in more detail in the new type by adding fields and methods. With each type that we add, you raise the level of abstraction of the code.
  • When a group of values are always used together, group them in a new structure.

Make implicit concepts explicit

When you have a cluster of related objects that work together, package them up in a containing object which hides the complexity in an abstraction that allows you to program at a higher level.

The process of making an implicit concept concrete has following benefits:

  1. You have to give it a name which helps you understand the domain a little better.
  2. You can scope dependencies more clearly, as you se the concept boundaries.
  3. You can test the new composite object directly without worrying about the internals.

Why start with tests

  • Starting with a test means that we have to describe what we want to achieve before we consider how. This focus helps us maintain the right level of abstraction for the target object.
  • It also helps us with information hiding as we have to decide what needs to be visible from outside the object.
  • To construct an object for a unit test, we have to pass its dependencies to it, which means that we have to know what they are. If an object has too many implicit dependencies, it will be painful to prepare for testing, forcing us to clean it up.

That's a wrap. I hope you found my notes helpful and you learned something new. That said, to fully understand and start implementing the concepts, buy the book and read it. You'll thank me later.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I reply to all emails I get from developers, and I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. Your email is respected, never shared, rented, sold or spammed. If you're already a subscriber, thank you.