Crafting Clean Tests. About documentation at Mercadona Tech

Alejandro Capdevila
Mercadona Tech
Published in
5 min readJul 31, 2023

Introduction

In my organization we have found traditional documentation (referring to typical user manuals with hundreds of pages) not to be very useful, unless in specific cases. Our encounters with documentation have lead to confusion and incorrect assumptions about the application’s functionality.

Coders, let’s be honest, tend to be disorganized and documentation can easily become outdated due to laziness or forgetfulness. Furthermore, any time invested in writing documentation that nobody will read is wasted time that could have been used to add value to your application.

Much is said about the importance of tests, especially when developing features and verifying that they perform as intended in conjunction with techniques like TDD. However, an aspect often overlooked is using tests as documentation for the project.

Using tests to understand what your code does

Tests have a very powerful utility in that they serve as the source of truth about what your code does. They are truthful and reliable: if they indicate that your code does something, it is indeed doing that. Additionally, the tests will be updated simultaneously with the production code.

This makes them excellent documentation tools. Whenever someone delves into an unknown piece of code, it should be easy to understand all the different use cases for which that code was created.

However, in many cases, we receive a reality check. This is not just about the necessity of having tests (if you don’t have them, you should begin creating them as soon as possible) but also about their readability.

We all know that productive code should be carefully crafted and strive for the highest quality possible. However, tests often get neglected, like the middle child who never receives attention from their parents. As the code evolves, tests can become complex and mutate without receiving proper review, resulting in unpalatable spaghetti code that no one wants to read. It can be a daunting task to make changes to these tests.

Taking care of the tests

That’s why we must avoid reaching that point. That’s why, when working with TDD and we are working in the refactoring phase, we must also refactor the tests.

Here I’m pointing out some simple techniques that can help in that tedious task. But there are many more that can be applied.

Naming

A simple change to a variable or method name can often be the difference between incomprehensible and clear code.

For example, given the following code:

    def test_admin(self):
user1 = User.objects.create(name="user1", type="regular_user", registry_date=datetime(2015, 1, 23))
user2 = User.objects.create(name="user2", type="admin", registry_date=datetime(2021, 8, 11))
user3 = User.objects.create(name="user1", type="regular_user", registry_date=datetime(2015, 1, 23))

users = get_them()

assert users == [user2]

Can you say, in just one look, what is that test verifying? Or do you have to stop a second, and check the details of the creation of the users to know the difference between them?

Now let’s take a look at a different version of that same test:

def test_retrieve_admin_users(self):
regular_user = User.objects.create(name="user1", type="regular_user", registry_date=datetime(2015, 1, 23))
admin_user = User.objects.create(name="user2", type="admin", registry_date=datetime(2021, 8, 11))
another_regular_user = User.objects.create(name="user1", type="regular_user", registry_date=datetime(2015, 1, 23))

adim_users = retrieve_admin_users()

assert adim_users == [admin_user]

Here, you don’t need to check the details of the users. It’s easy to see that the second one is the admin, which you except to retrieve after calling the action. The name of the test has also been changed to reflect what is being tested.

Extract to method

Another simple step is to extract repeated elements in our tests to a common function that adds semantic meaning to our actions.

So, if we have several tests where we do a similar setup:

class MySuperAwesomeUserTests:

def test_an_awesome_test(self):
john = # a user with some specific configuration
alice = # another user with a different configuration

# testing and asserting something

def test_another_awesome_test(self):
john = # a user with some specific configuration
alice = # another user with a different configuration

# testing and asserting another thing

# a lot more super awesome tests here with a similar setup

It can be pretty helpful to move that setup to a standard function:

class MySuperAwesomeUserTests:

def _setup_initial_users(self):
john = # a user with some specific configuration
alice = # another user with a different configuration

def test_an_awesome_test(self):
self._setup_initial_users()

# testing and asserting something

def test_another_awesome_test(self):
self._setup_initial_users()

# testing and asserting another thing

# a lot more super awesome tests here with a similar setupBuilder pattern

Builder pattern

Often, we need to create a series of objects that serve as subjects for our tests. However, constructing these objects can be complex, and it may be hard to tell the differences between objects in one test and those in another. We can use the builder pattern to simplify matters to create these objects.

So, for example, if we have a user model like this one:

@dataclasses
class User:
name: str
type: str
registry_date: datetime

We can create a builder class for this model:

class UserBuilder:
_name: str
_type: str
_registry_date: datetime

def __init__(self):
self._registry_date = datetime(2020, 1, 1)

def with_name(self, name: str):
self._name = name
return self

def with_type(self, status: str):
self._type = status
return self

def with_registry_date(self, registry_date: datetime):
self._registry_date = registry_date
return self

def build(self):
return User(name=self._name, type=self._type, registry_date=self._registry_date)

In that way, when having to create users for our tests, it could look something like this:

some_user = UserBuilder().with_name('John').with_type('regular_user').with_registry_date(datetime(2023, 7, 14))

So, as the model gets more complex, it’s really easy to keep creating our custom objects without much complexity. We can even set some default parameters in the builder constructor, as I did with the registry_date.

Factory pattern

In combination with the builder pattern, another very useful pattern for creating objects is the factory pattern. So, for example, the team can reach a consensus that John is a registered user and Alice is a system administrator.

So, if we reuse the builder from the previous example, we could create something like this:

class UserFactory:
def create_standard(self):
return UserBuilder().with_name('John').with_type('standard').with_registry_date(datetime(2023, 7, 14))

def create_admin(self):
return UserBuilder().with_name('Alice').with_type('admin').with_registry_date(datetime(2021, 5, 19))

And so, in our tests, we could have this:

john: User = UserFactory().create_standard()
alice: User = UserFactory().create_admin()

With this, even if the definition of the model changes and it expands over time, we can keep things really simple in our tests, as we will need to change the builder and the factory, and not every test where the users are being used.

Conclusions

Documentation might be a necessary evil in some contexts where, for example, engineers do not work in long-term squads, development is externalized and/or someone non-technical is expected to be able to read it.

However, I invite teams to reflect on the time spent on maintaining documentation, and instead use well crafted tests as a suitable and more effective alternative to document the code’s purpose.

--

--