总览
许多非平凡的系统也是数据密集型或数据驱动型的。 测试系统中数据密集型的部分与测试代码密集型系统非常不同。 首先,数据层本身可能非常复杂,例如混合数据存储,缓存,备份和冗余。
所有这些机器都与应用程序本身无关,但必须进行测试。 其次,代码可能非常通用,并且为了对其进行测试,您需要生成以某种方式构造的数据。 在这5个系列的系列教程中,我将介绍所有这些方面,探讨使用Go设计可测试的数据密集型系统的几种策略,并深入研究具体示例。
在第一部分中,我将介绍抽象数据层的设计,该层可进行适当的测试,如何在数据层中进行错误处理,如何模拟数据访问代码以及如何针对抽象数据层进行测试。
针对数据层进行测试
处理实际数据存储及其复杂性并不复杂,并且与业务逻辑无关。 数据层的概念使您可以为数据提供一个简洁的界面,并隐藏有关如何存储数据以及如何访问数据的细节。 我将使用一个名为“ Songify”的示例应用程序进行个人音乐管理,以使用实际代码说明这些概念。
设计抽象数据层
让我们回顾一下个人音乐管理领域-用户可以添加歌曲并为其添加标签-并考虑我们需要存储什么数据以及如何访问它。 我们域中的对象是用户,歌曲和标签。 您希望对任何数据执行两类操作:查询(只读)和状态更改(创建,更新,删除)。 这是数据层的基本接口:
package abstract_data_layer
import "time"
type Song struct {
Url string
Name string
Description string
}
type Label struct {
Name string
}
type User struct {
Name string
Email string
RegisteredAt time.Time
LastLogin time.Time
}
type DataLayer interface {
// Queries (read-only)
GetUsers() ([]User, error)
GetUserByEmail(email string) (User, error)
GetLabels() ([]Label, error)
GetSongs() ([]Song, error)
GetSongsByUser(user User) ([]Song, error)
GetSongsByLabel(label string) ([]Song, error)
// State changing operations
CreateUser(user User) error
ChangeUserName(user User, name string) error
AddLabel(label string) error
AddSong(user User, song Song, labels []Label) error
}
请注意,此域模型的目的是提供一个简单但不完全琐碎的数据层来演示测试方面。 显然,在实际的应用程序中,将会有更多的对象,例如专辑,流派,艺术家,以及有关每首歌曲的更多信息。 如果一推再推,您始终可以在歌曲说明中存储有关歌曲的任意信息,并可以根据需要附加任意数量的标签。
实际上,您可能需要将数据层划分为多个接口。 一些结构可能具有更多的属性,并且这些方法可能需要更多的参数(例如,所有GetXXX()
方法可能需要一些分页参数)。 您可能需要其他数据访问接口和方法来进行维护操作,例如批量加载,备份和迁移。 有时公开异步数据访问接口来代替同步接口,或者除了同步接口之外,还有意义。
我们从这个抽象数据层中学到了什么?
- 一站式数据访问操作。
- 以域的形式清晰地查看我们应用程序的数据管理要求。
- 可以随意更改具体数据层实现的能力。
- 在具体数据层完整或稳定之前,能够根据接口及早开发域/业务逻辑层。
- 最后但并非最不重要的一点是,可以模拟数据层以快速,灵活地测试域/业务逻辑的能力。
数据层中的错误和错误处理
数据可以存储在本地数据中心和云的组合中,跨不同地理位置的多个群集上的多个分布式数据存储中。
会有失败,并且那些失败需要被处理。 理想情况下,错误处理逻辑(重试,超时,灾难性故障的通知)可以由具体的数据层处理。 当数据不可访问时,域逻辑代码应仅取回数据或一般错误。
在某些情况下,域逻辑可能希望对数据进行更细粒度的访问,并在某些情况下选择回退策略(例如,仅部分数据可用,因为无法访问集群的一部分,或者数据过时,因为未刷新缓存) )。 这些方面对数据层的设计及其测试都有影响。
就测试而言,您应该返回自己在抽象数据层中定义的错误,并将所有具体的错误消息映射到您自己的错误类型,或者依赖于非常通用的错误消息。
模拟数据访问代码
让我们模拟我们的数据层。 模拟的目的是在测试期间替换真实的数据层。 这要求模拟数据层公开相同的接口,并能够使用固定(或计算)响应来响应方法的每个序列。
另外,跟踪每个方法被调用多少次很有用。 我不会在这里演示它,但是甚至可以跟踪对不同方法的调用顺序,以及将哪些参数传递给每个方法以确保一定的调用链。
这是模拟数据层结构。
package concrete_data_layer
import (
. "abstract_data_layer"
)
const (
GET_USERS = iota
GET_USER_BY_EMAIL
GET_LABELS
GET_SONGS
GET_SONGS_BY_USER
GET_SONG_BY_LABEL
ERRORS
)
type MockDataLayer struct {
Errors []error
GetUsersResponses [][]User
GetUserByEmailResponses []User
GetLabelsResponses [][]Label
GetSongsResponses [][]Song
GetSongsByUserResponses [][]Song
GetSongsByLabelResponses[][]Song
Indices []int
}
func NewMockDataLayer() MockDataLayer {
return MockDataLayer{Indices: []int{0, 0, 0, 0, 0, 0, 0, 0}}
}
const
语句列出了所有支持的操作和错误。 每个操作在“ Indices
切片中都有其自己的索引。 每个操作的索引表示相应方法的调用次数以及下一个响应和错误应该是多少。
对于每个除错误外还具有返回值的方法,都有一部分响应。 调用模拟方法时,将返回相应的响应和错误(基于此方法的索引)。 对于除错误外没有返回值的方法,无需定义XXXResponses
切片。
请注意,所有方法均共享错误。 这意味着,如果您要测试一系列呼叫,则需要以正确的顺序注入正确数量的错误。 一种替代设计将为每个响应使用一对由返回值和错误组成的对。 NewMockDataLayer()
函数返回一个新的模拟数据层结构,其所有索引均初始化为零。
这是GetUsers()
方法的实现,它说明了这些概念。
func(m *MockDataLayer) GetUsers() (users []User, err error) {
i := m.Indices[GET_USERS]
users = m.GetUsersResponses[i]
if len(m.Errors) > 0 {
err = m.Errors[m.Indices[ERRORS]]
m.Indices[ERRORS]++
}
m.Indices[GET_USERS]++
return
}
第一行获取GET_USERS
操作的当前索引(最初将为0)。
第二行获取当前索引的响应。
如果已填充“ Errors
字段,则第三行至第五行分配当前索引的Errors
,并递增错误索引。 当测试快乐路径时,错误将为nil。 为了使其更易于使用,您可以避免初始化Errors
字段,然后每个方法都将为错误返回nil。
下一行将增加索引,因此下一次调用将获得正确的响应。
最后一行返回。 用户和err的命名返回值已被填充(对于err,默认为nil)。
这是另一种方法GetLabels()
,它遵循相同的模式。 唯一的区别是使用哪个索引,使用哪个罐头响应集合。
func(m *MockDataLayer) GetLabels() (labels []Label, err error) {
i := m.Indices[GET_LABELS]
labels = m.GetLabelsResponses[i]
if len(m.Errors) > 0 {
err = m.Errors[m.Indices[ERRORS]]
m.Indices[ERRORS]++
}
m.Indices[GET_LABELS]++
return
}
这是一个用例的主要示例,其中泛型可以节省很多样板代码。 可以利用反射来达到相同的效果,但这超出了本教程的范围。 此处的主要要点是,模拟数据层可以遵循通用模式并支持任何测试方案,您将很快看到。
一些只返回错误的方法呢? CreateUser()
出CreateUser()
方法。 它甚至更简单,因为它只处理错误,不需要管理固定的响应。
func(m *MockDataLayer) CreateUser(user User) (err error) {
if len(m.Errors) > 0 {
i := m.Indices[CREATE_USER]
err = m.Errors[m.Indices[ERRORS]]
m.Indices[ERRORS]++
}
return
}
该模拟数据层只是模拟接口并提供一些有用的服务进行测试的示例。 您可以提出自己的模拟实现或使用可用的模拟库。 甚至还有一个标准的GoMock框架。
我个人发现模拟框架易于实现,并且喜欢自己动手(通常自动生成它们),因为我将大部分开发时间都花在编写测试和模拟依赖项上。 YMMV。
针对抽象数据层进行测试
现在我们有了一个模拟数据层,让我们针对它编写一些测试。 重要的是要意识到,这里我们不测试数据层本身。 我们将在本系列后面的其他方法中测试数据层本身。 此处的目的是测试依赖抽象数据层的代码的逻辑。
例如,假设某个用户想要添加一首歌曲,但是我们为每个用户分配了100首歌曲。 预期的行为是,如果用户的歌曲数少于100且添加的歌曲是新的,则将添加该歌曲。 如果歌曲已经存在,则返回“重复歌曲”错误。 如果用户已经有100首歌曲,则返回“超出歌曲配额”错误。
让我们使用模拟数据层为这些测试用例编写一个测试。 这是一个白盒测试,这意味着您需要知道被测代码将调用哪种数据层方法,以及调用顺序,以便可以正确填充模拟响应和错误。 因此,测试优先方法在这里并不理想。 让我们先编写代码。
这是SongManager
结构。 它仅取决于抽象数据层。 这样一来,您就可以在生产过程中向其传递真实数据层的实现,而在测试过程中传递模拟数据层。
SongManager
本身完全DataLayer
接口的具体实现。 SongManager
结构还接受它存储的用户。 大概每个活动用户都有其自己的SongManager
实例,并且用户只能为其自己添加歌曲。 NewSongManager()
函数确保输入的DataLayer
接口不为nil。
package song_manager
import (
"errors"
. "abstract_data_layer"
)
const (
MAX_SONGS_PER_USER = 100
)
type SongManager struct {
user User
dal DataLayer
}
func NewSongManager(user User,
dal DataLayer) (*SongManager, error) {
if dal == nil {
return nil, errors.New("DataLayer can't be nil")
}
return &SongManager{user, dal}, nil
}
让我们实现一个AddSong()
方法。 该方法首先调用数据层的GetSongsByUser()
,然后进行多次检查。 如果一切正常,它将调用数据层的AddSong()
方法并返回结果。
func(lm *SongManager) AddSong(newSong Song,
labels []Label) error {
songs, err := lm.dal.GetSongsByUser(lm.user)
if err != nil {
return nil
}
// Check if song is a duplicate
for _, song := range songs {
if song.Url == newSong.Url {
return errors.New("Duplicate song")
}
}
// Check if user has max number of songs
if len(songs) == MAX_SONGS_PER_USER {
return errors.New("Song quota exceeded")
}
return lm.dal.AddSong(user, newSong, labels)
}
查看此代码,您可以看到我们忽略了另外两个测试用例:数据层方法GetSongByUser()
和AddSong()
的调用可能由于其他原因而失败。 现在,有了我们前面的SongManager.AddSong()
实现,我们可以编写一个涵盖所有用例的综合测试。 让我们从幸福的道路开始。 TestAddSong_Success()
方法创建一个名为Gigi的用户和一个模拟数据层。
它使用包含空切片的切片填充GetSongsByUserResponses
字段,当SongManager GetSongsByUser()
在模拟数据层上调用GetSongsByUser()
时,这将导致一个空切片。 无需对模拟数据层的AddSong()
方法进行任何调用,默认情况下,该方法将返回nil错误。 该测试仅验证确实没有从父调用SongManager的AddSong()
方法返回任何错误。
package song_manager
import (
"testing"
. "abstract_data_layer"
. "concrete_data_layer"
)
func TestAddSong_Success(t *testing.T) {
u := User{Name:"Gigi", Email: "gg@gg.com"}
mock := NewMockDataLayer()
// Prepare mock responses
mock.GetSongsByUserResponses = [][]Song{{}}
lm, err := NewSongManager(u, &mock)
if err != nil {
t.Error("NewSongManager() returned 'nil'")
}
url := https://www.youtube.com/watch?v=MlW7T0SUH0E"
err = lm.AddSong(Song{Url: url", Name: "Chacarron"}, nil)
if err != nil {
t.Error("AddSong() failed")
}
}
$ go test
PASS
ok song_manager 0.006s
测试错误条件也非常容易。 您可以完全控制数据层从对GetSongsByUser()
和AddSong()
的调用返回的内容。 这是一项测试,以验证在添加重复歌曲时您是否获得了正确的错误消息。
func TestAddSong_Duplicate(t *testing.T) {
u := User{Name:"Gigi", Email: "gg@gg.com"}
mock := NewMockDataLayer()
// Prepare mock responses
mock.GetSongsByUserResponses = [][]Song{{testSong}}
lm, err := NewSongManager(u, &mock)
if err != nil {
t.Error("NewSongManager() returned 'nil'")
}
err = lm.AddSong(testSong, nil)
if err == nil {
t.Error("AddSong() should have failed")
}
if err.Error() != "Duplicate song" {
t.Error("AddSong() wrong error: " + err.Error())
}
}
以下两个测试用例测试在数据层本身发生故障时是否返回了正确的错误消息。 在第一种情况下,数据层的GetSongsByUser()
返回错误。
func TestAddSong_DataLayerFailure_1(t *testing.T) {
u := User{Name:"Gigi", Email: "gg@gg.com"}
mock := NewMockDataLayer()
// Prepare mock responses
mock.GetSongsByUserResponses = [][]Song{{}}
e := errors.New("GetSongsByUser() failure")
mock.Errors = []error{e}
lm, err := NewSongManager(u, &mock)
if err != nil {
t.Error("NewSongManager() returned 'nil'")
}
err = lm.AddSong(testSong, nil)
if err == nil {
t.Error("AddSong() should have failed")
}
if err.Error() != "GetSongsByUser() failure" {
t.Error("AddSong() wrong error: " + err.Error())
}
}
在第二种情况下,数据层的AddSong()
方法返回错误。 由于对GetSongsByUser()
的第一次调用应该成功,所以mock.Errors
片包含两个项目:第一次调用为nil,第二次调用为error。
func TestAddSong_DataLayerFailure_2(t *testing.T) {
u := User{Name:"Gigi", Email: "gg@gg.com"}
mock := NewMockDataLayer()
// Prepare mock responses
mock.GetSongsByUserResponses = [][]Song{{}}
e := errors.New("AddSong() failure")
mock.Errors = []error{nil, e}
lm, err := NewSongManager(u, &mock)
if err != nil {
t.Error("NewSongManager() returned 'nil'")
}
err = lm.AddSong(testSong, nil)
if err == nil {
t.Error("AddSong() should have failed")
}
if err.Error() != "AddSong() failure" {
t.Error("AddSong() wrong error: " + err.Error())
}
}
结论
在本教程中,我们介绍了抽象数据层的概念。 然后,使用个人音乐管理领域,我们演示了如何设计数据层,构建模拟数据层以及如何使用模拟数据层测试应用程序。
在第二部分中,我们将专注于使用真实的内存数据层进行测试。 敬请关注。
翻译自: https://code.tutsplus.com/tutorials/testing-data-intensive-code-with-go-part-1--cms-29847