A New Way of Writing SocketIO Package Tests
Introducing a new style of writing tests in the socketio package. This update will help writing tests for the package clean and consistent. This should allow people new to the page to quickly understand what’s going on so they can help in efforts to provide comprehensive testing.
The current tests in the project usually follow the format:
var tests = []struct{
name string
param1 string
want string
}{}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
a := doSomething(test.param1)
assert.Equal(t, test.want, a)
})
}
This is a best practice in Go, using a table driven test[1] design, and it has several things right. The tests are named and processed using t.Run to run the tests as sub-tests. Using an assert library provides consistency across t.Error messages. Using a struct array to manage the tests gives a quick view in the the parameters that are being tested.
However, there is some room for improvement.
A more recent way of writing tests using closure based testing has written about by Jack Lamond[2] requires a bit more boilerplate with additional benefits. The tests moved to this format look like the following:
var test = func(param1, want string) func(*testing.T) {
return func(t *testing.T) {
a := doSomething(param1)
assert.Equal(t, want, a)
}
}
t.run(<name>, test(<param1>, <want>))
As the above example shows, there’s a lot to like about the style. It’s an underrated feature that the test code can actually be at the top of the function; making it easy to see what is being tested without scrolling through all of the testing parameters first. It’s easy to add new tests that run in their own sub-test using the t.run directly outside of the parameters struct. It also allows the parameters of what will be tested to be shown clearly, outside of a block the array of parameters that table-driven tests promote.
With some modifications there could be room to do even better:
All test cases in the njones/socketio project will be updated to basically follow the format below:
var opts []options
// tests
var test = func(options …option) func(param1, want string) func(*testing.T) {
return func(param1, want string) func(*testing.T) {
return func(t *testing.T) {
for _, opt := range options {
opt(t)
}
a := doSomething()
assert.Equal(t, want, a)
}
}
}
// test cases
tests := map[string]func() (param1, want string) {
<name>: func() (param1, want string) { return <param1>, <want> },
}
// test runner
for name, testing := range tests {
t.run(name, test(opts…)(testing()))
}
This is a mix between the two styles of table-driven and closure driven testing. It provides something like a table driven test (but in the form of a map, with the names of the tests as the keys) and it still uses closures to run the test. Is this the best of both worlds or is it the worst, or a mix of the two?
The biggest benefits of writing tests in this manner are:
- The test function can still be at the top of the test function
- Closures can be used to encapsulate test parameters
- The map keeps the name of the test out of the struct and ensures unique test names
- The map runs slightly differently each time, making sure no tests depend on each other
- The
t.Runcan still be called outside of the loop, so adding a test quickly can be achieved. - The function inside of the map that provides the test parameters can hold closure values for more flexibility.
- The code is clearly separated between “the test”, “the test parameters” and “the test runner”
There is an added options function that can be used to control which tests are run based on outside factors. For an example a runTests(“<name”) function can provide functionality where only that test is run and all other are skipped using the t.SkipNow() function. This provides the additional benefits:
- Tests that are skipped are known and don’t need to be “commented out”
- Allows skipping a single or groups of test with any formula possible.
Some of the drawbacks are:
- The test map is verbose because of having to provide a function signature each time
- There is a lot of nested functions for the original test cases (it’s three deep)
- The
t.Runhas a lot of function calls - There is a fair amount of boilerplate that needs to be setup to get the full benefits
Outside of the drawbacks that are mentioned above. This seems to be a solid way of writing tests that gives a lot of flexibility. All of the tests in the repository will be converted to this style. This is a easy way to add and extend the tests within any package in the repository. Hopefully this encourages contributors and allows maintaining hight test coverage throughout the repository.
I’m always looking forward to keeping tests fresh and up-to-date.
[1] https://dave.cheney.net/2019/05/07/prefer-table-driven-tests [2] https://medium.com/@cep21/closure-driven-tests-an-alternative-style-to-table-driven-tests-in-go-628a41497e5e