Arrange/Act/Assert is a name for a sequence of steps which most of your tests already perform. By recognizing this implicit pattern, and organizing your test code around it, you can make your tests easier to read and understand.
What is it?
- Arrange: Setup objects, the database, or anything else.
- Act: Exercise the system under test.
- Assert: Verify the result.
Here’s a simple example:
describe "#add_tag" do
it "adds the tag to the user" do
tag = create(:tag)
user = create(:user, tags: [])
user.add_tag(tag)
expect(user.tags).to eq([tag])
end
end
Each of the Arrange/Act/Assert steps are separated by newlines. This makes it easy to tell them apart, so you can quickly tell what action is being performed, and what the expected outcome is.
If this pattern seems familiar but the name sounds new, it’s because Arrange/Act/Assert is also known by many other names, such as the four phase test, setup-experiment-verify, and given-when-then.
A deceptively confusing test
Consider this other test of the same method:
it "logs the tag being added" do
tag = create(:tag)
expect(Rails.logger).to receive(:info).with("added #{tag.name}")
user = create(:user, tags: [])
user.add_tag(tag)
end
This one isn’t organized around Arrange/Act/Assert. Just by quickly glancing at it, it’s not clear what the expected outcome is. To understand that, you have to read through the Arrange step. You can get there, but it takes more time and effort. Each of the Arrange, Act, and Assert steps are still performed, but the test isn’t written to clearly distinguish between them.
If we organize the code around the pattern, it gets a little longer, but it becomes much easier to follow.
it "logs the tag being added" do
tag = create(:tag)
allow(Rails.logger).to receive(:info)
user = create(:user, tags: [])
user.add_tag(tag)
expect(Rails.logger).to have_received(:info).with("added #{tag.name}")
end
You can more clearly see that the expected outcome is for something to be logged.
let and before break the flow
Consider this example of a request spec for an API to add tags to a user:
describe "#create" do
let(:user) { create(:user) }
let(:tag) { create(:tag) }
let(:params) { {tag: {name: tag.name}} }
before do
login_as(user)
post(user_tags_path(user, params:))
end
it "adds the tag" do
expect(user.tags).to eq([tag])
end
context "when adding a nonexistent tag" do
let(:params) { {tag: {name: "nonexistent"}} }
it "doesn't do anything" do
expect(user.tags).to be_empty
end
end
end
In this test, each it block is small, which is nice, but they include only the Assert step. To find out what preconditions were needed (Arrange) or what actual action was performed (Act), you have to scan the rest of the test file.
It’s also not immediately clear which of the let setup is actually needed in each individual test. The second “doesn’t do anything” test, for instance, doesn’t use the let(:tag) we defined. Because let is loaded lazily, this didn’t result in any wasted CPU time. But by making it harder to understand what the preconditions are, it did waste a little bit of your developer time–which is a lot more expensive.
As an alternative, consider this example where the tests are organized around Arrange/Act/Assert.
describe "#create" do
it "adds the tag" do
user = create(:user)
tag = create(:tag)
params = {tag: {name: tag.name}}
post(user_tags_path(user, params:))
expect(user.tags).to eq([tag])
end
it "when adding a nonexistent tag, doesn't do anything" do
user = create(:user)
params = {tag: {name: "nonexistent"}}
post(user_tags_path(user, params:))
expect(user.tags).to be_empty
end
end
It’s now much more easy to identify exactly what setup is needed, what action is being performed, and what we expect the result to be. And believe it or not, this version is actually a little bit shorter than the let version.
Organize your tests for readability
Most of your tests run in order of Arrange/Act/Assert already. By organizing the code of your tests to highlight this implicit pattern, your tests become easier to read and understand at a glance. Using tools like let and before to move parts of those steps outside the body of your tests does make the test body smaller. But that code still lives somewhere, and the abstractions to hide it come at the cost of making it more difficult to track it down when you need to.