Unit Tests: Beyond the Calculator Example

Leonardo Yoshida Taminato
ProFUSION Engineering
9 min readDec 18, 2023

Introduction

Ever noticed how unit testing tutorials always start with those basic math examples, like checking if 1 + 1 equals 2 or if 1 + 2 equals 3? It’s like the initiation ritual into the world of coding tests. But here’s the catch: do we really need to test every possible combination of numbers, even if they pretty much mean the same thing? Let’s be real. When we’re writing unit tests, it’s not about going crazy and testing every single scenario. It’s more like finding a smart balance between testing the basics and handling those tricky edge cases. So, in this article, we’re breaking free from the calculator example and diving into the practical side of unit testing. For that, we’ll be using the UserRegistration use case in the repository created for this clean architecture article.

Arrange-Act-Assert Pattern

The Arrange-Act-Assert (AAA) pattern is a fundamental concept in unit testing that helps organize and structure your test code. This pattern is designed to enhance the readability and maintainability of your tests by dividing them into three distinct sections: Arrange, Act, and Assert.

Arrange

The Arrange phase is where you set up the necessary preconditions and context for your test. This involves creating objects, initializing variables, and preparing the environment to simulate the conditions under which the code being tested will run. This section helps ensure that the system is in a known state before the actual testing begins.

Act

The Act phase is where you execute the specific functionality that you are testing. This involves calling methods or interacting with the system under test¹ based on the preconditions established in the Arrange phase.

Assert

The Assert phase is where you verify if the outcome of the Act phase meets your expectations. This is the critical step where you ensure that the code behaves as intended and produces the expected results.

Here’s an example²:

// Arrange
const dependencies = … // initialize dependencies
const sut = new SystemUnderTest(dependencies) // initialize the class that you're going to test

// Act
const testParams = … // initialize the params for the test case
const response = sut.execute(testParams) // execute the test use case

// Assert
const expected = … // initialize the expected result;
expect(result).toEqual(response) // verify the result;

1. System under test could also be just a function, the idea would remain the same.

2. This is not a strict rule, so there’s no need to write the tests exactly this way. For example, it’s totally fine if you think it’s better to initialize the testParams on the Arrange section.

Test Doubles

In the realm of unit testing, the term “Test Doubles” refers to objects used as substitutes for real components, enabling controlled interactions during testing. These doubles come in various forms, each serving a specific purpose in isolating the unit of code under examination. Here are some common types of test doubles:

Dummy Objects:

  • Purpose: Dummies are placeholders, supplying required objects to fulfill method signatures but without contributing to the test logic.
  • Example: Passing a dummy list to a method that requires a list parameter but doesn’t use its content.

Stubs:

  • Purpose: Stubs provide predefined responses to method calls, allowing developers to control the behavior of dependencies during testing.
  • Example: Creating a stub for a database query that always returns a specific dataset.

Mocks:

  • Purpose: Mocks are dynamic objects that record the interactions they have with the unit under test. They allow expectations to be set and later verified.
  • Example: Verifying that a method on a mock object is called with specific parameters.

Spies:

  • Purpose: Spies gracefully blend elements of both mocks and stubs. They record the play’s interactions, akin to mocks, but with a more forgiving nature, allowing flexibility in the order and frequency of method calls.
  • Example: Capturing the number of times a specific method is called without enforcing a strict sequence.

Fakes:

  • Purpose: Fakes are the understudies ready to step in with simplified, working implementations of dependencies. They mirror the behavior of the real components but in a manner tailored for testing.
  • Example: Designing a fake email service that logs emails to a file instead of dispatching them into the real world.

Case Study

Let’s take a look at the UserRegistration use case:

// @domain/useCases/userRegistration.ts

export class UserRegistration {
constructor(
private userRepository: UserRepository,
private emailValidator: EmailValidator,
) { }

async execute(input: UserRegistrationInput): Promise<User> {
const { email, password, confirmPassword } = input;

if (!email) throw new MissingParam('email');
if (!password) throw new MissingParam('password');
if (!confirmPassword) throw new MissingParam('confirmPassword');

if (password !== confirmPassword)
throw new PasswordDoesNotMatchError();

const isValid = this.emailValidator.validate(email);
if (!isValid)
throw new InvalidEmailError();

const user = await this.userRepository.findByEmail(email);
if (user)
throw new UserAlreadyExistsError();

const newUser = await this.userRepository.create({ email, password });
return newUser;
};
};

Analyzing the UserRegistration.execute method:

It extracts three properties from the method parameter input and throws an error if any of them is missing.

 ...
const { email, password, confirmPassword } = input;

if (!email) throw new MissingParam('email');
if (!password) throw new MissingParam('password');
if (!confirmPassword) throw new MissingParam('confirmPassword');
...

In this block, it throws an error if the extracted password and confirmPassword don’t match.

... 
if (password !== confirmPassword)
throw new PasswordDoesNotMatchError();
...

After that, it tries to validate the email with an external email validator dependency and throws an error if the validator returns false or if the validator itself fails, since it’s not inside any try-catch block.

...
const isValid = this.emailValidator.validate(email);
if (!isValid)
throw new InvalidEmailError();
...

Then, it uses the user repository to check if there’s already a user with the provided email and throws an error if so.

...
const user = await this.userRepository.findByEmail(email);
if (user)
throw new UserAlreadyExistsError();
...

Finally, it tries creating a user with the provided email and password and returns it.

...
const newUser = await this.userRepository.create({ email, password });
return newUser;
...

Possible behaviors found after the analysis:

  1. Throw an exception if the email is missing;
  2. Throw an exception if the password is missing;
  3. Throw an exception if the confirmPassword is missing;
  4. Throw an exception if the password and confirmPassword are different;
  5. Throw an exception if the EmailValidator.validate throws;
  6. Throw an exception if the email is not valid;
  7. Throw an exception if the UserRepository.findByEmail throws;
  8. Throw an exception if a user with this email already exists;
  9. Throw an exception if the UserRepository.create throws;
  10. Return the created user on success;

These are basically the test cases we need to create for this use case, but we’re not going to write all of them since some of them have quite the same idea.

Writing Unit Tests

Before writing the unit tests, we need to create some test doubles for our use case dependencies and since we’re using the Dependency Inversion Principle for the UserRepository and EmailValidator, we can create these spy implementations:

// happy path return
const createdUser: User = {
id: 'test_id',
email: 'test_email',
password: 'test_password',
}

// Potential exceptions messages
const USER_REPOSITORY_CREATE_EXCEPTION = 'UserRepositorySpy.create Error!'
const USER_REPOSITORY_FIND_BY_EMAIL_EXCEPTION = 'UserRepositorySpy.findByEmail Error!'
const EMAIL_VALIDATOR_VALIDATE_EXCEPTION = 'EmailValidatorSpy.validate Error!'

class UserRepositorySpy implements UserRepository {
public throwError: 'create' | 'findByEmail' | null = null // force behavior
public createCallsParams: Omit<User, 'id'>[] = [] // save create calls parameters
public findByEmailCallsParams: string[] = [] // save findByEmail calls parameters
public createReturn: User = {...createdUser}
public findByEmailReturn: User | null = null

create(user: Omit<User, 'id'>): Promise<User> {
this.createCallsParams.push(user)
if (this.throwError === 'create') throw new Error(USER_REPOSITORY_CREATE_EXCEPTION)
return Promise.resolve(this.createReturn)
}

findByEmail(email: string): Promise<User | null> {
this.findByEmailCallsParams.push(email)
if (this.throwError === 'findByEmail') throw new Error(USER_REPOSITORY_FIND_BY_EMAIL_EXCEPTION)
return Promise.resolve(this.findByEmailReturn)
}
}

class EmailValidatorSpy implements EmailValidator {
public validationBehavior: 'return_true' | 'return_false' | 'throw' = 'return_true'
public validateCallsParams: string[] = [] // save validate calls parameters

validate(email: string): boolean {
this.validateCallsParams.push(email)
if (this.validationBehavior === 'return_true') return true;
else if (this.validationBehavior === 'return_false') return false;
throw new Error(EMAIL_VALIDATOR_VALIDATE_EXCEPTION)
}
}

It doesn’t really matter how you implement those dependencies, you just need to make them allow you to force their behavior according to your needs. In the scenarios mentioned earlier, we typically expect them to function as in the happy path, such as EmailValidator.validate returning true and UserRepository.findByEmail returning null. Nevertheless, there are instances where we may require them to behave differently, such as EmailValidator.validate returning false or throwing an error to assess the use case under such circumstances. We’ll see these cases down below.

Now, let’s write some tests:

  1. Return the created user on success (happy path);
  2. Throw an exception if the email is missing (same idea for password and confirmPassword, so there’s no need to show them here);
  3. Throw an exception if the EmailValidator.validate throws;
  4. Throw an exception if the email is not valid;

Return the created user on success (happy path):

By default, our spies behave according to the happy path, and we can also assert that they’re being called with the correct parameters.

describe('UserRegistration', () => {
it('should return a user on success', async () => {
// Arrange
const userRepository = new UserRepositorySpy()
const emailValidator = new EmailValidatorSpy()
const sut = new UserRegistration(userRepository, emailValidator)

// Act
const input = {
email: createdUser.email,
password: createdUser.password,
confirmPassword: createdUser.password,
}
const response = await sut.execute(input)

// Assert
const expectedUser = {...createdUser}
expect(emailValidator.validateCallsParams[0]).toEqual(input.email) // ensure we call EmailValidator.validate with the correct parameter
expect(userRepository.findByEmailCallsParams[0]).toEqual(input.email) // ensure we call UserRepository.findByEmail with the correct parameter
expect(userRepository.createCallsParams[0]).toEqual({email: input.email, password: input.password}) // ensure we call UserRepository.create with the correct parameter
expect(response).toEqual(expectedUser)
});
...
});

Throw an exception if the email is missing:

Based on the happy path, we need to make the necessary changes in our test arrangements so it can throw the missing email error. Also, we’re not using await on the Act step from now on because they all throw an error, so we can use jest’s expect(…).rejects.toThrow(…) with the await keyword instead.

describe('UserRegistration', () => {
...
it('should throw an error if email is missing', async () => {
// Arrange
const userRepository = new UserRepositorySpy()
const emailValidator = new EmailValidatorSpy()
const sut = new UserRegistration(userRepository, emailValidator)

// Act
const input = {
password: createdUser.password,
confirmPassword: createdUser.password,
}
const responsePromise = sut.execute(input)

// Assert
await expect(responsePromise).rejects.toThrow(new MissingParam('email'));
});
...
});

We’re not going to show here the test for the missing password, confirmPassword and their match because there’s nothing new to add.

Throw an exception if the EmailValidator.validate throws:

Now, we’ll need to force the EmailValidator.validate to throw an error. If needed, you can change the MockEmailValidator implementation so you can force it to throw an error somehow.

describe('UserRegistration', () => {
...
it('should throw an error if EmailValidator.validate throws', async () => {
// Arrange
const userRepository = new UserRepositorySpy()
const emailValidator = new EmailValidatorSpy()
emailValidator.validationBehavior = 'throw' // defining how our spy should behave
const sut = new UserRegistration(userRepository, emailValidator)

// Act
const input = {
email: createdUser.email,
password: createdUser.password,
confirmPassword: createdUser.password,
}
const responsePromise = sut.execute(input)

// Assert
await expect(responsePromise).rejects.toThrow(new Error(EMAIL_VALIDATOR_VALIDATE_EXCEPTION));
});
...
});

The same goes for the UserRepository.findByEmail and UserRepository.create methods calls.

Throw an exception if the email is not valid:

Finally, we force EmailValidator.validate to return false instead of throwing an error.

describe('UserRegistration', () => {
...
it('should throw an error if the email is not valid', async () => {
// Arrange
const userRepository = new UserRepositorySpy()
const emailValidator = new EmailValidatorSpy()
emailValidator.validationBehavior = 'return_false' // defining how our spy should behave
const sut = new UserRegistration(userRepository, emailValidator)

// Act
const input = {
email: createdUser.email,
password: createdUser.password,
confirmPassword: createdUser.password,
}
const responsePromise = sut.execute(input)

// Assert
await expect(responsePromise).rejects.toThrow(new InvalidEmailError());
});
...
});

The same goes for the UserRepository.findByEmail method return.

Conclusion

To sum it up, unit testing isn’t about endless permutations of 1 + 1 scenarios. We’ve sidestepped the usual calculator examples and dived into the nitty-gritty of practical unit testing with the UserRegistration use case. The Arrange-Act-Assert pattern served as our trusty guide, bringing order to our tests. As we dissected the execution method, we uncovered various behaviors and challenges, from handling missing parameters to navigating intricate business rules.

Introducing test doubles, like UserRepositorySpy and EmailValidatorSpy, we showcased their versatility in controlling dependencies during testing. These doubles aren’t just about mimicking real components; they offer a strategic advantage in crafting effective unit tests.

In essence, unit testing, as demonstrated in this article, is a pragmatic tool for developers. It’s not just a checkbox for correctness but a means to navigate real-world complexities and fortify our code against unexpected hiccups. So, the next time you write a unit test, consider building resilience into your code base without getting bogged down by testing every conceivable input.

You can take a better look at the clean architecture repository, it contains two test suites covering the behaviors we listed in this article, but one of them is using jest’s capabilities of creating test doubles, which makes the code a lot cleaner.

--

--