Best Practice for writing Unit tests
Unit tests through Testable Properties
The goals of a unit test are to:
- Give a human-readable and complete specification of how the unit works
- Use code to ensure that the unit works as expected
We write the human-readable and complete specification as a number of testable properties.
A testable property is:
- A concise statement about the behavior of the unit, that can be tested to be either true or false.
Each testable property typically becomes one test case in the unit test.
Group testable properties by features
When a system has complex behavior, it will also have many testable properties. In this case it is best to organize these in groups. The best organizing principle is to identify features of the unit that either build on top of each other or are independent of each other. Grouping testable properties by features will make them easier to read, and make it easier to ensure that they completely specify each feature.
When writing unit tests, the structure of the test code should clearly communicate the features and testable properties.
For more info see Structure and Interpretation of Test Cases - Kevlin Henney
Coverage and Mutation testing
Our pipeline enforces 100% coverage and performs mutation testing of the code.
Note
The feedback from the pipeline on your unit tests should primarily be considered as hints about testable properties that you have missed in your test design.
Good Example: A token contract (subset)
This is an example of how to split functionality into features and write concise testable properties.
Feature 1: Transfer Tokens
- A user can transfer tokens to another user, which updates the balances.
- If a user transfers zero tokens no balances are updated.
- A user cannot transfer more tokens than she owns.
- A user cannot transfer a negative amount of tokens.
Feature 2: Approvals and Transfer From
- A user can approve another user to transfer a number of tokens from her account, which updates the allowances.
- A user that is approved to transfer from another user can perform a transfer, and the allowance amount is reduced accordingly.
- A user that is not approved to transfer from another user cannot transfer tokens from that user.
- A user that is approved to transfer from another user cannot transfer more tokens than allowed.
Bad Example (Not testable)
These are not testable properties, since they do not accurately describe the conditions and expected outcome.
- Add allowance to another user.
- Transfers to another account and asserts.
- Transfer negative amount of tokens, fails.
Test Doubles
A Test Double is any kind of pretend object used in place of a real object for testing purposes.
When using test doubles their name should be suffixed with the most descriptive name from the list below, i.e. TransactionContextFake
.
The following kinds of test doubles exist:
- Dummy objects are passed around but never actually used. Usually they are just used to fill parameter lists.
- Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an in memory database is a good example).
- Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test.
- Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
- Mocks are objects pre-programmed with expectations which form a specification of the calls they are expected to receive.
For more info see Mocks Aren't Stubs - Martin Fowler
Coding the Test setup
The test setup creates a good base state for testing the features. Minimize the number of different setup-states that different test-cases start from.
Coding the Test cases
The test cases should follow the structure of features and testable properties.
- Test-cases for each feature should be grouped. Using a nested class or a separate file are great ways of grouping test-cases for a feature in Java. When using nested classes, the sequence of features should be from simple to complex.
- Each test-case should test a single testable property.
- Each test-case should aim to be self-contained and readable in isolation. (See Test setup)
- The sequence of the test-cases should be from simple positive cases to complex negative cases. Choose the sequence that best communicates the specification of the intended behaviour.
- Test-cases should have short names that reflect the testable property. Do not include "test" in the name.
- Each test-case should explicitly state the testable property that is being tested, using a one-line comment in Java and any other language.
- The optimal code inside a test-case is short and has 3 parts: a) prepare the unit b) execute an action c) assert the expected outcome
- Use the terminology of the unit under test in the test code and the comments.
Code example
You can see a code example here Token Contract Test.