AAA Testing Strategies
Because the classics will never fail you - learn about AAA Testing (Assemble, Act, Assert)
AAA Testing Strategies
Automated testing is one of those things that every developer has a love-hate relationship with. For the junior engineers, it can be a daunting task to draft the perfect unit test, especially if you don't understand the unit of code that you're supposed to be testing. For senior engineers, it can be frustrating to have full mastery over the React lifecycle, or your MVC (Model, View, Controller) framework of choice, and yet struggle to craft effective and scalable test suites.
Today, we're going to explore the basics of AAA testing with React components and TypeScript functions, why it's a strategy that has withstood the test of time (pun intended), and how it can help you to navigate additional testing patterns that can make your tests more concise, easy to maintain, and scalable.
What are Automated Tests
Automated tests are simply: code that asserts the behavior of other code.
Essentially, tests can import your source code that your users will actually engage with and they can interact with them and define both expected inputs and outputs for your source code.
There are different kinds of automated tests that each serve different purposes, as well as different ways to structure automated tests (which we'll refer to as "disciplines").
You can imagine automated tests as a pyramid of categories, with each layer of the pyramid having fewer and fewer tests the closer you get to the top. Generally that pyramid looks like this:
/\
/ \
/ E2E\ End-to-End tests assert the application
/ Tests\ behaves correctly in real deployments.
/--------\
/ \
/ Integration\ Integration & UI tests verify your app
/ & UI Tests \ integrates correctly with dependencies.
/-------------- \
/ \
/ Unit Tests \ Unit tests form the foundation —
/________________________\ you'll write these the most.
Why?
Writing automated tests serve three very important purposes:
- Ensure the behavior of your source code
- Make sure that you don't introduce breaking changes / regressions
- Document the behavior of your application
So the most obvious utility of automated tests is that it ensures that your source code does what you expected it to do. We've all been there where we've crafted the most beautiful, perfect function - hit compile or run and then it just didn't work. Automated tests can help us validate our source code quickly and reliably so that we can define what the expected outputs of our tests should be and then we just have to modify the source code until those test assertions are satisfied.
The next one is preventing regressions. Because you've already written the tests once, when you come back to your codebase and make changes to either further enhance your code, or simply refactor it to make it easier to read or maintain, your automated tests can help you to be confident that you haven't changed your code so much that you've introduced a breaking change.
Lastly, and honestly most importantly - especially if you work on a team - automated tests serve as documentation on how your application behaves. Other developers can depend on automated tests to explain parts of a codebase that they have little exposure to, or that they never touch regularly. And if they are maintained as your codebase grows and changes, then they serve as the most accurate, up-to-date documentation that your team can depend upon without the need of formal knowledge transfer sessions.
What does AAA stand for?
AAA is an acronym for:
- Assemble
- Act
- Assert
It is the foundational structure for any quality automated test. But as code gets more and more complicated, it can be difficult to remember what to Assemble, how to Act, or should/should not be Asserted on.
Assemble
In this phase of a test, you're going to want to "set up" your test.
This will include:
- expected outputs
- input values
- define mock dependency behavior
Act
In this phase of a test, you're actually going to call the test subject in question and pass in any input values defined in the Assemble phase.
You should also capture any outputs during this phase for use during the Assert phase (unless of course your test subject has a void return type and doesn't return anything).
Assert
In this phase of a test, you're finally going to assert that your test subject behaved the way you expected it to.
This means that if there were any outputs captured during the Act phase, we should set up test assertions that their value is equal to whatever expected value you had for this test.
Additionally, if there are any side effects that should have impacted any mocked values, you should assert that your mocks were interacted with in the ways that you were expecting them to be.
Example
Let's start with a very basic example:
// adder.ts
export function add(first: number, second: number): number {
return first + second
}We have a super easy add function that takes in two numbers,
adds them together, and returns the result. Easy!
Now let's set up it's unit test with AAA testing strategy.
// adder.test.ts
import { describe, expect, it } from 'vitest'
import * as subject from './adder'
describe('Adder', () => {
it('should add two numbers together', () => {
// Assemble
const expected = 4
// Act
// in this example, since our inputs are literal values,
// I didn't define these in the Assemble phase
const actual = subject.add(2, 2)
// Assert
expect(actual).toEqual(expected)
})
})That's it!
Hopefully, an example like this is very familiar to you. But it's important to understand the why of this test setup. By defining our automated tests in this way, they set us up for clear communication of system behavior. When everyone on your team expects to see tests written in this way, they don't have to spend extra energy trying to understand how you specifically write tests. They understand the basic flow of a test, and now they can update your test as needed if they have to modify code that your automated tests cover.
Like I said in the intro to this blog post: tests act as documentation for how your system behaves!
Let's try a slightly more complicated example. Let's tackle FizzBuzz!
// fizzbuzz.ts
export function fizzBuzz(target: number): string {
let output = ''
if(target <= 0) {
return output
}
for(let i = 1; i <= target; i++) {
if (i % 3 === 0) {
output += 'fizz\n'
break
}
if( i % 5 === 0) {
output += 'buzz\n'
break
}
output += `${i}\n`
}
return output
}Classic FizzBuzz solution. We have 3 conditions in our fizzBuzz function here:
- is
targetless than or equal to0? - is the current iterator divisible by
3? - is the current iterator divisible by
5?
So how would we set up our automated test to match this with AAA testing strategy?
import { describe, expect, it } from 'vitest'
import * as subject from 'fizzbuzz'
describe('FizzBuzz', () => {
describe('when target is less than or equal to 0', () => {
// Assemble
const expected = ''
describe('when target is -123', () => {
it('should return an empty string', () => {
const actual = subject.fizzbuzz(-123)
expect(actual).toEqual(expected)
})
})
describe('when target is 0', () => {
it('should return an empty string', () => {
const actual = subject.fizzbuzz(0)
expect(actual).toEqual(expected)
})
})
})
describe('when target is NOT divisible by 3 or 5', () {
it('should print the numbers in order up to the passed target', () => {
const actual = subject.fizzbuzz(2)
expect(actual).not.toContain('fizz')
expect(actual).not.toContain('buzz')
})
})
describe('when target is divisible by 3', () {
it('should print "fizz"', () => {
const actual = subject.fizzbuzz(3)
expect(actual).toContain('fizz')
expect(actual).not.toContain('buzz')
})
})
describe('when target is divisible by 5', () {
it('should print "buzz"', () => {
const actual = subject.fizzbuzz(5)
expect(actual).toContain('fizz')
expect(actual).toContain('buzz')
})
})
describe('when target is divisible by both 3 AND 5', () {
it('should print "fizzbuzz"', () => {
const actual = subject.fizzbuzz(15)
expect(actual).toContain('fizz')
expect(actual).toContain('buzz')
expect(actual).toContain('fizzbuzz')
})
})
})Whew! Okay - a lot more happening here!
So the first thing you'll notice with this test file is that it is much longer than our source code - and that's normal! We should generally have more tests than we do lines-of-code. We want to be exhaustive with our tests.
That being said, with this example, we could simplify this with .each helper
function. With .each, we define ALL of our Assemble phase within an array,
and then our test function will actually be executed on each object within that array.
import { describe, expect, it } from 'vitest'
import * as subject from 'fizzbuzz'
describe('FizzBuzz', () => {
it.each([
{ target: -123, expected: [''] },
{ target: 0, expected: [''] },
{ target: 3, expected: ['fizz'] },
{ target: 5, expected: ['fizz', 'buzz'] },
{ target: 15, expected: ['fizz', 'buzz', 'fizzbuzz'] },
])('when target is $target, should print [$expected]', ({target, expected}) => {
const actual = subject.fizzbuzz(target)
expected.forEach((_expected) => {
expect(actual).toContain(_expected)
})
})
})Much better!
And this is a perfect example of being able to leverage your test tools to make for clear and concise automated tests.
Conclusion
I hope that this short blog has helped you to understand AAA testing, and hopefully you've learned how best to write tests going forward.
Happy testing!