The primary purpose of unit testing is to help us verify that the code we wrote actually does what we want it to do. For example, if we pass a specific input into a function, we are able to verify that we are getting the expected result back.
Beyond verifying that the code is working as expected, unit tests gives us other advantages as well:
- Stability: Whenever we modify existing code, we may unintentionally change some underlying code that impacts another part of the software. This type of situation may easily lead to bugs appearing in what was considered to be a previously stable component. However, by using unit tests to cover as much code as possible, we are able to detect these types of surprises, and fix them early in the development process.
- Pseudo-Documentation: By setting up unit tests based on expected usage of the code, we provide a form of documentation for other people who have to maintain the code at a later date. In my career, I have often had to investigate code as part of an audit. By examining the unit tests I was able to quickly ascertain the code's intended use - allowing me ultimately resolve the audit in much less time.
Quick Rules for Unit Testing
- Keep the test narrow: ideally, unit tests should be small pieces of code that test one thing. A test input will be passed into a function, and the result will be returned and verified. Note that a single unit test may appear to verify more than one thing. For example, if a function being tested is supposed to return an array with 3 elements, then the length of the array should be verified, as well as each element.
- Focus on planed usage cases: as mentioned above, this gives a form of pseudo-documentation for how to use the code.
- Use test data the reflects actual use cases: using correct data will help us ensure the code will behave correctly in production. Furthermore, it will help reinforce the pseudo-documentation.
- Cover edge cases: these are the test that will detect issues like off-by-one errors, or other "silly" mistakes.
- Cover all code paths through a function: if there are three possible paths through a function, then there should be a minimum of 3 unit tests. If the number of possible paths seems to be high, then that is an indicator that the code should be refactored.
- Don't test simple getters and setters: all a simple getter or setter does is use an assignment operator. Furthermore, all of the getters and setters are very likely to be used when other parts are being unit tests. Thus, they are at least being implicitly tested anyway.
- Do test getters or setters that have logic in them: if the setter is validating the passed in value, then the validation logic needs to be tested. If the getter is using logic to determine its response, then that logic needs to be tested.
- Unit tests should have no side effects: if a unit test has a side effect, such as causing a global value to be set, then it may impact later tests. This can cause problems to appear and disappear randomly based on the order in which unit tests are ran.
- Unit tests should have no external dependencies: if a piece of code has an external dependency, then that dependency should be mocked. For example, you are writing a test for code that calls a database for information. You need to create a mock object that will represent the connection to the database, and have the mock return a set of data appropriate to the unit test. Now the test may be ran at anytime, and we don't have to worry about if the database is actually available or not. Furthermore, we don't have to worry about what data is in the database either.