• A History of Testing in Go at SmartyStreets
  • By Michael Whatcott
  • Translation from: The Gold Project
  • This article is permalink: github.com/xitu/gold-m…
  • Translator: kasheemlew
  • Proofread by: StellaBauhinia

I get asked these two interesting questions a lot lately:

  1. Why did you change your testing tool (from GoConvey) to GUnit?
  2. Do you advise everyone to do that?

These are good questions, and as GoConvey’s co-founder and lead author of GUnit, it’s my responsibility to explain them. Direct answer, too long don’t read series:

Question 1: Why switch to Gunit?

There are some issues that have been troubling us in using GoConvey, so we came up with an alternative solution that better reflects the focus of the test library to address these issues. In that situation, we can no longer make a transition upgrade scheme for GoConvey. I’ll go into more detail and boil it down to a simple declarative conclusion.

Question 2: Do you recommend that everyone do this (from GoConvey to GUnit)?

Not at all. I only recommend that you use the tools and libraries that will help you achieve your goals. You need to figure out what you need first, and then find or build the right tools as soon as possible. Test tools are the foundation on which you build your projects. If that resonates with you, then GUnit will be an attractive option in your selection. You have to study it and choose carefully. GoConvey’s community is growing and has many active maintainers. If you’d like to support this project, feel free to join us.


A long time ago in a galaxy far, far away…

Go to test

We first started using Go around the time Go 1.1 was released (mid-2013), and it was natural for us to come across the Go Test and “Testing” packages when we started writing code. I’m glad to see the Testing package included in the standard library or even the toolset, but I’m not impressed with its usual approach. Later in this article, we will use the famous “bowling game” exercise to show what we can achieve with different testing tools. (You can take the time to familiarize yourself with the production code to better understand the testing section.)

Here are some ways to write bowling game tests using the “Testing” package in the standard library:

import "testing"

// Helpers:

func (this *Game) rollMany(times, pins int) {
	for x := 0; x < times; x++ {
		this.Roll(pins)
	}
}
func (this *Game) rollSpare() {
	this.rollMany(2, 5)
}
func (this *Game) rollStrike() {
	this.Roll(10)
}

// Tests:

func TestGutterBalls(t *testing.T) {
	t.Log("Rolling all gutter balls... (expected score: 0)")
	game := NewGame()
	game.rollMany(20, 0)

	ifscore := game.Score(); score ! = 0 { t.Errorf("Expected score of 0, but it was %d instead.", score)
	}
}

func TestOnePinOnEveryThrow(t *testing.T) {
	t.Log("Each throw knocks down one pin... (expected score: 20)")
	game := NewGame()
	game.rollMany(20, 1)

	ifscore := game.Score(); score ! = 20 { t.Errorf("Expected score of 20, but it was %d instead.", score)
	}
}

func TestSingleSpare(t *testing.T) {
	t.Log("Rolling a spare, then a 3, then all gutters... (expected score: 16)")
	game := NewGame()
	game.rollSpare()
	game.Roll(3)
	game.rollMany(17, 0)

	ifscore := game.Score(); score ! = 16 { t.Errorf("Expected score of 16, but it was %d instead.", score)
	}
}

func TestSingleStrike(t *testing.T) {
	t.Log("Rolling a strike, then 3, then 7, then all gutters... (expected score: 24)")
	game := NewGame()
	game.rollStrike()
	game.Roll(3)
	game.Roll(4)
	game.rollMany(16, 0)

	ifscore := game.Score(); score ! = 24 { t.Errorf("Expected score of 24, but it was %d instead.", score)
	}
}

func TestPerfectGame(t *testing.T) {
	t.Log("Rolling all strikes... (expected score: 300)")
	game := NewGame()
	game.rollMany(21, 10)

	ifscore := game.Score(); score ! = 300 { t.Errorf("Expected score of 300, but it was %d instead.", score)
	}
}
Copy the code

For those of you who have used xUnit before, here are two things that will make you uncomfortable:

  1. Because there is no unifiedSetupFunctions/methods can be used, and the game structure needs to be created repeatedly in all games.
  2. All assertion error messages have to be written and mixed in with an if expression that checks the antisense of the forward assertion statement you are writing. When using the comparison operator (<,>,< => =) are even more annoying.

So, we explored how to test and got into the depth of why the Go community abandoned the idea of “our favorite test helper” and “assertion methods” in favor of “table-driven” testing to reduce template code. Rewrite the above example with table-driven tests:

import "testing"

func TestTableDrivenBowlingGame(t *testing.T) {
	for _, test := range []struct {
		name  string
		score int
		rolls []int
	}{
		{"Gutter Balls", 0, []int{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
		{"All Ones", 20, []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}},
		{"A Single Spare", 16, []int{5, 5, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
		{"A Single Strike", 24, []int{10, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
		{"The Perfect Game", 300, []int{10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10}},
	} {
		game := NewGame()
		for _, roll := range test.rolls {
			game.Roll(roll)
		}
		ifscore := game.Score(); score ! = test.score { t.Errorf("FAIL: '%s' Got: [%d] Want: [%d]", test.name, score, test.score)
		}
	}
}
Copy the code

Yes, this is completely different from the previous code.

Advantages:

  1. The new code is much shorter! The test suite now has only one test function.
  2. Using loop statements solves the problem of setup duplication.
  3. Similarly, the user will only get the error code from an assertion statement.
  4. During debugging, you can easily add one to the struct definitionskip boolLet’s skip some tests

Disadvantages:

  1. The definition of an anonymous struct and the declaration of a loop look odd.
  2. Table-driven testing is only effective in some simple situations that involve only reading/reading data. As things get more complicated, it becomes cumbersome, and it is not easy (or possible) to extend the entire test with a single struct.
  3. Use slice to indicate that throws/ Rolls are “annoying.” We could have simplified it with some thought, but this would have complicated the logic of our template code.
  4. Even with only one assertion to write, this indirect/negative test makes me angry.

GoConvey

Now, we can’t just be satisfied with go Tests out of the box, so we’re using the tools and libraries that GO provides to implement our own testing methods. If you look closely at the SmartyStreets GitHub Page, you’ll notice one of the more well-known warehouses — GoConvey. It was one of our earliest contributions to the Go OSS community.

GoConvey is a two-pronged testing tool. First, there’s a test runner that monitors your code, executes go tests when there are changes, and renders the results into a cool web page, which is then displayed in the browser. Second, it provides a library that allows you to write behavior-driven development style tests in the standard GO Test function. The good news is that you can choose to use none, some, or all of these features in GoConvey.

We developed GoConvey for two reasons: Redeveloping a test runner that we had intended to do in JetBrains IDEs (we were using ReSharper at the time) and creating a set of things that we loved like nUnit and Machine-.Specifications (which we were before we started using Go) .NET store).

Here’s the effect of rewriting the above test with GoConvey:

import (
	"testing"

	. "github.com/smartystreets/goconvey/convey"
)

func TestBowlingGameScoring(t *testing.T) {
	Convey("Given a fresh score card", t, func() {
		game := NewGame()

		Convey("When all gutter balls are thrown".func() {
			game.rollMany(20, 0)

			Convey("The score should be zero".func() {
				So(game.Score(), ShouldEqual, 0)
			})
		})

		Convey("When all throws knock down only one pin".func() {
			game.rollMany(20, 1)

			Convey("The score should be 20".func() {
				So(game.Score(), ShouldEqual, 20)
			})
		})

		Convey("When a spare is thrown".func() {
			game.rollSpare()
			game.Roll(3)
			game.rollMany(17, 0)

			Convey("The score should include a spare bonus.".func() {
				So(game.Score(), ShouldEqual, 16)
			})
		})

		Convey("When a strike is thrown".func() {
			game.rollStrike()
			game.Roll(3)
			game.Roll(4)
			game.rollMany(16, 0)

			Convey("The score should include a strike bonus.".func() {
				So(game.Score(), ShouldEqual, 24)
			})
		})

		Convey("When all strikes are thrown".func() {
			game.rollMany(21, 10)

			Convey("The score should be 300.".func() {
				So(game.Score(), ShouldEqual, 300)
			})
		})
	})
}
Copy the code

As with the table-driven approach, the entire test is contained in a function. As in the original example, we repeat rolls/ throws with a helper function. Unlike the other examples, we now have a neat, non-cumbersome, scope-based execution model. All tests share game variables, but the beauty of GoConvey is that each outer scope is executed against each inner scope. Therefore, each test is relatively isolated from each other. Obviously, you can easily get into trouble if you don’t pay attention to initialization and scope.

In addition, some weird things can happen when you add calls to Convey into a loop (such as trying to use GoConvey with table-driven tests). T is completely managed by top-level Convey calls (have you noticed that it differs slightly from other Convey calls?). , so you don’t have to pass this argument everywhere you need an assertion. But if you’ve written any slightly more complicated tests in GoConvey, you’ll see that fetching the helper function is quite complicated. Before I decided to get around this, I built a fixed structure to hold the state of all tests, and in that structure I created functions that Convey’s callbacks would use. So one moment it’s a covey block and scope, and the next moment it’s a fixed structure and its methods, which seems a little weird.

gunit

So, although it took a little time, we finally realized that we just wanted a Go version of xUint that needed to get rid of the weird dot imports and underline package level registration variables (see your GoCheck). We liked GoConvey’s assertions so much that we split off a separate repository from the original project, and gUnit was born:

import (
	"testing"

	"github.com/smartystreets/assertions/should"
	"github.com/smartystreets/gunit"
)

func TestBowlingGameScoringFixture(t *testing.T) {
	gunit.Run(new(BowlingGameScoringFixture), t)
}

type BowlingGameScoringFixture struct {
	*gunit.Fixture

	game *Game
}

func (this *BowlingGameScoringFixture) Setup() {
	this.game = NewGame()
}

func (this *BowlingGameScoringFixture) TestAfterAllGutterBallsTheScoreShouldBeZero() {
	this.rollMany(20, 0)
	this.So(this.game.Score(), should.Equal, 0)
}

func (this *BowlingGameScoringFixture) TestAfterAllOnesTheScoreShouldBeTwenty() {
	this.rollMany(20, 1)
	this.So(this.game.Score(), should.Equal, 20)
}

func (this *BowlingGameScoringFixture) TestSpareReceivesSingleRollBonus() {
	this.rollSpare()
	this.game.Roll(4)
	this.game.Roll(3)
	this.rollMany(16, 0)
	this.So(this.game.Score(), should.Equal, 21)
}

func (this *BowlingGameScoringFixture) TestStrikeReceivesDoubleRollBonus() {
	this.rollStrike()
	this.game.Roll(4)
	this.game.Roll(3)
	this.rollMany(16, 0)
	this.So(this.game.Score(), should.Equal, 24)
}

func (this *BowlingGameScoringFixture) TestPerfectGame() {
	this.rollMany(12, 10)
	this.So(this.game.Score(), should.Equal, 300)
}

func (this *BowlingGameScoringFixture) rollMany(times, pins int) {
	for x := 0; x < times; x++ {
		this.game.Roll(pins)
	}
}
func (this *BowlingGameScoringFixture) rollSpare() {
	this.game.Roll(5)
	this.game.Roll(5)
}
func (this *BowlingGameScoringFixture) rollStrike() {
	this.game.Roll(10)
}
Copy the code

As you can see, the process of removing helper methods is tedious because we are operating on the structure-level state, not the state of the local variables of the function. In addition, the configured/tested/cleared execution model in xUnit is much easier to understand than the scoped execution model in GoConvey. Here, *testing.T is now managed by the embedded * gUnit.fixture. This approach is equally intuitive for simple and complex interaction-based tests.

Another big difference between GUnit and GoConvey is that according to xUnit’s test pattern, GoConvey uses a shared fixed structure while GUnit uses a new fixed structure. Both approaches make sense, depending on your application scenario. New fixed constructs are generally more desirable in unit testing, while shared fixed constructs are more beneficial in situations where configuration consumption is high, such as integration testing or system testing.

Gunit uses t.perl – gradient () as the new gradient ensures that separate test items are independent, so gUnit uses t.perl – gradient () by default. Similarly, since we only use reflection to call subtests, we can use the -run argument to pick specific tests to execute:

$ go test -v -run 'BowlingGameScoringFixture/TestPerfectGame'
=== RUN   TestBowlingGameScoringFixture
=== PAUSE TestBowlingGameScoringFixture
=== CONT  TestBowlingGameScoringFixture
=== RUN   TestBowlingGameScoringFixture/TestPerfectGame
=== PAUSE TestBowlingGameScoringFixture/TestPerfectGame
=== CONT  TestBowlingGameScoringFixture/TestPerfectGame
--- PASS: TestBowlingGameScoringFixture (0.00s)
    --- PASS: TestBowlingGameScoringFixture/TestPerfectGame (0.00s)
PASS
ok  	github.com/smartystreets/gunit/advanced_examples	0.007s
Copy the code

Admittedly, some of the previous sample code still exists (such as some code in the file header). We installed the following live templates in GoLand, which will automatically generate most of the previous content. Here are the commands to install live templates in GoLand:

  • Turn on preferences in GoLand.
  • inEditor/Live templatesSelected inGoList, then click+And select “Real-time Template”
  • Give him an abbreviated namefixture)
  • Paste the following code intoTemplate textArea:
func Test$NAME$(t *testing.T) {
    gunit.Run(new($NAME$), t)
}

type $NAME$ struct {
    *gunit.Fixture
}

func (this *$NAME$) Setup() {
}

func (this *$NAME$) Test$ENDThe $() {}Copy the code
  • After that, click next to the “Application context not specified” warningdefine.
  • inGoPut a tick in front and clickOK.

Now all we have to do is open a test file, enter fixtures, and TAB the test template automatically.

conclusion

Let me conclude in the style of the Agile software development manifesto:

We practiced and helped others, and eventually found a better way to test software. This allowed us to achieve a lot of valuable things:

  • A new fixed structure is implemented on the basis of the shared fixed structure
  • A simple execution model is implemented with clever scope semantics
  • Structure-level scope is implemented with local function (or package-level) variable scope
  • The direct assertion function is implemented with inverted checks and manually created error messages

That said, while other test libraries are good (that’s one thing), we prefer GUnit (that’s another).

If you find any errors in the translation or other areas that need improvement, you are welcome to revise and PR the translation in the Gold Translation program, and you can also get corresponding bonus points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.


Diggings translation project is a community for translating quality Internet technical articles from diggings English sharing articles. The content covers the fields of Android, iOS, front end, back end, blockchain, products, design, artificial intelligence and so on. For more high-quality translations, please keep paying attention to The Translation Project, official weibo and zhihu column.