All tests follow a similar structure, but sometimes you have to really squint to see it. Your framework, tool, or language may obscure these steps (or they may be no-ops), but they’re still there.

  • Arrange
  • Act
  • Assert

Sometimes reading tests to diagnose what’s going on with a system can be difficult. When that’s happening, I find making these steps of your test explicit can aid readability. Further, if every test has these steps made explicit, it’s easier to skim similar structures. Looking for these parts of the test can also be a great way to orient yourself in a new suite or system.

Example

How about an anti-pattern to start with? What if we put all of our setup in before/setup/arrange hooks?

@pytest.fixture
def make_api_request():
    self.response = api.post("/endpoint", body={})
    
def test_response_code(make_api_request):
    assert self.response.code == 201
    
def test_response_body_matches_json_schema(make_api_request):
    assert matches_schema(self.response.body, fixture_file("schemas/response.json"))

What happens now, when I need to tweak the request I’m making by changing part of the body? One way to handle this is to make another fixture (or a different context, test class, etc).

@pytest.fixture
def make_api_request():
    self.response = api.post("/endpoint", body={})

@pytest.fixture
def make_different_api_request():
    self.response = api.post("/endpoint", body={"username": "dugancathal"})

def test_response_code(make_api_request):
    assert self.response.code == 201
    
def test_response_body_matches_json_schema(make_api_request):
    assert matches_schema(self.response.body, fixture_file("schemas/response.json"))
    
def test_response_when_request_has_username(make_different_api_request):
    assert matches_schema(self.response.body, fixture_file("schemas/response.json"))

Now that’s not awful! But I don’t know that it’s a pattern that would scale well. And to my eyes at least, they glaze over toward the end of the really long test name with fixture arguments. When I’m tired, I might not notice the difference between the two.

Another way to handle this could be to use different test contexts.

class TestApi():
    def setup_method(self):
        self.response = api.post("/endpoint", body={})

    def test_response_code(self):
        assert self.response.code == 201
        
    def test_response_body_matches_json_schema(self):
        assert matches_schema(self.response.body, fixture_file("schemas/response.json"))

class TestApiWithUsername():
    def setup_method(self):
        self.response = api.post("/endpoint", body={"username": "dugancathal"})
    
    def test_response_when_request_has_username(self):
        assert matches_schema(self.response.body, fixture_file("schemas/response.json"))

This does feel more obvious, but at the risk of creating more mental burden. Nesting the contexts also forces me to remember state at deeper levels. If that state is important, it should be obvious in the Arrange step.

I would recommend, instead, that we make our tests [a little more WET (as opposed to DRY)] to make each test more readable and easier to work with. Let’s play it out and see.

class TestApi():
    def test_response_code(self):
        response = self._make_request()
        assert response.code == 201
        
    def test_response_body_matches_json_schema(self):
        response = self._make_request()
        assert matches_schema(response.body, fixture_file("schemas/response.json"))

    def test_response_when_request_has_username(self):
        response = self._make_request(body={"username": "dugancathal"})
        assert matches_schema(response.body, fixture_file("schemas/response.json"))

    def _make_request(self, body = {}):
        return api.post("/endpoint", body=body)

Here, each of the three steps these tests care about are now visible. For the first two tests, they have a no-op Arrange, followed by an obvious Act and Assert step. The final test has all three:

  • Arrange: body={"username": "dugancathal"}
  • Act: self.make_request(...)
  • Assert: assert ...

Further, any literal values to our Act step are explicit and we’ve kept noise out our tests. There’s “more to read” in each individual test, but it’s relevant information in the moment.

Nuances / Points of note

  • Some tools (e.g. RSpec) have special syntax for accessing the SUT (subject under test). Generally, I like this approach, but it can be overused and can impede readability if one’s not careful. The most common anti-pattern I see is developers using method calls as the SUT.

    subject { MyBusinessService.new(http_client).call(username) }
    
    it "fetches some data for the user" do
      expect(subject).to eq "Account Balance: 123"
    end
    

    As a rule, I only ever put instances or classes in these blocks.

    subject { MyBusinessService.new(http_client) }
    
    it "fetches some data for the user" do
      expect(subject.call("dugancathal")).to eq "Account Balance: 123"
    end
    

    This keeps the Act portion of the test explicit. Another benefit is it can provide room for additional arranging/configuration to allow for literal values.

  • You may also see “cleanup” as a fourth step in this cycle. That’s an important consideration. Tests should also follow the scouting rule - leave the world better than they found it.

    If your testing framework or tool of choice has support for an explicit cleanup step, see if you can leverage that. Try to avoid any cleanup logic that could fail the test or leave the system in an unknown state.

Conclusion

By structuring your tests in a consistent manner you give your suite a rhythm. This not only makes individual tests easier to read, debug, and reason about, it makes the entire suite easier to maintain.