英文:
Table Testing Go Generics
问题
我很期待 Go 1.18,并且想测试新的泛型特性。使用起来感觉相当不错,但我遇到了一个问题:
如何对泛型函数进行表格测试?
我想出了这个代码,但是由于无法实例化 T
值,所以我需要在每个函数中重新声明我的测试逻辑。
(在我的项目中,我使用结构体而不是 string
和 int
。只是不想包含它们,因为已经有足够的代码了)
你会如何解决这个问题?
编辑:
这是代码:
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
type Item interface {
int | string
}
type store[T Item] map[int64]T
// add adds an Item to the map if the id of the Item isn't present already
func (s store[T]) add(key int64, val T) {
_, exists := s[key]
if exists {
return
}
s[key] = val
}
func TestStore(t *testing.T) {
t.Run("ints", testInt)
t.Run("strings", testString)
}
type testCase[T Item] struct {
name string
start store[T]
key int64
val T
expected store[T]
}
func testString(t *testing.T) {
t.Parallel()
tests := []testCase[string]{
{
name: "empty map",
start: store[string]{},
key: 123,
val: "test",
expected: store[string]{
123: "test",
},
},
{
name: "existing key",
start: store[string]{
123: "test",
},
key: 123,
val: "newVal",
expected: store[string]{
123: "test",
},
},
}
for _, tc := range tests {
t.Run(tc.name, runTestCase(tc))
}
}
func testInt(t *testing.T) {
t.Parallel()
tests := []testCase[int]{
{
name: "empty map",
start: store[int]{},
key: 123,
val: 456,
expected: store[int]{
123: 456,
},
},
{
name: "existing key",
start: store[int]{
123: 456,
},
key: 123,
val: 999,
expected: store[int]{
123: 456,
},
},
}
for _, tc := range tests {
t.Run(tc.name, runTestCase(tc))
}
}
func runTestCase[T Item](tc testCase[T]) func(t *testing.T) {
return func(t *testing.T) {
tc.start.add(tc.key, tc.val)
assert.Equal(t, tc.start, tc.expected)
}
}
英文:
I'm excited for Go 1.18 and wanted to test the new generics feature.
Feels pretty neat to use, but I stumbled over an issue:
How do you table test generic functions?
I came up with this code, but I need to redeclare my testing logic over each function since I can't instantiate T
values.
(Inside my project I use structs instead of string
and int
. Just didn't want to include them because it's already enough code)
How would you approach this problem?
Edit:
Here's the code:
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
type Item interface {
int | string
}
type store[T Item] map[int64]T
// add adds an Item to the map if the id of the Item isn't present already
func (s store[T]) add(key int64, val T) {
_, exists := s[key]
if exists {
return
}
s[key] = val
}
func TestStore(t *testing.T) {
t.Run("ints", testInt)
t.Run("strings", testString)
}
type testCase[T Item] struct {
name string
start store[T]
key int64
val T
expected store[T]
}
func testString(t *testing.T) {
t.Parallel()
tests := []testCase[string]{
{
name: "empty map",
start: store[string]{},
key: 123,
val: "test",
expected: store[string]{
123: "test",
},
},
{
name: "existing key",
start: store[string]{
123: "test",
},
key: 123,
val: "newVal",
expected: store[string]{
123: "test",
},
},
}
for _, tc := range tests {
t.Run(tc.name, runTestCase(tc))
}
}
func testInt(t *testing.T) {
t.Parallel()
tests := []testCase[int]{
{
name: "empty map",
start: store[int]{},
key: 123,
val: 456,
expected: store[int]{
123: 456,
},
},
{
name: "existing key",
start: store[int]{
123: 456,
},
key: 123,
val: 999,
expected: store[int]{
123: 456,
},
},
}
for _, tc := range tests {
t.Run(tc.name, runTestCase(tc))
}
}
func runTestCase[T Item](tc testCase[T]) func(t *testing.T) {
return func(t *testing.T) {
tc.start.add(tc.key, tc.val)
assert.Equal(t, tc.start, tc.expected)
}
}
答案1
得分: 4
我需要在每个函数上重新声明我的测试逻辑。
正确。
你的函数 runTestCase[T Item](tc testCase[T])
已经提供了一个合理的抽象层级。就像你所做的那样,你可以在这里放置一些关于启动测试和验证预期结果的通用逻辑。然而,仅此而已。
一个泛型类型(或函数)在某个具体类型上必须被实例化,而一个单独的测试表只能包含其中一个类型,或者 interface{}
/any
,但你不能用它来满足特定的约束,比如 int | string
。
然而,你不需要总是测试每个可能的类型参数。泛型的目的是编写适用于任意类型的代码,特别是约束的目的是编写适用于支持相同操作的任意类型的代码。
我建议只为不同的类型编写单元测试,如果代码使用了具有不同含义的运算符。例如:
- 数字类型的
+
运算符(求和)和字符串类型的+
运算符(拼接) - 数字类型的
<
和>
运算符(大于、小于)和字符串类型的<
和>
运算符(按字典顺序在之前或之后)
参见这里,其中 OP 正试图做类似的事情。
英文:
> I need to redeclare my testing logic over each function
Correct.
Your function runTestCase[T Item](tc testCase[T])
already provides a reasonable level of abstraction. As you did, you can put there some common logic about starting the test and verifying the expected outcome. However that's about it.
A generic type (or function) under test has to be instantiated with some concrete type sooner or later, and one single test table can only include either one of those types — or interface{}
/any
, which you can not use to satisfy a specific constraint like int | string
.
However, you do not need to always test every possible type parameter. The purpose of generics is to write code that works with arbitrary types, and in particular the purpose of constraints is to write code with arbitrary types that support the same operations.
I'd advise to write unit tests for different types only if the code makes use of operators that have different meanings. For example:
- the
+
operator for numbers (sum) and strings (concatenation) - the
<
and>
for numbers (greater, lesser) and strings (lexicographically before or after)
See also this where the OP was attempting to do something similar
答案2
得分: 0
从技术上讲,如果你使用any
(或interface{}
)来定义测试用例,你可以避免为T
允许的每种类型编写单独的函数,但这会增加测试运行器的复杂性,在能够调用add()
之前,你必须断言通过空接口传递的类型。
仅仅因为可能可以这样做,并不意味着这样做就更好,我对此有一些个人的想法:
- KISS 始终是一个好原则
- 除了断言预期结果外,单元测试还可以作为其他开发人员的文档片段,用于描述边界情况和给定某些输入时函数的行为,因此保持代码简洁是可取的。
另一方面,可能存在一些场景,在这些场景中,使用我下面写的方法可能更方便:想象一下,你必须将大量的真实数据输入到函数中,因此你最终捕获了成千上万个真实请求,这些请求具有混合的int
和string
输入,用于测试你想要测试的通用函数。在这种情况下,将捕获的请求反序列化为具有空接口成员的测试用例切片可能更容易。
func TestStore(t *testing.T) {
t.Run("testAny", testAny)
}
type testCaseAny struct {
name string
start any
key int64
val any
expected any
}
func testAny(t *testing.T) {
t.Parallel()
tests := []testCaseAny{
{
name: "empty map int",
start: store[int]{},
key: 123,
val: 456,
expected: store[int]{
123: 456,
},
},
{
name: "existing key int",
start: store[int]{
123: 456,
},
key: 123,
val: 999,
expected: store[int]{
123: 456,
},
},
{
name: "empty map string",
start: store[string]{},
key: 123,
val: "test",
expected: store[string]{
123: "test",
},
},
{
name: "existing key string",
start: store[string]{
123: "test",
},
key: 123,
val: "newVal",
expected: store[string]{
123: "test",
},
},
}
for _, tc := range tests {
t.Run(tc.name, runTestCaseAny(tc))
}
}
func runTestCaseAny(tc testCaseAny) func(t *testing.T) {
return func(t *testing.T) {
storeInt, ok := tc.start.(store[int])
if ok {
valInt, ok := tc.val.(int)
assert.True(t, ok) // 这里实际上是在测试测试用例,而不是add()函数
storeInt.add(tc.key, valInt)
assert.Equal(t, tc.start, tc.expected)
return
}
storeStr, ok := tc.start.(store[string])
if ok {
valStr, ok := tc.val.(string)
assert.True(t, ok)
storeStr.add(tc.key, valStr)
assert.Equal(t, tc.start, tc.expected)
return
}
assert.True(t, false) // 如果store[int]和store[string]都不匹配类型,则测试失败
}
}
英文:
Technically, you can avoid writing separate functions for each type allowed by T
, if you use any
(or interface{}
) to define the test cases, but this comes with the cost of added complexity in the test runner, where you have to assert the type passed through the empty interface before being able to call add()
.
Just because it is possible doesn't mean it's also better and I have a couple personal thoughts about this:
- KISS is a always a good principle
- apart from asserting the expected results, the unit tests may also serve as a piece of documentation for other developers, in terms of edge cases and how the function is behaving given certain inputs so keeping the code clean is desirable.
On the other hand, there may be scenarios in which, using the approach I wrote below, may be more convenient: imagine you must feed a lot of real data into the function, so you end up capturing thousands of real requests which have mixed int
and string
inputs for the generic function you want to test. In such a scenario it could be easier to deserialize the captured requests into a slice of test cases that have empty interface members.
func TestStore(t *testing.T) {
t.Run("testAny", testAny)
}
type testCaseAny struct {
name string
start any
key int64
val any
expected any
}
func testAny(t *testing.T) {
t.Parallel()
tests := []testCaseAny{
{
name: "empty map int",
start: store[int]{},
key: 123,
val: 456,
expected: store[int]{
123: 456,
},
},
{
name: "existing key int",
start: store[int]{
123: 456,
},
key: 123,
val: 999,
expected: store[int]{
123: 456,
},
},
{
name: "empty map string",
start: store[string]{},
key: 123,
val: "test",
expected: store[string]{
123: "test",
},
},
{
name: "existing key string",
start: store[string]{
123: "test",
},
key: 123,
val: "newVal",
expected: store[string]{
123: "test",
},
},
}
for _, tc := range tests {
t.Run(tc.name, runTestCaseAny(tc))
}
}
func runTestCaseAny(tc testCaseAny) func(t *testing.T) {
return func(t *testing.T) {
storeInt, ok := tc.start.(store[int])
if ok {
valInt, ok := tc.val.(int)
assert.True(t, ok) // here we're actually testing the test case, not the add() function
storeInt.add(tc.key, valInt)
assert.Equal(t, tc.start, tc.expected)
return
}
storeStr, ok := tc.start.(store[string])
if ok {
valStr, ok := tc.val.(string)
assert.True(t, ok)
storeStr.add(tc.key, valStr)
assert.Equal(t, tc.start, tc.expected)
return
}
assert.True(t, false) // fail the test if neither store[int] nor store[string] matched the type
}
}
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论