Growing Object-Oriented Software, Guided By Tests (chapter 20)
Feb 23, 22We now move to the section of the book “Sustainable test-driven development”.
We want to make sure that our test code is easy to maintain and reason about. We don’t want it to be a drag on our development.
We should strive to keep the test code as clean as the production code (albeit coded with different styles).
Difficulty in testing might indicate a problem with our test structure, but it could also indicate that the design of our system could be improved.
If you are finding it hard to test a piece of functionality, rather than make the tests more complex to accommodate the system, ask if the code under test could be refactored to make it simpler.
If a system is easy to test usually it is easy to change.
Use testing to drive your design.
A grab bag of testing tips:
-
Don’t rely on singletons within production code, make sure you are injecting dependencies. E.g if you want to stub out dependencies this is a lot easier if they are being passed in! Think about how we would test timezones within a system, if the date is hardcoded in a method, you will have to wait until that date arrives to test the functionality! If we pass in a stubbed date object, we control the time hehe.
-
From there, we could also find behaviour that can be extracted to its own class (with tests!)
-
A goal of the OO style is to have clear boundaries between objects. If we hard code in a dependency within a class this is blurring the lines of the boundaries and will make it hard to test the system. Avoid this wherever possible (i.e all the time heh).
-
Use the same dependency extraction tips in production and test code. You should be test driving the development, so it will be incremental change
-
Logging should be separated from the main heft of the program. Use the same techniques above and perhaps extract a logging interface to be used around the system (where required), rather than calling the native
log
methods within your working objects. Formatting log code within another object is a definite code smell. Do it within anAdminLog
class that implementsILogger
or something similar! -
Avoid mocking concrete classes. We should be coding to abstractions, not concretions. If we test against a mock concrete class we lose information about the relationship between objects. Better to extract a named interface and use this (will help to show behaviour). Once something has a name, we can talk about it.
-
exception to the above rule If you are working with an external library that you can’t change. However, it might be better to create a wrapper around the library and test your class
-
Don’t overwrite internal class methods in tests! This obscures behaviour!
-
Don’t mock values -> they should be immutable anyway! Just instantiate the value object and pass it to the test.
- Try to refactor bloated constructors by extracting implicit components into specialised classes - using these heuristics as a guide:
- Package together arguments that are always used together in the class with the bloated constructor.
- Package together arguments that have the same lifetime in the class with the bloated constructor. Then give your new extracted class a meaningful name, and inject this into the previously bloated constructor ;) If our design is good this should be a relatively simple change.
-
If a class is doing too many things it may be a
confused object
. The class will probably be violating the single responsibility principle, so should be refactored as soon as possible to make the system easier to test. -
If a class appears to have too many dependencies, perhaps there is a way to initialize the fields with sensible defaults, and then use public methods to change these after instantiation.
- Try to write fewer expectations and assertions in tests - this might involve refactoring some production code! We could also try and stub out behaviour, rather than expecting certain behaviour in peer objects.
We should hopefully find these benefits if we are ‘listening to our tests’:
- Keep knowledge local: small composable objects that follow SRP (easy to test, easy to change)
- Naming of behaviour: the key is to give names to the behaviour that happens between objects. Give this behaviour a name and test it.
- More names mean more information: if we name the behaviours well, we will have a clearer picture of how the system is functioning.
- Pass behaviour, rather than data: let an object decide what to do with its dependencies via callbacks - don’t just shove the data in.
- More precise interfaces give us better information hiding and clearer abstractions.
## It’s much easier to keep a codebase clean and well tested as you go than have to clear up a mess at the end! ** will also make it easier to change ;) **
Tests should help, rather than hinder development. If you are finding it hard to write the tests, maybe it could be time to look at the system itself?