Share

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

  • 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 dependencies
    • Obtaining other objects from dependencies is prohibited
  • Bursting dependency injection myths
  • The business logic and object graph construction should be completely decoupled
    • 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 dependencies
      • 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.
  • 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 level details.
      • Such tests are very hard to maintain.
      • The tests don't read like a story
    • Writing tests before the code solves these problems
  • 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.