以下为作者观点:
在Go语言中编写测试时,可以采用不同的策略。作为Trendyol的索引团队,我们在单元测试中经常使用FunctionPerTest。在某些时候,我们意识到需要对某些类型的代码进行重复地测试代码。意识到这一点后,我们开始寻找不同的测试策略来克服这个问题,并发现了Table-Driven Tests(表驱动测试)。
我们研究了Table-Driven Tests的方法,并开始在一些测试案例中使用它。这两种结构都是为了提高测试代码的可读性,但是每种方法都有其优点和缺点。
func Parse_Time_Successfully_When_Given_Date_Format_Is_Correct(t *testing.T) {
// Given
dateString := "2022-03-09T03:30:00.000Z"
// When
time, err := ParseToTimeWithUtc(dateString)
// Then
assert.NoError(t, err)
assert.Equal(t, int64(1646796600000), time.UnixMilli())
}
func Return_0_And_Error_When_Given_Date_Format_Is_Not_Correct(t *testing.T) {
// Given
dateString := "2022-03-09T06:30:00"
// When
time, err := ParseToTimeWithUtc(dateString)
// Then
assert.Error(t, err)
assert.Equal(t, int64(0), time.UnixMilli())
}
首先,我们使用FunctionPerTest方法写了一个测试代码。
对于第一个测试案例,代码的编写很简单。每个部分要做什么都很清楚:
• Given -> 指定输入
• When -> 调用解析方法
• Then -> 作出断言
它很容易阅读,因为代码很小并且相互隔离。
当涉及到下面的情况时,事情就开始变得糟糕了。我们必须为每个案例重复相同的方法,只是改变输入值和预期结果。随着测试案例的增加,测试代码变得越来越糟。因为我们想测试所有不同的情况,我们不得不为同一个代码块编写多个重复的测试方法。由于重复和行数的增加,测试代码变得非常难读。
当我们想改变代码的一小部分时,比如改变输入的类型,我们也不得不在每个测试方法上改变它,测试代码的可维护性变得不可能。
测试代码中没有结构。如果我们想在一个测试案例中添加另一个断言,不必在其他案例中添加它。一段时间后,在某些情况下,我们可能开始忘记写一些断言,即使我们不得不写。
总结:
● 测试编写速度/难度:开始时很容易,但在添加更多的案例后会变得复杂;
● 可读性:当有较少的案例时很简单,但在增加更多的案例后变得很难;
● 简单性:测试中没有结构,可能会出现重复,因此复杂性增加;
● 可维护性:难以维护,代码修改会影响每个测试代码。
func TestParseDate(t *testing.T) {
m := func(millis int) int {
return millis * 1000000
}
tests := []struct {
date string
expected time.Time
wantErr assert.ErrorAssertionFunc
}{
{"2022-03-09T03:30:11.123Z", time.Date(2022, time.March, 9, 3, 30, 11, m(123), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.12Z", time.Date(2022, time.March, 9, 3, 30, 11, m(120), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.1Z", time.Date(2022, time.March, 9, 3, 30, 11, m(100), time.UTC), assert.NoError},
{"2022-03-09T03:30:11Z", time.Date(2022, time.March, 9, 3, 30, 11, m(0), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.816253Z", time.Date(2022, time.March, 9, 3, 30, 11, 816253000, time.UTC), assert.NoError},
{"2022-03-09T03:30:11.Z", time.UnixMilli(0), assert.Error},
{"2022-03-09T03:30:11", time.UnixMilli(0), assert.Error},
{"invalid", time.UnixMilli(0), assert.Error},
}
for _, test := range tests {
t.Run(test.date, func(t *testing.T) {
tm, err := ParseToTimeWithUtc(test.date)
assert.Equal(t, test.expected, tm)
test.wantErr(t, err)
})
}
}
在FunctionPerTest方法之后,我们为同一个方法写了table tests (表测试)。
第一个案例很难写,因为我们需要构建table tests的结构。我们对这种方法没有足够的经验,所以我们需要在互联网上做一些搜索。我们使用的IDE能够生成Table-Driven Tests方法的测试代码,所以我们向IDE寻求帮助,并在生成的代码上写了第一个测试。
func TestParseDate(t *testing.T) {
m := func(millis int) int {
return millis * 1000000
}
tests := []struct {
date string
expected time.Time
wantErr assert.ErrorAssertionFunc
}{
{"2022-03-09T03:30:11.123Z", time.Date(2022, time.March, 9, 3, 30, 11, m(123), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.12Z", time.Date(2022, time.March, 9, 3, 30, 11, m(120), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.1Z", time.Date(2022, time.March, 9, 3, 30, 11, m(100), time.UTC), assert.NoError},
{"2022-03-09T03:30:11Z", time.Date(2022, time.March, 9, 3, 30, 11, m(0), time.UTC), assert.NoError},
{"2022-03-09T03:30:11.816253Z", time.Date(2022, time.March, 9, 3, 30, 11, 816253000, time.UTC), assert.NoError},
{"2022-03-09T03:30:11.Z", time.UnixMilli(0), assert.Error},
{"2022-03-09T03:30:11", time.UnixMilli(0), assert.Error},
{"invalid", time.UnixMilli(0), assert.Error},
}
for _, test := range tests {
t.Run(test.date, func(t *testing.T) {
tm, err := ParseToTimeWithUtc(test.date)
assert.Equal(t, test.expected, tm)
test.wantErr(t, err)
})
}
}
测试代码迫使我们编写结构化的测试。当添加新的断言时,这使我们能够确保断言影响到所有的情况。通过这种方式,增加缺失控制的可能性已经减少。
表格驱动的测试结构使我们能够保持代码更有组织性和可读性。我们几乎不费吹灰之力就可以添加案例。额外的测试很容易写,所以我们实现了比其他方法更多的测试案例,并可以通过改变输入来编写新的案例。
总结:
● 测试编写的速度/难度:一开始很难,但很容易增加新的案例;
● 可读性:由于结构的原因,容易阅读;
● 简洁性:由于结构的原因,复杂性和重复性降低了;
● 可维护性:易于维护,易于添加新的输入和断言。
我们发现,当你有大量的测试用例时,Table-Driven Tests使事情变得更容易,这些测试用例只在其输入方面有所不同。我们决定扩大这方面的工作,确保这种方法适用于更复杂的测试。
在观察了这两种策略在基本方法上的优缺点后,我们将表驱动的测试方法应用于我们的一个更为复杂的方法进行服务调用。
这个方法已经有了用FunctionPerTest策略编写的测试代码,所以我们用它们来与表格测试进行比较。在测试中,我们需要创建服务的模拟,并控制服务调用的数量是否正确。下面,就是我们观察的结果:
FunctionPerTest
该结构允许我们对可以测试的内容和如何编写测试代码进行灵活处理。这一特点,无疑使它成为测试具有复杂功能和边缘案例的方法的一个好选择。
在可维护性方面,测试代码变得复杂而难以改变。测试代码的复杂性使得可读性变得困难。一段时间后,我们在测试类中迷失了方向,试图在不同的测试案例之间切换。
Table-Driven Tests
用这种方法写同样的测试是很困难的,因为我们不得不写大量的代码来设置构建,不得不决定我们的结构模型和预期值。
当写完第一个案例后,测试代码变得容易阅读和维护。虽然添加新的测试方法很难,但在现有的测试中添加新的案例比FunctionPerTest的方法相对容易。
另外,尽管添加新的案例变得很容易,但测试变得太大,难以阅读。由于条件性分支的存在,Mocking logic也更难。一段时间后,我们不得不在代码中添加逻辑表达式,或者将这些测试分开。这两种方法都使复杂性和可读性比以前更差。
整体来看,FunctionPerTest和Table-Driven Test方法都有其优点和缺点。选择哪一种取决于你的代码的功能和要求,并会影响你的测试代码的复杂性和可维护性。
总结一下我们的工作成果:
如果你有一个具有复杂逻辑和服务调用的代码,可以选择FunctionPerTest。当你没有很多测试用例时,用这种方法编写测试很容易,但随着测试用例数量的增加,会变得复杂和难以处理。
当你有一个简单的代码,有很多类似的测试用例,只是在测试中使用的数据不同时,Table-Driven Test更有用。通过使用表格,你可以很容易地改变每个测试的数据,而不必重写整个测试。
最后: 下方这份完整的软件测试视频学习教程已经整理上传完成,朋友们如果需要可以自行免费领取【保证100%免费】
这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!