Unit Testing Best Practises
Characteristics of a Good Unit Test
Self-checking - a test should automatically determine if it has passed or failed without user interaction
Repeatable - a test should return consistent results provided all input values remain the same between test runs
Fast - tests should not require much time to execute
Timely - unit tests should not require a disproportionate amount of time to write compared to the code that they are testing. If tests are taking a long time to write compared to the code being tested, consider refactoring the code to be more testable.
Isolated - unit tests should not depend on outside factors and be run in isolation
Code Coverage
A high code coverage cannot determine the quality of the code, but is simply a metric of the amount of code being tested. Not all methods need to be tested (eg: accessor methods), but it is important to maintain as high of a code coverage metric as possible on TESTABLE code to help maintain the stability of the software as it grows and is adapted.
Best Practises
Try to avoid introducing dependencies on infrastructure when writing unit tests. These make tests slow and possibly unreliable and should be reserved to integration tests.
Naming Conventions
The name of a test should consist of 2-3 parts:
the name of the method being tested
the condition under which the test occurs (optional)
the expected behaviour under the condition invoked
Naming standards are important as they verbosely express the intent of a test.
Example of proper naming conventions:
Example of improper naming convention:
Arrangement Within a Test
Arrange, Act, Assert is a common pattern when unit testing. As the name implies, it consists of three main actions:
Arrange your objects, creating and setting them up as necessary.
Act on an object.
Assert that something is as expected.
Example of improper test design:
Example of proper test design:
Readability is one of the more important aspects when writing good unit tests. Clearly separating what is being tested from the arrange and assert steps allows for multiple assertions to be performed on a single result.
Separating each of these actions within the test clearly highlight the dependencies required to call your code, how your code is being called, and what you are trying to assert. While it may be possible to combine some steps and reduce the size of your test, the primary goal is to make the test as readable as possible.
Avoid Literal Strings
Naming of variables in unit tests is just as important as naming conventions in code (if not more so). Unit tests should never contain literal strings. This removes the need for the reader of the test to inspect the code to determine what makes the value important.
example of string literal (no indication of reasoning):
For Codeception an example of proper reason indication would be:
Avoid Logic in Tests
When writing your unit tests avoid manual string concatenation and logical conditions such as if
, while
, for
, switch
, etc.
This reduces the chance to introduce a bug inside of your tests. It also focuses more on the result rather than the implementation details.
When you introduce logic into your test suite, the chance of introducing a bug into it increases dramatically. The last place that you want to find a bug is within your test suite. You should have a high level of confidence that your tests work, otherwise, you will not trust them. Tests that you do not trust, do not provide any value. When a test fails, you want to have a sense that something is actually wrong with your code and that it cannot be ignored.
Tip: If logic in your test is unavoidable consider splitting the test up into 2 or more separate tests
Avoid Multiple 'Acts' Inside of a Test
Each test should perform only one 'act' in a test. That means a separate test should be written for each Act.
Why?
If a test fails it may not be clear which act is failing
It ensures each test is focused on a single condition
Ensures the name of the test matches the Act
Example of test with multiple acts:
Example of tests properly segregated acts:
Avoid Validating Private Methods
There should be no need to test a private method. Private methods are an implementation detail. At some point in the code there will be a public method that calls the private method as part of its process. What matters is the end result from the public facing method. If you feel the desire to test a private method you are leaning towards debugging, which is not the intent of a unit test.
Code Coverage
All aspects of a method should be properly covered with unit tests. That means every if/else
, try/catch
, switch/case
, and return
should have a corresponding test provided. This is part of the process of 'proving' the code works as expected.
example:
Every case in the above example has a test to ensure maximum coverage.
If we neglected to write a test for 'grape' the code coverage map would indicate the omission.
File Locations
For Unit and Integration tests (API repository) it is advisable to follow the namespace path of the file being tested.
File Naming Conventions
File names should be verbose in identifying which class they are testing.
For Unit tests it is recommended that files match the name of the class plus the word 'Test'.
example:
CurriculumCourseControllerTest
will test the CurriculumCourseController.
LotteriesServiceTest
will test the LotteriesService.
Establishing a Baseline
It is recommended to prove the method works as intended under normal operating conditions. The first tests on the page is often the 'baseline' test to show that a class's methods work as intended, followed by the 'challenge' tests that are aimed at proving the code functions as intended under abnormal conditions.
Data Providers
Data providers are used when the same test is executed with different data inputs and expected results. This is useful when you are testing a method that has multiple if/else or switch/case statements.
Instead of duplicating the test method and changing the inputs, a Data Provider can be used:
More information on Data Providers
TL;DR
Readability is key
Avoid literal strings in favour of const
values
A test method name should consist of 2-3 parts
A test method should do 3 things: Arrange, Act, Assert
Only 1 Act per test
Avoid 'logic' in tests (loops, etc)
Every logic branch if/else
, switch/case
, try/catch
should have a corresponding test
File paths should reflect the path of the class being tested
Filenames should reflect the name of the class
Use a Data Provider when repeating tests on a function with varied arguments passed
Last updated