Test in Go: risk of using Cleanup

Test in Go: risk of using Cleanup

Be careful on what's not guaranteed!

My team is using testify as the testing framework for Go. We noticed that many methods provided by testify/suite can be easily achieved by the built-in testing package. For example, using Cleanup can do the same of AfterTest provided by testify/suite. It makes me wondering if we can just use Cleanup to replace as much methods from third-party packages as possible.

TL;DR

Cleanup can replace methods that are not relevant to subtests, which are AfterTest, TearDownAllSuite and TearDownTestSuite.

Cleanup can’t replace TearDownSubTest because there is a risk that a subtest pollute its parent test.

The experiment

I use a global variable externalResource as the external resource. It’s value is 1.

When preparing a subtest at SetupSubTest, externalResource is changed to 2. At the same time, a cleanup function is registered to restore externalResource to 1.

I expect that externalResource is set to 2 before the subtest and restored to 1 once the subtest is finished. So, from the point of parent test, externalResource is always 1. From the point of subtest, externalResource is always 2.

The code is

var externalResource = 1

type TestUnexpectedCleanupOrder struct {
    suite.Suite
}

func (s *TestUnexpectedCleanupOrder) TearDownTest() {
    fmt.Println("TearDownTest")
}

func (s *TestUnexpectedCleanupOrder) SetupSubTest() {
    externalResource = 2
    fmt.Printf("SetupSubTest. The externalResoure is: %d\\n", externalResource)
    s.T().Cleanup(func() {
        externalResource = 1
        fmt.Printf("SetupSubTest. The externalResoure is: %d\\n", externalResource)
    })
}

// Test case
func (s *TestUnexpectedCleanupOrder) TestCaseA() {
    fmt.Printf("Start TestCase A. The externalResoure is: %d\\n", externalResource)

    s.Run("Sub-TestCase A", func() {
        fmt.Printf("Finish Sub-TestCase A. The externalResoure is: %d\\n", externalResource)
    })
    fmt.Printf("Finish TestCase A. The externalResource is: %d\\n", externalResource)
}

The result

The output of the above code is (with line number)

1 Start TestCase A. The externalResoure is: 1
2     SetupSubTest. The externalResoure is: 2
3     Finish Sub-TestCase A. The externalResoure is: 2
4 Back to TestCase A. The externalResource is: 2
5 TearDownTest
6     Cleanup subTest. The externalResoure is: 1

Notice line 4 and line 6. The externalResource is still 2 when the subtest is finished and the parent test is resuming! externalResource is not cleaned until the parent test is finished.

According to the documentation of Cleanup:

Cleanup registers a function to be called when the test (or subtest) and all its subtests complete. Cleanup functions will be called in last added, first called order.

It only guarantee that a registered function is called after something.

It does NOT guarantee when a registered function is called before something.

The solution

If we add a TearDownSubTest to reset externalResource, then its value is cleaned as we need

func (s *TestUnexpectedCleanupOrder) TearDownSubTest() {
    externalResource = 1
    fmt.Printf("\\tTeardown subTest. The externalResoure is: %d\\n", externalResource)
}
Start TestCase A. The externalResoure is: 1
    SetupSubTest. The externalResoure is: 2
    Finish Sub-TestCase A. The externalResoure is: 2
    Teardown subTest. The externalResoure is: 1
Back to TestCase A. The externalResource is: 1
TearDownTest
    Cleanup subTest. The externalResoure is: 1

Summary

We can use Cleanup to clean up resources for normal tests. However, we need to use TearDownSubTest to clean the resource held by subtests.

The source code

https://github.com/xuanyuwang/testify-examples/tree/main/unexpected-cleanup-order