Structure your tests using the Arrange-Act-Assert pattern
Arrange-Act-Assert is a standardised approach to writing tests that makes them easier to read and helps you avoid some bad practices.
Although it’s possible to apply all kinds of metrics and mathematical principles to software testing, in practice most experienced developers just follow their intuition when they write tests and don’t really think that hard about how they write them. As it happens, writing good test cases is often more of an art than a science.
Fortunately there’s one simple trick that can help you write test cases that are easier to understand and debug: the Arrange-Act-Assert pattern.
The pattern
The Arrange-Act-Assert pattern is based on the idea that many tests do a combination of the following three things:
-
Arrange: Set up everything that we need before we can start our test, e.g. by creating, initialising or modifying test objects.
-
Act: Perform some action on the system under test (SUT), i.e. the thing that is being tested. The action is usually a function or method call.
-
Assert: Verify that the action was handled properly by the SUT, for instance by checking if a function call produced the expected result.
For example, a simple unit test in Python might look a bit like this:
def test_calculate_age_of_student(): student = Student('Jenny Tills', date_of_birth=datetime(2001, 3, 4)) current_age = student.age_on_date(date=datetime(2023, 1, 5)) assert current_age == 21This test is only three lines long, so it’s easy to see what’s happening here: first, we create a new Student object with a date of birth, then we use its age_on_date method to calculate the student’s current age in years on a specific date.
However, most tests that you encounter in real-life codebases consist of more than just three lines. Here’s an example of a slightly longer test:
def test_student_is_not_happy_with_gpa_if_gpa_is_below_gpa_goal(): student = Student('Studdy McStudface') student.gpa_goal = 7 student.register_course_result(CourseResult(grade=9, weight=3)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=5, weight=6)) student.register_course_result(CourseResult(grade=6, weight=3)) calculator = GpaCalculator() student.gpa = calculator.calculate_average_for_student(student) assert str(student.gpa) == '6.7' assert not student.is_happy_with_gpa()Not only is this test longer than the test that we saw earlier, it also resembles a wall of text, which makes it hard to scan!
And this test is actually not even that long; some tests require dozens of lines of code just to set up things like mocks and fixtures, or to verify that the SUT produced the expected result.
Paragraphs for your code
Longer texts usually consist of multiple paragraphs, which are separated from each other using newlines. We can do the same thing in our code.
That same test already looks a bit more readable and a little less threatening when we add some newlines to it:
def test_student_is_not_happy_with_gpa_if_gpa_is_below_gpa_goal(): student = Student('Studdy McStudface') student.gpa_goal = 7 student.register_course_result(CourseResult(grade=9, weight=3)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=5, weight=6)) student.register_course_result(CourseResult(grade=6, weight=3))
calculator = GpaCalculator() student.gpa = calculator.calculate_average_for_student(student)
assert str(student.gpa) == '6.7' assert not student.is_happy_with_gpa()The first section (or “paragraph”) is responsible for preparing the student object. The second section calculates and sets the student’s GPA, while the third section contains our assertions.
We can restructure this code a bit so that each of the three sections in the test corresponds with “Arrange”, “Act”, and “Assert” respectively:
def test_student_is_not_happy_with_gpa_if_gpa_is_below_gpa_goal(): student = Student('Studdy McStudface') student.gpa_goal = 7 student.register_course_result(CourseResult(grade=9, weight=3)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=5, weight=6)) student.register_course_result(CourseResult(grade=6, weight=3)) calculator = GpaCalculator() student.gpa = calculator.calculate_average_for_student(student)
result = student.is_happy_with_gpa()
assert str(student.gpa) == '6.7' assert result == falseYou can now instantly see that this test covers the is_happy_with_gpa() method without having to read the entire test. We had to make some small sacrifices though:
-
All setup code is now grouped together, even though it consists of two distinct parts.
-
In previous versions the
is_happy_with_gpa()was called as part of the assertion. But because we separated the action and assertions here, we had to introduce an intermediate variableresultthat stores the result from our action until we’re ready to check its value.
As a final touch, we can use single-line comments to add headings to each of the three major sections. This also allows us to split the “Arrange” section into two paragraphs again, as in the previous snippet:
def test_student_is_not_happy_with_gpa_if_gpa_is_below_gpa_goal(): # > Arrange student = Student('Studdy McStudface') student.gpa_goal = 7 student.register_course_result(CourseResult(grade=9, weight=3)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=7, weight=6)) student.register_course_result(CourseResult(grade=5, weight=6)) student.register_course_result(CourseResult(grade=6, weight=3))
calculator = GpaCalculator() student.gpa = calculator.calculate_average_for_student(student)
# > Act result = student.is_happy_with_gpa()
# > Assert assert str(student.gpa) == '6.7' assert result == falseThere are still some things that we could improve about this test, but at least we’ve managed to make things a little bit better!
By the way…
You should know that this was an incredibly opinionated article. Not everyone likes the Arrange-Act-Assert pattern. People don’t always use it. Even I don’t always use it.
In this section I’ll try to explain when and how I use it.
Headings
When I write tests I usually create a little template that looks a bit like the following snippet:
def test_something(): # > Arrange
# > Act
# > AssertThe > has no special meaning. I simply use it to differentiate these section headings from regular single-line comments:
def test_used_to_reproduce_a_bug(): # > Arrange # This recreates the situation described in NX-74205 ...Some people use capital letters (# ARRANGE), other symbols (## Arrange), or omit the whitespace between the comment symbol and the heading (#> Arrange, #arrange).
Speaking of whitespace: you can also omit the newlines between sections, if you want your tests to look small.
You can also use other section titles (e.g. “Given”/“When”/“Then”) if you want. After all, it’s your code!
Omitting headings
Note that you don’t have to include all three sections:
-
Many tests are so simple that they don’t require any setup;
-
Tests that verify if the SUT throws an exception rarely include assertions within the function body;
-
Sometimes the “Act” and “Assert” sections are so trivial that there’s no need to separate them.
If your test is simple enough, you can even leave them out entirely:
def test_abs(): assert abs(-420) == 420It’s also okay to omit headings if you feel that a test is just as easy to understand without them, as we saw in the first snippet or in this modified version that separates the three sections using only newlines:
def test_calculate_age_of_student(): student = Student('Beau Sachs', date_of_birth=datetime(1997, 6, 7))
current_age = student.age_on_date(date=datetime(2023, 1, 5))
assert current_age == 25It’s not just for Python
All of the examples you’ve seen up to this point are written in Python, but the Arrange-Act-Assert pattern works for virtually any language. Here are two examples that use Java and PHP:
@Testvoid testDogCanBark(): // > Arrange var dog = new Dog(new DefaultBarkingStrategy());
// > Act var result = dog.bark();
// > Assert assertEquals("Woof!", result);function test_dog_can_bark(): void{ // > Arrange $dog = new Dog(new ConspiratorialBarkingStrategy());
// > Act $result = $dog->bark();
// > Assert self::assertEquals('WEF!', $result);}Arrange-Act-Assert-Act-Assert
You might run into a situation where you want to include two distinct sets of assertions, e.g. when you want to verify that an object can be successfully created and updated within the same test.
Well, don’t. It’s called Arrange-Act-Assert for a reason.
What to assert when you’re expecting
The Arrange-Act-Assert pattern doesn’t work very well for tests that make use of mocks. Such tests often don’t have a section for traditional assertions. Instead, the assertions are written in the form of expectations that are defined during the “Arrange” step.
For such tests you could use a template that includes two “Arrange” sections; one for “normal” variables and objects, and one for mocks:
def test_something_with_mocks(): # > Arrange (data) # Create any objects you need, as usual
# > Arrange (expectations) # Create mocks with expectations here
# > Act