Wed 8th January 2020
Table of Contents
- 1. What Is the Point of Test-Driven Development?
- 2. Test-Driven Development with Objects
- 3. An Introduction to the Tools
- 4. Kick-Starting the Test-Driven Cycle
- 5. Maintaining the Test-Driven Cycle
- 6. Object Oriented Style
- 7. Achieving Object-Oriented Design
1. What Is the Point of Test-Driven Development?
The loop is:
- Write a failing unit test.
- Make the test pass.
- Refactor.
Writing tests first:
- Makes us clarify what we’re trying to achieve.
- Lets us stop programming when we’ve done enough.
- Encourages us to write loosely coupled components because we start off by writing them in isolation.
- Gives a spec of what the code is meant to do.
- Creates a complete test suite for catching regressions.
The golden rule of TDD:
Never write new functionality without a failing test.
The Bigger Picture
For developing features, we have a similar loop, but with acceptance tests instead of unit tests. This should ideally be end-to-end, interacting from the system from the outside.
Levels of Testing
- Acceptance testing: Does the whole system work?
- Integration testing: Does our code work against code we can’t change?
- Unit testing: Do our objects do the right things? Are they nice to use?
External and Internal Quality
External Quality is how well a system meets the needs of its users - does it do the right thing, is it fast, is it reliable. Internal Quality is how well a system meets the needs of its developers - is it easy to understand, is it easy to work with.
Acceptance testing speaks to external quality. Unit testing speaks to internal quality. Integration testing is somewhere in the middle.
Coupling and Cohesion
These are two measures of how hard it will be to change some code. Elements are tightly coupled if a change in one necessitates a change in another. An element is cohesive if it has clear and well defined responsibilities.
2. Test-Driven Development with Objects
OOP focuses on the communication between objects.
It’s important to distinguish between Values and Objects. Values are immutable instances that model fixed quantities - they are equal if their contents are equal. Objects can change and have an identity.
In Java, object Roles are defined by Interfaces. A Role is a set of related Responsibilities. An Object implements one or more Roles. A Collaboration is an interaction between Objects/Roles.
A simple design technique is CRC cards. Create index cards for each Candidate (potential class) and write a list of Responsibilities and Collaborators on them.
Tell, Don’t Ask
A calling object should describe what it wants in terms of the object it is calling. For example:
((EditSaveCustomizer) master.getModelisable()
.getDockablePanel()
.getCustomizer())
.getSaveItem().setEnabled(Boolean.FALSE.booleanValue());
Should become:
master.allowSavingOfCustomisations();
This is the Law of Demeter. It allows you to swap objects around more easily as it doesn’t bind your class to the implementations of the class you’re calling. It also makes the interactions between objects explicit.
Support for TDD with Mock Objects
Since a component’s state is internal to it, the best way to test it is to provide Mock objects and test its interactions with these mocks.
3. An Introduction to the Tools
A test fixture is the fixed state that exists at the start of a test.
4. Kick-Starting the Test-Driven Cycle
A walking skeleton is an implementation of the smallest amount of real functionality that can automatically build, deploy and test end-to-end. The functionality is trivially simple so we can concentrate on infrastructure. Eg. for a database backed web app, it would show a flat web page with info from the database.
We want our test to start from scratch, build a deployable system, deploy it and run the tests. Creating this will make us think somewhat of the overall structure (but not design everything at the outset).
Creating the walking skeleton will take a fair amount of time - it will include a bunch of set up and infrastructure choices.
5. Maintaining the Test-Driven Cycle
Start writing each feature as a failing acceptance test. This should be written in terms of the application’s domain, not knowing about any of the underlying technologies.
Start testing with the simplest success case. Keep a notepad to jot down failure cases/refactorings and other technical tasks as you program.
When writing the failing test, concentrate on making it easy to read and understand. When it reads well, you can then concentrate on infrastructure to support the test.
Run the test first, to make sure it fails and does so in the expected way.
You should unit-test behaviours/features, not methods.
If a unit/integration test is hard to write, pay attention to why and how the design can change to make it easier.
6. Object Oriented Style
Separation of concerns - when we change the behaviour of a system, we should only change as little code as possible. Also think about moving to Higher levels of abstraction.
Cockburn’s ports and adapters keeps code for business is isolated from the technical infrastructure:
- Ports are interfaces to the business logic, described in terms of the business logic.
- Adapters are bridges between the application core and the technical parts.
It’s used in hexagonal architecture. The difference between hexagonal architecture and layered architecture is that if two technologies need to talk to each other, they do so through the business layer. Business logic is described in terms of what business people understand.
It can be a pain mapping between the domain models of objects (eg coping the data from the business User
class to the database User
class), but there are tools to help with this.
If you’re in a different domain you probably shouldn’t be using the same objects.
Eg. ProtoBuffers only be used for communication, not as a domain object.
Encapsulation and Information Hiding:
- Encapsulation - An object can only be affected through its API. We can control how much a change to an object will impact other code.
- Information Hiding - Abstracts away how the object works, this lets us ignore the low level details.
You can break encapsulation by sharing references to mutable objects (aliasing). You should define immutable value types, avoid global variables, perform defensive copies.
An object’s Peers can roughly be split into:
- Dependencies, services that the object requires. You can’t create the object without them.
- Notifications (observers), these are fire-and-forget, the object doesn’t care which ones are listening.
- Adjustments, things that change the object’s behaviour - like policy objects (or delegates).
Dependencies should be passed into the constructor, notifications and adjustments can be set/added.
Objects should be context independent - they should not have built in knowledge about the surrounding system.
Keep a record of architecture decisions.
7. Achieving Object-Oriented Design
How Writing a Test First Helps the Design
Starting with a test gets us to describe what we want to achieve, not how. Keeping unit tests clear keeps us at the right level of abstraction and information hiding. Keeping them short means that we limit the scope of the object.
To construct the object for testing, we’ve got to pass in its dependencies. By considering how you’d make the object in an environment other than its main one, you help with context independence.
Communication over Classification
As mentioned before, how objects communicate is more important than class structure.
An interface dictates if two objects will fit together, a protocol says if they will work together. By using mocks (eg, checking that methods of the dependencies are called), we make this visible.
Value Types
Values are immutable (and so don’t have an identity), objects have state, so they have identity and therefore relationships. Brief shout out to Domain Driven Design - you should use Value types for concepts in the domain model.
This is how Value types come about through refactoring:
- Breaking Out - when the code is getting too complex, break some behaviour out into helper types (why are these values though?).
- Budding Off - when we’re exploring the code, introduce a type with no fields to pass around as a concept. Then you can add to it later.
- Bundling Up - when you notice that groups of values are used together.
Object Types
Similar to the above:
- Breaking Out - you can start coding without much regard for structure, create a passing unit test and then start breaking the concerns out into smaller chunks.
- Budding Off - we find that a class has a dependency that doesn’t exist yet. We create it as an interface and use that in our class, discovering what methods it needs.
- Bundling Up - creating classes that package a bunch of other related objects. We can also test this object directly and create a mock of the entire object for the rest of the code to use in tests.
Identify relationships with interfaces
Interfaces should be as narrow as possible (this tends to happen with the Budding Off approach above).
Having an interface named Thing
and an implementation named ThingImpl
is probably a sign of bad design.
The interface should be described in general, domain language and the implementation should have something specific about it to use in the name.
Refactor interfaces too
As you gather interfaces, consider whether similar interfaces are really the same and if so combine them. If not, make them more different.
Building up to higher-level programming
Split code into an implementation layer and a declarative layer. The implementation layer is the graph of objects. The declarative layer builds up this graph and you can make a bunch of convenience functions to make that nicer - it becomes a DSL.
And what about classes?
We don’t consider class hierarchies in the design of the system - they arise out of refactoring common code (although delegation is usually better).