如何为自定义Go内存数据存储编写单元测试?
内存数据存储的单元测试方法
我要做简单的REST API,自己实现了一个内存数据存储,接口定义如下:
type datastore interface { Add(*Element) error Get(ElementID) (*Element, error) Update(*Element) error Delete(ElementID) error GetAll() []*Element }
存储结构体和初始化函数:
type Datastore struct { mu sync.Mutex bucket map[string]*Element } func NewDB() *Datastore { return &Datastore { bucket: make(map[string]*Element), } }
我已经写了部分测试用例,比如GetAll的测试:
func TestGetAllTODOTasks(t *testing.T) { ts := NewDB() var elem = &Element{fieldA : "A" , fieldB : "B"} ts.Add(elem) want := []*Element{elem} if got := ts.GetAll(); !reflect.DeepEqual(got, want) { t.Errorf("Got %v wanted %v", got, want) } }
但测试Update这类方法时,需要先调用Add再执行Update,比如:
func TestUpdateTODOTasks(t *testing.T) { ts := NewDB() var elem = &Element{ID: "test-id", fieldA : "A" , fieldB : "B"} err := ts.Add(elem) if err != nil { t.Errorf("=> failed to create: %v", err.Error()) } var updated_elem = &Element{ID: elem.ID, fieldA : "A-updated" , fieldB : "B"} err = ts.Update(updated_elem ) if err != nil { t.Errorf("=> failed to update: %v", err.Error()) } }
想了解这类内存数据存储的标准单元测试方法。
标准单元测试实践
1. 每个测试用例独立初始化存储实例
每个测试都通过NewDB()创建全新的存储实例,确保测试用例之间完全隔离,不会互相污染数据。这是内存存储测试的基础,避免前一个测试的残留数据影响后续测试结果。
2. 覆盖所有接口方法的「正常场景」和「异常场景」
针对每个接口方法,要覆盖成功路径和失败路径:
- Add方法:
- 正常添加单个元素,验证Get/GetAll能正确获取
- 添加重复ID的元素,验证返回预期错误
- Get方法:
- 获取存在的元素,验证数据一致
- 获取不存在的ID,验证返回预期错误
- Update方法:
- 更新已存在的元素,验证数据被正确修改
- 更新不存在的ID,验证返回预期错误
- 更新时传入不合法的元素(如ID为空),验证错误处理
- Delete方法:
- 删除已存在的元素,验证Get/GetAll无法再获取
- 删除不存在的ID,验证返回预期错误
- GetAll方法:
- 空存储时返回空切片
- 存储多个元素时返回完整列表
3. 抽离重复的测试辅助函数
像Update、Delete这类需要先添加元素的测试,可以把「创建并添加元素」的逻辑抽成辅助函数,减少代码重复:
func setupTestElement(t *testing.T, ds *Datastore) *Element { t.Helper() // 标记为辅助函数,错误信息会指向调用处而非函数内部 elem := &Element{ID: "test-id", FieldA: "A", FieldB: "B"} if err := ds.Add(elem); err != nil { t.Fatalf("failed to setup test element: %v", err) } return elem }
简化后的Update测试示例:
func TestUpdate_Success(t *testing.T) { ds := NewDB() originalElem := setupTestElement(t, ds) // 构造更新后的元素 updatedElem := &Element{ ID: originalElem.ID, FieldA: "A-updated", FieldB: originalElem.FieldB, } if err := ds.Update(updatedElem); err != nil { t.Fatalf("update failed unexpectedly: %v", err) } // 验证更新结果 retrievedElem, err := ds.Get(originalElem.ID) if err != nil { t.Fatalf("failed to get updated element: %v", err) } if !reflect.DeepEqual(retrievedElem, updatedElem) { t.Errorf("element not updated correctly: got %v, want %v", retrievedElem, updatedElem) } } func TestUpdate_NotFound(t *testing.T) { ds := NewDB() nonExistentElem := &Element{ID: "non-existent", FieldA: "test"} err := ds.Update(nonExistentElem) if err == nil { t.Error("expected error when updating non-existent element, got nil") } // 可进一步验证错误类型是否符合预期,比如自定义ErrNotFound // if !errors.Is(err, ErrNotFound) { // t.Errorf("expected error %v, got %v", ErrNotFound, err) // } }
4. 验证操作后的实际数据状态
不要只检查方法返回的错误是否符合预期,一定要通过其他接口验证数据的实际状态:
- Update后用Get确认数据已修改
- Delete后用Get或GetAll确认元素已移除
- Add后用GetAll确认元素存在
5. 测试并发安全性
因为Datastore用了sync.Mutex,必须测试并发读写场景下的安全性,避免竞态条件:
func TestDatastore_ConcurrentAccess(t *testing.T) { ds := NewDB() const numOps = 1000 var wg sync.WaitGroup wg.Add(numOps * 2) // 并发添加元素 for i := 0; i < numOps; i++ { go func(id int) { defer wg.Done() elem := &Element{ID: fmt.Sprintf("id-%d", id), FieldA: fmt.Sprintf("val-%d", id)} if err := ds.Add(elem); err != nil { t.Errorf("concurrent add failed: %v", err) } }(i) } // 并发获取元素 for i := 0; i < numOps; i++ { go func(id int) { defer wg.Done() _, err := ds.Get(fmt.Sprintf("id-%d", id)) // 允许找不到,因为添加和获取可能有先后顺序 if err != nil && !errors.Is(err, ErrNotFound) { t.Errorf("concurrent get failed: %v", err) } }(i) } wg.Wait() // 最终验证元素数量是否正确 if len(ds.GetAll()) != numOps { t.Errorf("expected %d elements, got %d", numOps, len(ds.GetAll())) } }
6. 使用子测试组织相关测试用例
用testing.T.Run把同一方法的不同测试场景组织成子测试,让测试结果更清晰:
func TestUpdate(t *testing.T) { t.Run("Success", func(t *testing.T) { // 成功更新的测试逻辑 }) t.Run("NotFound", func(t *testing.T) { // 更新不存在元素的测试逻辑 }) t.Run("InvalidID", func(t *testing.T) { // 更新ID为空的元素的测试逻辑 }) }
内容的提问来源于stack exchange,提问作者maantos
相关产品推荐
相关产品推荐

