Structure and Readability

How to make your test code well-structured and readable

Arrange, Act, Assert

Every test can be divided into three steps: arrange, act, assert.

  1. Arrange: Setup everything needed to call the function to test
  2. Act: Call the test function
  3. Assert: Verify the result of the test function

The arrange step can be optional if nothing has to be created beforehand. An empty line should separate these steps. Following this structure helps reading the test and makes finding problems a lot faster. If you get used to this structure, it’s straightforward to read a test someone else wrote, because you look for “arrange, act, assert”

Take a look at these examples:

Bad ❌:

def test_should_sort_list():
	list_to_sort = [4, 2, 3, 6]
	sorted_list = my_sort_function(list_to_sort)
	assert sorted_list == [2, 3, 4, 6]

Good ✔:

def test_should_sort_list():
	list_to_sort = [4, 2, 3, 6]

	sorted_list = my_sort_function(list_to_sort)

	assert sorted_list == [2, 3, 4, 6]

You can see, it’s a lot easier to read and understand the test.

Naming your test functions

Test functions should be named in a way that it’s clear what should be tested and what’s the intended outcome. That makes it a lot easier to understand. See the following example, which tries to parse a version number from a string:

Bad ❌:

def test_version_number():
	assert VersionNumber("v001")

	with pytest.raises(ValueError):
		VersionNumber("foo")

There are two issues: First, two cases are tested by this test: parsing a correct string and expecting a valid VersionNumber and a ValueError raised for an invalid version string. The test function should be split into two functions and named accordingly:

Good ✔:

def test_parsing_valid_version_number_creates_version_number():
	assert VersionNumber("v001")

def test_parsing_invalid_version_number_raises_value_error():
	with pytest.raises(ValueError):
		VersionNumber("foo")

You can see, it’s a lot easier to understand what the test does. Naming test functions might be constrained in some way by the Test framework. Pytest, for example, requires test functions to start with test_.

Create utility functions

Test functions can get quite long and hard to understand, even if they only test one aspect. To help understand the test function, it’s possible to create some utility functions. These utility functions can get useful names and make the code straightforward to read. Remember: Code is read far more often than it is written. These utility functions are not created upfront, but can be created by refactoring the test code after some while. Let’s take a look at an example:

Bad ❌:

def test_open_no_scene_unsaved_changes_save_changes(open_manager):
    open_manager._engine.current_file_path.return_value = None
    open_manager._engine.has_unsaved_changes.return_value = True
    open_manager._view_callback_provider.ask_for_save.return_value = True
    open_manager._open_scene = MagicMock()

    open_manager.do_it(MagicMock())

    assert open_manager._open_scene.called
    assert open_manager._engine.save.called

It’s pretty hard to understand what’s actually going on in the test. Let’s refactor the code into utility functions. We name the utility functions in a way the names describe what the function does:

Good ✔:

def test_open_no_scene_unsaved_changes_save_changes(open_manager):
    a_scene_with_no_name_but_unsaved_changes()

    open_file()

    file_was_openened_and_unsaved_changes_were_saved()

The test code is much more clear now.

Last modified September 20, 2020