写点什么

数据库测试的基础要素

  • 2021-10-22
  • 本文字数:7493 字

    阅读完需:约 25 分钟

数据库测试的基础要素

众所周知,在测试行业,模拟数据库和其他持久化层会降低测试效率。在测试时,如果一个组件不属于测试的一部分,就很难测试它与其他组件之间的交互行为。遗憾的是,这个行业只专注于功能层面的测试,很少有人接受过其他类型测试的培训。这篇文章通过引入数据库测试的概念来纠正这个问题。这些技术也适用于其他类型的持久化机制,比如调用微服务。


为了了解如何测试数据库,我们先“忘记”与单元测试和集成测试相关的一些概念。直接一点说,现如今对这些术语的定义已经偏离了它们最初的含义。所以,在文章的剩余部分,我们将不再使用它们。


测试最本质的目的是生成信息。一个测试用例在执行完之后应该生成与被测试的东西相关的信息,这些信息是你原先不知道的。生成的信息越多越好。因此,我们倾向于“一个测试用例应该尽可能提供可以证明某个事实所需的断言”,而不是“一个测试用例只提供一个断言”。


另一个有问题的观点是“所有的测试都应该是独立的”。人们通常会误读这个观点,认为每一个测试都应该使用 Mock,你所测试的每一个功能应该与它们的依赖项隔离。但这样是毫无意义的,因为在生产环境中,这些功能不可能与它们的依赖项隔离。相反,你应该尽可能像在生产环境中那样测试,这样才会发现尽可能多的问题。


“所有的测试都应该是独立的”这句话真正的意思是说,每一个测试都可以独立于其他测试运行。或者,换句话说,你可以按照任意的顺序、在任意时刻运行每一个测试或一组测试。


很多人在测试时把事情弄复杂了。他们在执行每一个测试(甚至是每一个单独的测试用例)之前都会完整地重建数据库。这带来了一些问题。


首先,测试变慢了。创建新数据库和填充数据需要时间,这通常是造成数据库测试变慢的直接原因,而这又反过来让人们不愿意去执行测试,甚至不准备这类测试。


另一个问题与数据库里的记录数量有关。当数据库里只有一条代码,有些代码运行得很好,但当有成千上百条记录时就会失败。在某些情况下,比如查询语句里缺少了 WHERE 子句,只要两条记录就会导致测试失败。


因此,我们需要编写数据库端的测试。不管在任何时候,你都应该用生产环境的数据副本来执行测试,并看着它们全部执行成功。


.NET ORM Cookbook”就给出了一个很好的示例。这个项目有 1600 多个数据库端测试用例,它们可以按照任意顺序执行。为了理解其中的原理,我们将构建一些简单的 CRUD 测试来解释这些概念。


接下来的问题是一致性。人们常说,每一个测试都应该具备完美的一致性,也就是说,每次运行同一个测试都应该得到相同的结果。为了获得一致性,不能使用基于时间或随机生成的测试数据,也不能被环境影响到。


在测试数据库时,这是无法实现的。因为总有一些不可预测的问题出现,比如网络连接问题、磁盘问题、旧数据,等等。


但并不是说不具备这种一致性的测试就是不可靠的。尽管一些属性会不一致,但测试在大部分时间都会返回相同的结果。随机出现的失败让可以你知道应用程序在哪些情况下会有怎样的表现。


注意:本文所有的例子都可以再 GitHub 上找到。

创建记录

我们的第一个测试是创建一条记录。为了简单起见,我们选择了 EmployeeClassification 类,它只有四个字段:


int EmployeeClassificationKey string? EmployeeClassificationName bool IsEmployee bool IsExempt
复制代码


在检查数据库模式时,我们发现 EmployeeClassificationKey 是一个自生成数字字段,所以就不用管它了。EmployeeClassificationName 有唯一性约束,这是给很多人造成麻烦的地方。


[TestMethod]public async Task Example1_Create(){  var repo = CreateEmployeeClassificationRepository();  var row = new EmployeeClassification()  {    EmployeeClassificationName = "Test classification",  };  await repo.CreateAsync(row);}
复制代码


这个测试是不可重复运行的,因为在第二次运行它时,相同的名字已经存在了。为了解决这个问题,我们加了一个区分方式,比如时间戳或 GUID。


[TestMethod]public async Task Example2_Create(){  var repo = CreateEmployeeClassificationRepository();  var row = new EmployeeClassification()  {    EmployeeClassificationName = "Test " + DateTime.Now.Ticks,  };  await repo.CreateAsync(row);}
复制代码


这个测试并没有真正测试什么东西。我们知道,CreateAsyn 没有抛出异常,但它可能是一个空方法。为了让测试完整,我们需要加入读操作。

创建和读取记录

在创建和读取测试中,我们先确保可以从数据库读取到非 0 的键。然后,我们用这个键读取记录,并验证从数据库读取的记录字段与原先的一样。


[TestMethod]public async Task Example3_Create_And_Read(){  var repo = CreateEmployeeClassificationRepository();  var row = new EmployeeClassification()  {    EmployeeClassificationName = "Test " + DateTime.Now.Ticks,  };  var key = await repo.CreateAsync(row);  Assert.IsTrue(key != 0);
var echo = await repo.GetByKeyAsync(key); Assert.AreEqual(key, echo.EmployeeClassificationKey); Assert.AreEqual(row.EmployeeClassificationName, echo.EmployeeClassificationName); Assert.AreEqual(row.IsEmployee, echo.IsEmployee); Assert.AreEqual(row.IsExempt, echo.IsExempt);}
复制代码


注意:当没有读取到记录时 Repository 并不会抛出异常,所以,在属性级别的断言之前加入 Assert.IsNotNull,可以更好地捕获测试失败情况。


断言太多会导致一些问题。首先,如果一个断言失败了,你不知道是哪一个。IsEmployee 和 IsExempt 都是 Boolean 类型,所以你都没有办法通过上下文信息来判断是哪个失败了。你可以通过加入更多信息来解决这个问题,如果测试框架支持的话。


其次,难以诊断。如果多个断言失败了,只有第一个被捕获到,后续的信息丢失了。为了解决这个问题,我们使用了 AssertionScope 对象。所有与之相关的断言会被集中在一起,在 using 代码块最后统一报出来。AssertionScope 的实现示例可以在GitHub上找到。对于更为复杂的创建,可以考虑使用流式AssertionScope或者 NUnit 的Assert.Multiple


[TestMethod]public async Task Example4_Create_And_Read(){  var repo = CreateEmployeeClassificationRepository();  var row = new EmployeeClassification()  {    EmployeeClassificationName = "Test " + DateTime.Now.Ticks,  };  var key = await repo.CreateAsync(row);  Assert.IsTrue(key != 0, "New key wasn't created or returned");
var echo = await repo.GetByKeyAsync(key);
using (var scope = new AssertionScope(stepName)) { scope.AreEqual(expected.EmployeeClassificationKey, actual.EmployeeClassificationKey, "EmployeeClassificationKey"); scope.AreEqual(expected.EmployeeClassificationName, actual.EmployeeClassificationName, "EmployeeClassificationName"); scope.AreEqual(expected.IsEmployee, actual.IsEmployee, "IsEmployee"); scope.AreEqual(expected.IsExempt, actual.IsExempt, "IsExempt"); } }
复制代码


随着测试用例越来越多,这会变成一项枯燥的重复性工作,所以我们需要一个辅助方法。


row.EmployeeClassificationKey = key;PropertiesAreEqual(row, echo); 
static void PropertiesAreEqual(EmployeeClassification expected, EmployeeClassification actual, string? stepName = null){ Assert.IsNotNull(actual, $"Actual value for step {stepName} is null."); Assert.IsNotNull(expected, $"Expected value for step {stepName} is null.");
using (var scope = new AssertionScope(stepName)) { scope.AreEqual(expected.EmployeeClassificationKey, actual.EmployeeClassificationKey, "EmployeeClassificationKey"); scope.AreEqual(expected.EmployeeClassificationName, actual.EmployeeClassificationName, "EmployeeClassificationName"); scope.AreEqual(expected.IsEmployee, actual.IsEmployee, "IsEmployee"); scope.AreEqual(expected.IsExempt, actual.IsExempt, "IsExempt"); }}
复制代码


你也可以不用手动写这个方法,直接使用CompareNETObjects库。

创建、更新和读取记录

接下来的测试我们要更新记录,涉及一个创建操作和两次读取操作。


[TestMethod]public async Task Example5_Create_And_Update(){  var repo = CreateEmployeeClassificationRepository();  var version1 = new EmployeeClassification()  {    EmployeeClassificationName = "Test " + DateTime.Now.Ticks,  };  var key = await repo.CreateAsync(version1);  Assert.IsTrue(key != 0, "New key wasn't created or returned");  version1.EmployeeClassificationKey = key;
var version2 = await repo.GetByKeyAsync(key); PropertiesAreEqual(version1, version2, "After created");
version2.EmployeeClassificationName = "Modified " + DateTime.Now.Ticks; await repo.UpdateAsync(version2);
var version3 = await repo.GetByKeyAsync(key); PropertiesAreEqual(version2, version3, "After update");}
复制代码


为了能够知道为什么比较操作会失败,我们给 PropertiesAreEqual 方法加了一个 stepName 参数。

创建和删除记录

到目前为止,我们已经涵盖了 CRUD 的 C、R 和 U,就差 D 了。在删除测试中,我们仍然会读取数据两次。但是,我们会使用 Repository 另一个方法,当找不动记录时返回 null。如果你的 Repository 没有这个方法,请参考第 7 个示例。


[TestMethod]public async Task Example6_Create_And_Delete(){  var repo = CreateEmployeeClassificationRepository();  var version1 = new EmployeeClassification()  {    EmployeeClassificationName = "Test " + DateTime.Now.Ticks,  };  var key = await repo.CreateAsync(version1);  Assert.IsTrue(key != 0, "New key wasn't created or returned");  version1.EmployeeClassificationKey = key;
var version2 = await repo.GetByKeyOrNullAsync(key); Assert.IsNotNull(version2, "Record wasn't created"); PropertiesAreEqual(version1, version2, "After created");
await repo.DeleteByKeyAsync(key);
var version3 = await repo.GetByKeyOrNullAsync(key); Assert.IsNull(version3, "Record wasn't deleted");}[TestMethod]public async Task Example7_Create_And_Delete(){ var repo = CreateEmployeeClassificationRepository(); var version1 = new EmployeeClassification() { EmployeeClassificationName = "Test " + DateTime.Now.Ticks, }; var key = await repo.CreateAsync(version1); Assert.IsTrue(key != 0, "New key wasn't created or returned"); version1.EmployeeClassificationKey = key;
var version2 = await repo.GetByKeyAsync(key); PropertiesAreEqual(version1, version2, "After created");
await repo.DeleteByKeyAsync(key);
try { await repo.GetByKeyAsync(key); Assert.Fail("Expected an exception. Record wasn't deleted"); } catch (MissingDataException) { //Expected }}
复制代码


如果你的数据库使用了软删除,你还需要检查相应的记录是否更新了删除标记。这可以通过以下几行代码来实现。


var version4 = await GetEmployeeClassificationIgnoringDeletedFlag(key);Assert.IsNotNull(version4, "Record was hard deleted");Assert.IsTrue(version4.IsDeleted);
复制代码

创建记录的改进

在第一个测试中,可选数据列总是使用默认值。这个可以通过数据驱动测试来解决。下面的例子针对的是 MSTest,不过其他主流的测试框架也有类似的东西。


[TestMethod][DataTestMethod, EmployeeClassificationSource]public async Task Example9_Create_And_Read(bool isExempt, bool isEmployee){  var repo = CreateEmployeeClassificationRepository();  var row = new EmployeeClassification()  {    EmployeeClassificationName = "Test " + DateTime.Now.Ticks,    IsExempt = isExempt,    IsEmployee = isEmployee  };  var key = await repo.CreateAsync(row);  Assert.IsTrue(key > 0);  Debug.WriteLine("EmployeeClassificationName: " + key);
var echo = await repo.GetByKeyAsync(key); Assert.AreEqual(key, echo.EmployeeClassificationKey); Assert.AreEqual(row.EmployeeClassificationName, echo.EmployeeClassificationName); Assert.AreEqual(row.IsEmployee, echo.IsEmployee); Assert.AreEqual(row.IsExempt, echo.IsExempt);}
public class EmployeeClassificationSourceAttribute : Attribute, ITestDataSource{ public IEnumerable<object[]> GetData(MethodInfo methodInfo) { for (var isExempt = 0; isExempt < 2; isExempt++) for (var isEmployee = 0; isEmployee < 2; isEmployee++) yield return new object[] { isExempt == 1, isEmployee == 1 }; }
public string GetDisplayName(MethodInfo methodInfo, object[] data) { return $"IsExempt = {data[0]}, IsEmployee = {data[1]}"; }}
复制代码


现在,我们可以为单个测试创建多条记录,需要具备查看在数据库创建了哪些记录的能力。在 MSTest 中,我们可以使用 Debug.WriteLine 来记录日志。如果你用的是其他测试框架,可以参考它们的文档,找到相应的方法。

过滤记录

到目前为止只涉及单条记录,但一些 Repository 方法会返回多条记录,这就带来了一些额外的挑战。


在接下来的测试中,我们要查找 IsEmployee = true 和 IsExempt = false 的记录。我们需要事先在数据库中准备好匹配的记录和不匹配的记录。


我们需要两种断言。


  1. 断言返回了我们事先插入的匹配的记录。

  2. 断言不返回非匹配的记录。


注意第二种断言。我们不仅仅要检查我们新创建的非匹配记录不会被返回,还要检查其他非匹配的记录,这涉及之前已存在的记录。


[TestMethod]public async Task Example10_Filtered_Read(){  var repo = CreateEmployeeClassificationRepository();
var matchingSource = new List<EmployeeClassification>(); for (var i = 0; i < 10; i++) { var row = new EmployeeClassification() { EmployeeClassificationName = "Test " + DateTime.Now.Ticks + "_A" + i, IsEmployee = true, IsExempt = false }; matchingSource.Add(row); }
var nonMatchingSource = new List<EmployeeClassification>(); for (var i = 0; i < 10; i++) { var row = new EmployeeClassification() { EmployeeClassificationName = "Test " + DateTime.Now.Ticks + "_B" + i, IsEmployee = false, IsExempt = false }; nonMatchingSource.Add(row); } for (var i = 0; i < 10; i++) { var row = new EmployeeClassification() { EmployeeClassificationName = "Test " + DateTime.Now.Ticks + "_C" + i, IsEmployee = true, IsExempt = true }; nonMatchingSource.Add(row); } await repo.CreateBatchAsync(matchingSource); await repo.CreateBatchAsync(nonMatchingSource);
var results = await repo.FindWithFilterAsync(isEmployee: true, isExempt: false);
foreach (var expected in matchingSource) Assert.IsTrue(results.Any(x => x.EmployeeClassificationName == expected.EmployeeClassificationName));
var nonMatchingRecords = results.Where(x => x.IsEmployee == false || x.IsExempt == true).ToList(); Assert.IsTrue(nonMatchingRecords.Count == 0, $"Found unexpected row(s) with the following keys " + string.Join(", ", nonMatchingRecords.Take(10).Select(x => x.EmployeeClassificationKey)));}
复制代码


我们不检查记录条数。除非你在测试中根据唯一值来过滤数据,否则,如果有其他测试也在使用同一个数据库,就会有问题。这些问题通常出现在分片数据库中,或者在并行执行测试用例时。


随着时间推移,你会发现,返回的数据记录条数会持续增加。当记录条数的增加导致测试变慢,你需要考虑以下这些操作。


  1. 重置数据库。

  2. 改进索引。

  3. 移除 Repository 的这些方法。


重置数据库是最快的操作,但我通常很少会建议这么做。尽管测试数据库中会有很多记录,但比起生产数据库,仍然少很多个数量级。这意味着重置数据库只会将性能问题隐藏掉。


改进索引有它自己的难点,因为每一个索引都会降低写入性能。不过,如果你可以忍受,改进索引会给用户带来更好的体验。


最后一个选项也需要考虑在内,特别是当这些方法返回很多数据。GetAll 方法只返回几十条记录是没有问题的,但如果返回 1 万条记录,你就不应该考虑在生产环境中使用它,你应该将其移除。

关于清理

很多人建议在测试结尾把创建的记录删掉,甚至有人会把整个测试放在一个事务中,确保新创建的记录会被删掉。


一般来说,我并不鼓励这种做法。测试数据库通常不会有太多数据,回滚事务只会错失累积数据的机会。


另外,清理操作有时候也会失败,特别是当你手动删除记录而不是回滚事务时。这种脆弱的测试是我们要避免的。


说到事务,有人建议整个测试从头到尾只使用一个事务。这可能是一种严重的反模式,它会影响你并行执行测试,因为它可能会阻塞数据库(还可能出现死锁)。况且,有些数据库(比如 SQL Server)的回滚非常慢。


话虽如此,在测试中加入清理步骤并没有错,只是你要小心,不要让测试时间变得太长或增加失败情况。

结论

持久化层的测试与类和方法的测试不一样。这些技术不难掌握,与其他技术一样,要掌握它们都需要练习。先从简单的 CRUD 场景开始,再过渡到复杂的场景,比如并行测试、随机采样、性能测试和全数据集扫描。

作者简介

Jonathan Allen 在 90 年代后期开始为一家健康诊所开发 MIS 项目,并逐步将它们从 Access 和 Excel 变成企业级解决方案。在花了五年时间为金融行业开发自动化交易系统之后,他成为了多个项目的顾问,包括机器人仓库的 UI、癌症研究软件的中间层,以及一家大型房地产保险公司的大数据需求。在业余时间,他喜欢学习 16 世纪的武术,并撰写相关的文章。


原文链接


The Fundamentals of Testing with Persistence Layers

2021-10-22 09:325703

评论

发布
暂无评论
发现更多内容

带你梳理Jetty自定义ProxyServlet实现反向代理服务

华为云开发者联盟

容器 k8s jetty Servlet引擎 ProxyServlet

带头撸抽奖系统,DDD + RPC 开发分布式架构!

小傅哥

DDD 小傅哥 架构设计 springboot 抽奖系统

使用mock.js给前端生成需要的数据

与风逐梦

大前端 后端 开发工具

云小课 | 详解华为云独享型负载均衡如何计费

华为云开发者联盟

负载均衡 华为云 弹性负载均衡 独享型ELB实例 独享型负载均衡

打造数字人民币的大运应用场景

CECBC

最小二乘法,了解一下?

华为云开发者联盟

数据 数据处理 计算 最小二乘法 数学工具

Go- 函数参数和返回值

HelloBug

函数 参数 返回值 Go 语言

多样数字人民币钱包来袭,阻力与动力并存

CECBC

以区块链为基础 通证经济是下一代互联网的数字经济

CECBC

后Kubernetes时代的虚拟机管理技术之kubevirt篇

谐云

虚拟机 #Kubernetes#

一分钟学会使用ApiPost中的全局参数和目录参数

CodeNongXiaoW

大前端 测试 后端 接口工具

来了!《中国移动2021智能硬件质量报告》正式发布

没有7年经验你真学不会这份SpringCloud实战演练文档

公众号_愿天堂没有BUG

Java 编程 程序员 架构 面试

Golang:再谈生产者消费者模型

Regan Yue

协程 Go 语言 8月日更

NameServer 核心原理解析

leonsh

RocketMQ 消息队列 NameServer

区块链+物联网设备,能产生什么反应?

CECBC

从lowcode看下一代前端应用框架

百度Geek说

大前端 lowcode

protocol buffer的高效编码方式

程序那些事

Java protobuf 程序那些事

国产接口调试工具ApiPost中的内置变量

Proud lion

大前端 测试 后端 Postman 开发工具

【虚拟机专栏】智能合约执行引擎的前世今生

趣链科技

KubeCube开源:魔方六面,降阶Kubernetes落地应用

网易数帆

开源 Kubernetes 容器 KubeCube

6种常用Bean拷贝工具一览

码农参上

8月日更 对象拷贝

零基础入门:基于开源WebRTC,从0到1实现实时音视频聊天功能

JackJiang

音视频 WebRTC 即时通讯 IM

模块一作业

小智

架构实战营

Java NIO在接口自动化中应用

FunTester

Java nio 接口测试 测试开发

🏆「作者推荐」Java技术专题-JDK/JVM的新储君—GraalVM和Quarkus

洛神灬殇

Java JVM GraalVM 8月日更

web技术分析| 一篇前端图像处理秘籍

anyRTC开发者

大前端 音视频 WebRTC web技术分享

在?进来看看新一季周边到底做点啥?【话题讨论】

气气

话题讨论

GraphQL设计思想

Ryan Zheng

graphql

传统到敏捷的转型中,谁更适合做Scrum Master?

华为云开发者联盟

Scrum 敏捷 团队 项目经理 Scrum Master

Android模块化开发实践

vivo互联网技术

android 架构 开发 项目实战 模块

数据库测试的基础要素_数据库_Jonathan Allen_InfoQ精选文章