Contrary to popular belief, the purpose of unit tests is not to verify correctness of code. Correctness can only be determined by a human. Code can be beautifully engineered and thoroughly unit tested, yet incorrect. The purpose of unit testing is more nuanced: to prevent changes to the expected behavior. When we write unit tests, we are formally specifying the expected behaviors of the testSubject. Whether of not these expectations are the correct expectations is purely subjective. When all of the expected behavior of our code is being witnessed by unit tests, we have a ‘safety net’ in place that allows us to add new expected behaviors and refactor existing expected behavior implementations with confidence that all of the preexisting desired behavior remains in place.
Unit Tests fall into 3 main categories:
- Tests for methods that have a return value and no side effects. These are the most straightforward methods to test. ‘assertEquals(expectedReturnValue, actualReturnValue)’ is used to test this type of method. ‘verify(…)’ statements are not needed for this method type. Using verify() during tests for this method type is only making the test fragile because you are locking the behavior into a certain implementation unnecessarily.
- Tests for methods with side effects only. This method type is characterized by by its ‘void’ return type. These methods must have some side effect(s) that we must verify, otherwise they would be dead code. Testing this method type does require using verify() to witness a certain implementation in most cases. In our example, we must verify that a certain DAO method is called with specific parameters to persist some data when ‘saveExampleVO()’ is called. We want a test to fail if this internal behavior is altered or goes missing without also updating the corresponding tests to match our new definition of expected behavior. A better technique is to avoid creating or using void return type methods. Our example could be altered such that mockExampleDao returns some success/failure message and we could assert() that testSubject.saveExampleVO() returns the value from the injected mockExampleDao instead of validating the implementation.
- Tests for methods that return a value and also have unrelated side effects. This is just bad coding style. This method type violates the single responsibility principle and should be avoided. Both assert() and verify() statement are required to witness the desirable behavior in this case. Its best to refactor this type of method instead.
“That’s not Testable…” – this really means “I don’t want to or don’t know how to unit test that”. Difficulty with unit testing a class is a code smell, usually too many behaviors packed into long methods that don’t inject their dependencies. Refactor by decomposition until the code is testable. Avoid using non OOP concepts like static method calls and procedural programming style.
Strive to test the public interface behavior only. If a bug exists in the class that in no way shape or form is externally observable by testing its public interface, is it really a bug after all? Write tests that witness desirable external behaviors, but avoid enforcing any particular implementation. Ideally we want to leave the implementation free to be refactored without failing any tests, as long as the externally observable behavior remains unchanged. As long as the implementation still produces the ‘right answer’ we are not concerned with how the result was produced. Tests that fail during refactoring are fragile and discourage code cleanup activities. ‘verify(…)’ statements in tests are a sign that the test is enforcing a particular implementation. ‘verify(…)’ should be avoided unless there is a desirable side effect that must be in place. Class methods with side effects should really be avoided anyway.
Unit tests execute in isolation. Unit tests do not run within any servlet container. They do not interact with any database. The instance of a class being tested doesn’t even interact with real instances of its own dependencies. The idea is to completely isolate the testSubject and replace all dependencies with ‘imposters’ (aka mock objects) during test execution. Any test that is witnessing the interactions between 2 or more genuine class implementations is not a unit test but rather some form of integration test.
Focus on edge/boundry cases, that’s prime bug hunting territory.
Don’t forget to test the sad path too. Tests should also witness desirable failure conditions, such as exceptions being thrown when expected. What’s the desired behavior with input is null? EmptyString? Whitespace?
Naming is important: Good test names should describe the behavior that is being witnessed, but not call out any details about the implementation of the behavior. For example testServiceReturnsExpectedValue() instead of testServiceReturnsValueFromDao()
Limitations of Unit Testing:
No form of testing can prove no additional undesirable behavior is present, only that all desirable behavior remains present. (The Volkswagon emissions scandal is a prime case study exhibiting the problem)
Testing that loops through every input possibility just takes too long to execute. We must spot check, focusing on edge cases.