Unit Testing and Design
These videos from Misko Hevery describe how we can write testable code. They also explain how the act of writing testable code improves the design.
- An Introduction to Unit Testing
- Global State and Singletons Make the Code Untestable
- Law of Demeter and Dependency Injection
- Inheritance, Polymorphism and Testing
- Psychology of Testing: Tests as Executable Specifications
An Introduction to Unit Testing
- What makes code hard to test?
- Using new operator in business logic
- Looking up other objects in business logic
- Doing work in a constructor
- Global state/singletons
- Static methods (essentially procedural programming)
- Deep inheritance trees
- Too many conditionals
- What makes code easier to test?
- Class dependencies are explicitly specified. This helps the unit test code instantiate a small subset of fake objects to verify the class under test
- Business logic and object graph construction logic are completely separate
- Dependency injection
Comparison of different test techniques:
|Attribute||Scenario testing||Functional/Subsystem testing||Unit testing|
|Scope||Test the system as a user.||Replace the external dependencies of the subsystem with dummy entities.||Test individual classes|
|Speed of debugging when a test fails||Takes a long time to identify and fix the bug. Need to hunt for the bug in the entire system,||Identifies the bug right down to the subsystem level. It takes time to identify the bug within the subsystem.||Identifies the bug right down to the method level|
|Speed of execution||Slow and time consuming||Moderate||Very fast|
|Reliability||Test are flaky. Many times the tests might fail due to issues with the test environment||Less flaky than scenario tests. Issues may be encountered due to dummy interface entities||Tests are crisp; less chance of tests failing due to problems with the test environment|
|Ease of writing tests||Hard||Moderately difficult||Easy|
|Volume of tests||A small set of scenario tests to verify that all subsystems operate together.||A larger set of tests that identify the working of a subsystem.||A large set of tests. The test code almost equal in size to the production code.|
Global State and Singletons Make the Code Untestable
- Singletons/global variables make code hard to unit test
- For unit testing to be reliable, we should write code where repeating an operation produces the same result.
- If performing an operation results in modification of a global state, it is hard to guarantee that repeating the operation will produce the same result.
- Use of singletons results in a deceptive class interface as the true dependencies of the class under test are buried inside the code.
- Each unit test wants to instantiate a small portion of the full system and run a test. When instantiating a small part results in side effects, execution of a test might create side effects that will result in other tests to fail.
- Global state is transitive, any object you obtain from from a global object is deemed global.
- The presentation contains a detailed example that illustrates the ill effects of having a global state.
Law of Demeter and Dependency Injection
- Explicit new calls inhibit unit testing
- The unit tests cannot use dummy and mock objects for dependencies of the class under test
- Dependencies should be passed explicitly to constructors
- The constructors should just store the references the dependencies; they should not be calling methods on the dependencies
- The Law of Demeter states that classes just call methods on their
- Obtaining other objects from dependencies is prohibited
- Bursting dependency injection myths
- The business logic and object graph construction should be completely
- The business logic classes get their dependencies via constructors/methods
- Business logic objects are not aware of the object tree
- No explicit new statements are present in the business logic
- The factory classes are responsible for creating objects with explicit
- The factories also make sure objects are created with valid
- When a new object is requested, the dependencies for that the object might also be created by calling the factory methods for the dependencies.
- Dependencies passed in the constructor should have a lifetime that is same of greater than the object being constructed.
- Frameworks like Guice can be used to automate object graph construction and factories.
Inheritance, Polymorphism and Testing
- Most ifs in the code can be replaced by polymorphism
- With procedural programming, we saw the end of "goto".
- Object oriented programming should lead to elimination of most conditionals in the code
- Exception: Comparisons using primitive types on both sides cannot be replaced with polymorphic behavior
- Code without conditionals is easier to read and test
- There is a fine line here. Deep inheritance hierarchies make the code harder to test and maintain.
- A few rules for reducing conditionals:
- Save callers from checking the return value:
- Do not return NULL from objects, when no operation is desired, return a dummy do nothing object.
- Throw exceptions instead of returning error codes from methods.
- "if" statements checking the type of the object can be replaced with polymorphism
- A switch statement almost always can be replaced with polymorphism
- Multiple if statement checks in the code point to missed polymorphic modeling.
- Save callers from checking the return value:
- In many cases, use of polymorphism shifts an if statement from the
business logic to the factory code
- This improves readability and testability
- Performance is also improved, as the if check is being performed
only at the time of object construction
- Dependency injection frameworks like Guice can take care of factory if statements
Psychology of Testing: Tests as Executable Specifications
- Add tests to code after writing is like trying to add sugar to a cake
after baking it!
- Tests written after the code tend to contain a lot of implementation
- Such tests are very hard to maintain.
- The tests don't read like a story
- Writing tests before the code solves these problems
- Tests written after the code tend to contain a lot of implementation level details.
- Tests should be viewed as executable specifications
- This is also referred to as BDD (Behavior Driven Development)
- Each test tells a story
- The tests are written in a behavior
- Humans are good at generalizing if they are given specific examples on something works. Executable specs serve as the examples for understanding code.
- End to end tests
- Principles that apply to unit testing also work for end to end tests. Components are the entity being tested at this level.
- Dependency injection at component level would simplify end to end testing
- End to end tests tell more complicated stories. An English type DSL helps in telling these stories.