The 4A unit test pattern
Tracy Gilmore
Posted on April 23, 2022
Introduction
In my post on Unit Proving I proposed an enhancement to the AAA approach to unit test construction, which I have been using for many years.
There is absolutely nothing wrong with the AAA approach and if you are unfamiliar with it I suggest reading my previous post and do a few google searches to gain some other people's perspective on the topic - Don't just take my word for it.
In brief the AAA stands for:
- Arrange: prepare the test environment so it is self-contained.
- Act: Perform the action on the Unit that is the purpose of the unit test case.
- Assert: Confirm the impact on the environment/Unit is as expected and report an error if not.
This approach is tried and tested and is effective for the vast majority of unit tests. However, I have encountered cases when a false (positive and negative) result has been reported and the test passes when it should not have. The majority of such cases have not been due to errors in the Unit or even an error in the Unit Test but as a result of a false understanding of the test environment. The 4A approach builds on AAA to address this circumstance.
An example scenario
The following example demonstrates the issue using JavaScript and the jest test framework but the issue is not language specific by any means.
// unit.spec.js
import UnitUnderTest from '../../src/unitUnderTest';
import MockDependency from '../mocks/mockDependency';
describe('Unit under test', () => {
let mockDependency;
beforeEach(() => {
mockDependency = new MockDepedency();
});
afterEach(() => {
mockDependency = null;
});
it('can be created with its dependencies', () => {
// Arrange
const unitUnderTest = new UnitUnderTest(mockDependency);
// Act
const result = unitUnderTest.methodDependendOnMock();
// Assert
expect(result).toBeTruthy();
});
});
In the example code above well, hidden behind the code actually, we have a dependency where the functionality we are exercising in methodDependendOnMock
is reliant on a method provided by the mockDependency
object.
It is quite possible that mockDependency
may have changed and the functionality we require is now broken. Equally the method may have been removed, or worse, the method still exists on the Mock but not on the Actual, which should result in a failure of this unit test case but does not.
My suggested 4th A (Affirm) will go some way to resolving this by confirming our dependency is as we expect.
// unit.spec.js
import UnitUnderTest from '../../src/unitUnderTest';
import MockDependency from '../mocks/mockDependency';
import ActualDependency from '../../src/dependency';
describe('Unit under test', () => {
let mockDependency;
beforeEach(() => {
mockDependency = new MockDepedency();
});
afterEach(() => {
mockDependency = null;
});
it('can be created with its dependencies', () => {
// Arrange
const unitUnderTest = new UnitUnderTest(mockDependency);
// AFFIRM - the dependent method exists on both dependencies.
expect('dependentMethod' in mockDependency).toBeTruthy();
expect('dependentMethod' in ActualDependency).toBeTruthy();
// ToDo: Apply more affirmations here to confirm the interface is as expected, if required.
// Act
const result = unitUnderTest.methodDependendOnMock();
// Assert
expect(result).toBeTruthy();
});
});
The above test requires us to bring the definition of the actual dependency into scope of the test case. It also requires us to have visibility of the implementation of the UnitUnderTest.methodDependendOnMock
method but this is the Unit test, the proof of the Unit so there is nothing wrong with that.
Work in progress
The construction and maintenance of unit tests is, in my opinion, a developer responsibility and a vital part of the Software Engineering process. In the same way that code (Units) will evolve over time, so will their unit test. Until it is retired, a Unit remains in a constant state of "Work in progress" even after release. Consequently, it is also the case with the Unit's Unit Test.
When a bug report is investigated and the Unit (or Units) at the root of the issue have been identified, Unit tests can help considerably especially when the developer is familiar with TDD (Test-Driven Development).
If you are not familiar with Test-Driven Development, I would recommend investigating it. I have found it to be a very productive approach although I don't use it all the time.
Suffice to say at this point the process follows a RED-GREEN-BLUE strategy; RED: Code the test - it fails, GREEN: Update the code - test passes, BLUE: Refactor to clean up the code and identify any design changes required.
One of the first actions to be performed when investing a fault is to try and reproduce it. This enables the developer to identify the environment in which the fault occurs, which might be significant. It will also help identify the Unit(s) involved in the failure. This can be made more difficult if there are several Units involved in the root case or there is a timing aspect to the root cause, but try breaking the cascading failure down into individual faults each attributable to a single Unit.
Following the TDD approach we then write a new unit test case to reproduce each fault [RED PHASE] one by one. We then make the necessary changes required to resolve the fault [GREEN PHASE]. Of course, the solution could be to revise an existing unit test case making the new test case obsolete. The solution could equally necessitate a revision of the Unit but there are also cases where the unit test case needs to be enhanced (duplicated) because the result is dependent on a specific state of the environment not previously realised or that has recently come about. Confirming the state of the test environment is where my enhancement of AAA comes in to play.
Pros and Cons
In many modern test frameworks, it is possible to combine test cases that share a common test environment. Establishing (and tearing down) the environment is often performed outside of test cases using functions such as beforeAll
, beforeEach
, afterAll
and afterEach
. Also, test result confirmation is usually performed by something like an assert
or expect
instruction. However, we should resist the temptation to include confirmation in the prepare/tear-down blocks but either;
- Have a new unit test case at the start of the suite to
Affirm
the environment is as expected. - Perform the
Affirm
ation at the start of each test case, but also resist the DRY itch to place theAffirm
check in a function and call it from each test case. I have found this to be counter-productive (what might be considered an anti-pattern.)
To Close
I would recommend not following 4A for each and every test case but apply AAA as the rule and extend it to include the Affirm
check when you suspect or have identified a need.
Please tell me what you think of the 4A approach in the discussion section below. Have you tried it? Did it help or just make the unit tests longer?
Posted on April 23, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.