Improve testing with AAA

A test is a procedure that assesses a behavior to determine if it is functioning correctly. Some of the most common types of tests include unit tests, integration tests, and end-to-end tests. However, they all serve the same basic purpose: to test something and report whether it is a success or a failure.

With testing, we gain awareness of when something goes wrong. Without testing, coding can become risky. Testing establishes a feedback loop for development, safeguarding us from changes that could potentially harm our business.

Given the immense value of tests, is there a way to write high-quality tests? Or is basic testing sufficient? Well, the choice should always be to aim for the best possible. In testing, there is a simple yet effective rule or pattern to follow, known as Arrange-Act-Assert (AAA)

Arrange

Consider what inputs are required for the behavior we want to test and establish the objectives for it. Organizing these steps sets up the test case. In this phase, we need to prepare everything that the behavior relies on for the process. For example, if the test requires a specific object or configuration, if it needs a database with simulated data, or if it depends on external services, all of these operations must be handled at the start of the test.

Act

With everything properly configured for this case, the next step is to act on the target behavior. The actions taken should focus on the main aspect that needs to be tested. This might involve calling a function or method, making a REST API call, or interacting with a web page. The Subject Under Test (SUT) typically comes into play during this step. It represents what we are testing, and it is always clear who the inputs are intended for in the Arrange step.

Assert

Finally, we have the expected results. In this step, we evaluate the success or failure of the actions taken in the Act step. Sometimes, assertions can be as straightforward as checking numeric or string values. Other times, they may involve assessing various facets of a system. At this point, the test culminates in determining whether it passed or failed.

Example

Let's illustrate this pattern with an example:

We'll use Javascript as the programming language due to its popularity, and for testing, we'll choose a fun testing framework.

Now, let's imagine we have a function that we want to test.

function serializeData(userData) {
  return {
  	name: userData.name,
    age: userData.age,
    workingExperience: userData.lastJob['finishDate'] - userData.firstJob['startDate'],
    lastJobName: userData.lastJob['companyName']
  };
}

And if we want to test this functionality using the Assert-Act-Arrange (AAA) pattern, we could do the following:

const { serializeData as sut } = require('./serializeData');

test('serializeData should serialize user data correctly', () => {
  // Arrange
  const input = {
    name: 'John Doe',
    age: 30,
    email: '[email protected]',
    firstJob: {
      startDate: new Date('2020-01-01'),
      finishDate: new Date('2021-01-01'),
      companyName: 'Company A'
    },
    middleJob: {
      startDate: new Date('2021-01-01'),
      finishDate: new Date('2022-01-01'),
      companyName: 'Company B'
    },
    lastJob: {
      startDate: new Date('2022-01-01'),
      finishDate: new Date('2023-01-01'),
      companyName: 'Company C'
    }
  };

  // Act
  const serializedData = sut(userData);

  // Assert
  expect(serializedData).toEqual({
    name: 'John Doe',
    age: 30,
    workingExperience: 94694400000,
    lastJobName: 'Company C'
  });
});

With this pattern it is very clear what is being evaluated, the input data and the output data.

Recommendation/Advice

There are other aspects of testing that we could add to this example, but perhaps another post will be more precise. For now, I want to give a recommendation for this type of testing, that for example we want to demonstrate in that function that regardless of the input data we use, the expected result is clear and should not change for any reason. Therefore, we can use faker to generate the test input data to do so.

Then the test will be something like this:

const faker = require('faker');
const { serializeData as sut } = require('./serializeData');

test('serializeData should serialize user data correctly', () => {
  // Arrange
  	const fakeUserName = faker.name.firstName();
    const fakeAge = faker.random.number({ min: 18, max: 60 })
    const fakeCompanyName = faker.company.companyName();
    const fakeFirstJobStartDate = faker.date.past();
    const fakeLastJobFinishDate = faker.date.recent();
    
    const userData = {
    name: fakeUserName,
    age: fakeAge,
    email: faker.internet.email(),
    firstJob: {
      startDate: fakeFirstJobStartDate,
      finishDate: faker.date.past(),
      companyName: faker.company.companyName()
    },
    middleJob: {
      startDate: faker.date.past(),
      finishDate: faker.date.past(),
      companyName: faker.company.companyName()
    },
    lastJob: {
      startDate: faker.date.past(),
      finishDate: fakeLastJobFinishDate,
      companyName: fakeCompanyName
    }
  };

  // Act
  const serializedData = sut(userData);

  // Assert
  const expectedWorkingExperience = fakeLastJobFinishDate - fakeFirstJobStartDate
  const expectedSerializedData = {
    name: fakeUserName,
    age: fakeAge,
    workingExperience: expectedWorkingExperience,
    actualJobName: fakeCompanyName
  };

  expect(serializedData).toEqual(expectedSerializedData);
});

Because the data generated by faker is random, the test results should vary each time it is run. However, the structure of the test and the validation of the results remain consistent.

Conclusion

The main idea behind this entire post is for those who wish to enhance the way tests are written and provide clarity and better comprehension to other developers who will review our code. The Arrange-Act-Assert pattern is advantageous because it encourages more structured, readable, maintainable, and effective testing. Adhering to this pattern establishes a sturdy foundation for creating and executing unit and integration tests, which in turn contribute to ensuring software quality and reliability.