I often hear developers justify themselves with different arguments as to why they don't write tests.
The most popular of these are:
- I'm sure my code is written without bugs
- Writing tests is difficult
- Businesses don't want to spend money on writing tests
- I don't understand how to write them
- Here can be your option
Just forget about it! Tests are the mandatory part of your code and your responsibility as a software engineer.
In this article I'd like to describe how we test our code in the fintech start-up, which allows us to guarantee a high level of confidence that any regression is safe for us.
Let's start with some basic concepts and principles
Why do you need unit-tests?
-
Tests help make your production more stable
- regression testing
- contract fixing
-
Tests increase the usability of development
- faster feedback
- simpler refactoring
-
Tests organise poorly written code
- make you think about test cases
- make you think about code quality
What does a quality unit-test look like?
- easy to understand
- fail on bugs
- resistant to refactoring
- fast
- environment independent
What does a normal test look like?
- hard to understand
- doesn't fail on bugs
- any refactoring forces you to fix the tests
- slow
- environment dependent
So how do you write a quality unit-test?
Use contracts. A unit contract is the unit's expectations about you and also your expectations about the unit. Hence your code must be contractual if you want to test it with unit-tests.
The contract terms you need to test are:
- Useful work (e.g. database calls or other third party services)
- Expected result
- Result on boundary conditions
- Exception conditions
Try to test your class contract, not its implementation.
The functions you unit-test must be clean, otherwise your unit-tests will test your function's implementation.
A clean function a.k.a. deterministic function - function always give the same output (Y) for an input (X). Nondeterministic functions produce variable outputs. Deterministic means the opposite of randomness, giving the same results every time and without interacting with the environment in any way, for example:
Input: [1, 2, 3, 4] > array_reverse > Output: [4, 3, 2, 1]
This is the example of a good unit-test which tests a clean function:
final class ItemGrouperTest extends TestCase
{
public function testGroupReturnsGroupedItemsGroupedByNameAndCurrency(): void
{
$itemGrouper = new ItemGrouper();
$items = [
$item1 = new Item(1, 'Nike AF 1', 'GBP', 13000),
$item2 = new Item(1, 'Nike AF 2', 'GBP', 13000),
$item3 = new Item(1, 'Nike AF 3', 'EUR', 14000),
$item4 = new Item(2, 'Nike AF 1', 'GBP', 13000),
];
$groupedItems = $itemGrouper->group(
ItemGrouper::GROUP_BY_NAME & ItemGrouper::GROUP_BY_CURRENCY,
...$items
);
$this->assertCount(3, $groupedItems);
$this->assertCount(2, $groupedItems[0]->getItems());
$this->assertContains($item1, $groupedItems[0]->getItems());
$this->assertContains($item2, $groupedItems[0]->getItems());
$this->assertCount(1, $groupedItems[1]->getItems());
$this->assertContains($item3, $groupedItems[1]->getItems());
$this->assertCount(1, $groupedItems[2]->getItems());
$this->assertContains($item4, $groupedItems[2]->getItems());
}
}
We know the behaviour of our method, we give the input data and we check the expected result, perfect! This is the test I would always like to see in a deterministic function.
But what about functions that aren't clean?
These functions are usually called in service classes or as they are also named class-manager. They perform more complex operations: call one or more external services, aggregate results of these external services, use results through chain calls, etc.
Here is the description of such class:
A controller class that calls the external service userRepository to get the list of users, then this list of users is passed through usersFormatter class to prepare a response.
If we follow the basic rules for these classes, we can easily write such tests.
The basic rules are:
- All external dependencies must be mocked.
- We test the external dependency solely by contract: we check the call, the expected result and the exception conditions.
Let's write the tests for given class above:
final class UserControllerTest extends TestCase
{
public function testListActionReturnsSuccessfulJsonResponseAndCallsAllRelatedDependencies(): void
{
$userController = new UserController(
$userRepository = $this->createMock(UserRepository::class),
$usersFormatter = $this->createMock(UsersFormatter::class),
);
$userController->setContainer($this->createMock(ContainerInterface::class));
$request = $this->createMock(Request::class);
$userRepository->expects($this->once())
->method('getList')
->willReturn($users = [
$this->createMock(User::class),
$this->createMock(User::class),
]);
$usersFormatter->expects($this->once())
->method('format')
->with(...$users)
->willReturn($formatterResponse = [
'key' => 'any response here',
'why' => 'cuz we dont case about result of 3-rd party services here, just any expected results'
]);
$response = $userController->listAction($request);
$expectedJson = json_encode($formatterResponse, JsonResponse::DEFAULT_ENCODING_OPTIONS);
$this->assertSame($expectedJson, $response->getContent());
}
}
Conclusion
Try to write more deterministic methods that are easily covered by tests.
Non-deterministic methods shouldn't be left untested, simple checks for expected behaviour can save your code from unexpected bugs and errors.
And remember the better your code is covered by tests, than more you are protected from future bugs and refactoring.
Top comments (0)