众所周知,在测试行业,模拟数据库和其他持久化层会降低测试效率。在测试时,如果一个组件不属于测试的一部分,就很难测试它与其他组件之间的交互行为。遗憾的是,这个行业只专注于功能层面的测试,很少有人接受过其他类型测试的培训。这篇文章通过引入数据库测试的概念来纠正这个问题。这些技术也适用于其他类型的持久化机制,比如调用微服务。
为了了解如何测试数据库,我们先“忘记”与单元测试和集成测试相关的一些概念。直接一点说,现如今对这些术语的定义已经偏离了它们最初的含义。所以,在文章的剩余部分,我们将不再使用它们。
测试最本质的目的是生成信息。一个测试用例在执行完之后应该生成与被测试的东西相关的信息,这些信息是你原先不知道的。生成的信息越多越好。因此,我们倾向于“一个测试用例应该尽可能提供可以证明某个事实所需的断言”,而不是“一个测试用例只提供一个断言”。
另一个有问题的观点是“所有的测试都应该是独立的”。人们通常会误读这个观点,认为每一个测试都应该使用 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 的记录。我们需要事先在数据库中准备好匹配的记录和不匹配的记录。
我们需要两种断言。
断言返回了我们事先插入的匹配的记录。
断言不返回非匹配的记录。
注意第二种断言。我们不仅仅要检查我们新创建的非匹配记录不会被返回,还要检查其他非匹配的记录,这涉及之前已存在的记录。
[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)));
}
复制代码
我们不检查记录条数。除非你在测试中根据唯一值来过滤数据,否则,如果有其他测试也在使用同一个数据库,就会有问题。这些问题通常出现在分片数据库中,或者在并行执行测试用例时。
随着时间推移,你会发现,返回的数据记录条数会持续增加。当记录条数的增加导致测试变慢,你需要考虑以下这些操作。
重置数据库。
改进索引。
移除 Repository 的这些方法。
重置数据库是最快的操作,但我通常很少会建议这么做。尽管测试数据库中会有很多记录,但比起生产数据库,仍然少很多个数量级。这意味着重置数据库只会将性能问题隐藏掉。
改进索引有它自己的难点,因为每一个索引都会降低写入性能。不过,如果你可以忍受,改进索引会给用户带来更好的体验。
最后一个选项也需要考虑在内,特别是当这些方法返回很多数据。GetAll 方法只返回几十条记录是没有问题的,但如果返回 1 万条记录,你就不应该考虑在生产环境中使用它,你应该将其移除。
关于清理
很多人建议在测试结尾把创建的记录删掉,甚至有人会把整个测试放在一个事务中,确保新创建的记录会被删掉。
一般来说,我并不鼓励这种做法。测试数据库通常不会有太多数据,回滚事务只会错失累积数据的机会。
另外,清理操作有时候也会失败,特别是当你手动删除记录而不是回滚事务时。这种脆弱的测试是我们要避免的。
说到事务,有人建议整个测试从头到尾只使用一个事务。这可能是一种严重的反模式,它会影响你并行执行测试,因为它可能会阻塞数据库(还可能出现死锁)。况且,有些数据库(比如 SQL Server)的回滚非常慢。
话虽如此,在测试中加入清理步骤并没有错,只是你要小心,不要让测试时间变得太长或增加失败情况。
结论
持久化层的测试与类和方法的测试不一样。这些技术不难掌握,与其他技术一样,要掌握它们都需要练习。先从简单的 CRUD 场景开始,再过渡到复杂的场景,比如并行测试、随机采样、性能测试和全数据集扫描。
作者简介
Jonathan Allen 在 90 年代后期开始为一家健康诊所开发 MIS 项目,并逐步将它们从 Access 和 Excel 变成企业级解决方案。在花了五年时间为金融行业开发自动化交易系统之后,他成为了多个项目的顾问,包括机器人仓库的 UI、癌症研究软件的中间层,以及一家大型房地产保险公司的大数据需求。在业余时间,他喜欢学习 16 世纪的武术,并撰写相关的文章。
原文链接:
The Fundamentals of Testing with Persistence Layers
评论