This article is about the maintainability of unit test code. I don’t know if you’ve ever written a spaghetti-style unit test, which is structured like this. Frankly, I’ve written quite a few.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func TestFoo(t *testing.T) {
    // test get
    resp, err := GET(blabalbal)
    assert.Nil(err)
    ...

    // test post
    resp, err = POST(blabalbal)
    assert.Nil(err)
    ...

    // test update
    resp, err = PUT(blabalbal)
    assert.Nil(err)
    ...
}

Most people write this for convenience: to initialize variables and to reuse them. But when the use case code is too long, and the single test happens to fail, it is difficult to find the specific reason, and it takes a lot of time to locate it when debugging.

Solutions

The Go community’s testing framework, has provided two more mature solutions.

Let’s look at the two separately.

GoConvey

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package package_name

import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
)

func TestSpec(t *testing.T) {
    // Only pass t into top-level Convey calls
    Convey("Given some integer with a starting value", t, func() {
        x := 1

        Convey("When the integer is incremented", func() {
            x++

            Convey("The value should be greater by one", func() {
                So(x, ShouldEqual, 2)
            })
        })

        Convey("When the integer is incremented again", func() {
            x++

            Convey("The value should be greater by one", func() {
                So(x, ShouldEqual, 2)
            })
        })
    })
}

The code, as above, is passable.

One of the special things about GoConvey is that it is executed in a tree, not from top to bottom. That is, it is a depth-first traversal and does not share variables, so that when When the integer is incremented and When the integer is incremented again are executed, the value of x is the value of 1 assigned by the upper level.

The above code is executed in the following order.

  • Given some integer... -> When the integer is incremented -> The value should be....
  • Given some integer... -> When the integer is incremented again -> The value should be....

testify assert suite

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Basic imports
import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/suite"
)

// Define the suite, and absorb the built-in basic suite
// functionality from testify - including a T() method which
// returns the current testing context
type ExampleTestSuite struct {
    suite.Suite
    VariableThatShouldStartAtFive int
}

// Make sure that VariableThatShouldStartAtFive is set to five
// before each test
func (suite *ExampleTestSuite) SetupTest() {
    suite.VariableThatShouldStartAtFive = 5
}

// All methods that begin with "Test" are run as tests within a
// suite.
func (suite *ExampleTestSuite) TestExample() {
    assert.Equal(suite.T(), 5, suite.VariableThatShouldStartAtFive)
    suite.Equal(5, suite.VariableThatShouldStartAtFive)
}

// In order for 'go test' to run this suite, we need to create
// a normal test function and pass our suite to suite.Run
func TestExampleTestSuite(t *testing.T) {
    suite.Run(t, new(ExampleTestSuite))
}

suite mainly through the following hook functions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SetupAllSuite has a SetupSuite method, which will run before the
// tests in the suite are run.
type SetupAllSuite interface {
    SetupSuite()
}

// SetupTestSuite has a SetupTest method, which will run before each
// test in the suite.
type SetupTestSuite interface {
    SetupTest()
}

// TearDownAllSuite has a TearDownSuite method, which will run after
// all the tests in the suite have been run.
type TearDownAllSuite interface {
    TearDownSuite()
}

// TearDownTestSuite has a TearDownTest method, which will run after
// each test in the suite.
type TearDownTestSuite interface {
    TearDownTest()
}

In this way, shared variables and destruction, etc. can be placed in the corresponding functions for processing, thus integrating a series of functions into a set of tests.

Summary

I personally prefer to use convey, as long as you understand its tree-like execution pattern, you will find that the overall test code can be written much less and the structure is very clear. Through the tree organization, you can put test cases of the same topic in the same TestXXX function, and then refine them in each Convey function according to the conditions layer by layer, and finally pass in assertions through So for verification.

This article does not talk about specific technical things, but mainly briefly introduces two unit testing frameworks, but the most important thing is to show that unit test code, also need to be valued code, but also need to be well organized code structure and use cases, unit tests are used to ensure the execution of the code itself, usually written after the frequency of change is not too high, if the use of spaghetti-style organization, after a long time It is very difficult to debug after a long time.

With the help of convey, you can subdivide the test case code into different functions without interfering with each other, which is very beneficial to maintainability.