谈一下我们是怎么做数据库单元测试(Database Unit Test)的

作者水平有限,如有错误或纰漏,请指出,谢谢。

背景介绍

最近在团队在做release之前的regression,把各个feature分支merge回master之后发现DB的单元测试出现了20多个失败的test cases。之前没怎么做过DB的单元测试,正好借这个机会熟悉一下写DB单元测试的流程。

这篇博文中首先介绍一下在我们的特定项目场景中是如何搭建DB 单元测试框架的,然后举一个简单的例子,从头到尾在visual studio中创建一个简单的单元测试工程。

我们开发的产品使用的数据库为Sql Server,总共有400多张表,2000多个存储过程,每个存储过程都相当于应用代码中的一个功能函数。代码中的每个复杂的功能函数都可以通过写单元测试来在一定程度上保证代码质量,存储过程也如此。代码中的UT难点在于解耦,也就把相互牵连在一起的代码彼此分离开来,各个击破,例如A函数需要B函数提供的数据,测试A函数的时候我们只想测试A函数,不想调用B,这时候就需要我们自己提供B函数生成的数据。这叫做mock。

在做DB单元测试的时候,存储过程所使用的数据比较特殊,都是持久化在数据库表中的,2000多个存储过程增删改查400多个表,我们需要把这些表的数据为每个存储过程做隔离,如果测试用例使用的数据相互之间关联,恐怕会天下大乱,因为在一般情况下,单元测试用例的运行顺序都是随机的,如果单元测试使用的数据有关联,很有可能两次运行结果也是随机的(但是有一种方法可以固定case执行顺序,我在最后的例子中进行说明),我们这次的20多个失败的cases就有这种原因导致的,两台机器上跑出的结果不一样,有的成功,有的失败。

注:有关单元测试的定义,见另外一篇帖子,单元测试有毒

那么问题就来了,如何才能做数据的隔离呢?说一下我们的方案。

准备数据

我们创建了一个基准的数据库,做出一个备份,叫做base.bak,这个版本比较低,比如是2.8,这里面包含了一些测试的基本数据。然后我们创建了另外一个preparation的工程,用于把base.bak升级到当前release版本,例如,当前release的版本为2.18。这个工程同时也测试了升级的流程。升级成功之后,把这个数据库在本地做一个备份release_2_18.bak。好了,数据都准备好了。

测试需要注意的要点

四个函数

对于微软的这个DB UT测试框架,有四个函数需要搞清楚,因为这可能影响你的测试结果:

[ClassInitialize]
public static void ClassInitialize(TestContext testContext)
{
    ...
}
[ClassCleanup]
public static void ClassCleanup()
{
   ...
}
[TestInitialize()]
public void TestInitialize()
{
   ...
}
[TestCleanup()]
public void TestCleanup()
{            
   ...
}
  • 顾名思义,ClassInitialize() 是在每个类初始化的时候被调用的
  • ClassCleanup() 是在类结束的时候,也就是一个类所有的case跑完的时候被调用的
  • TestInitialize() 是在每个case跑之前被调用的。
  • TestCleanup() 是在每个case调用之后被调用的。

对么?粗体的这句话不对,其余是对的。

测试用例的运行是无序的,包含多个类的情况。

看下面测试用例的之情情况你就明白了:

AssemblyInitialize
TestClass1: ClassInitialize
TestClass1: TestInitialize
TestClass1: MyTestCase1
TestClass1: TestCleanup
TestClass2: ClassInitialize
TestClass2: TestInitialize
TestClass2: MyTestCase2
TestClass2: TestCleanup
TestClass1: ClassCleanup
TestClass2: ClassCleanup
AssemblyCleanup

ClassCleanup() 并不意味着TestClass1ClassCleanup 在这个类的最后一个case跑完之后被立即调用!事实上,它会等待所有case都被运行完之后,同TestClass2ClassCleanup 一块执行。

具体原因看这个帖子,How to run ClassCleanup (MSTest) after each class with test?

三个Action

还是看下面的一个例子:

[TestMethod()]
public void Test_GetBasicRevenueByName()
{
    SqlDatabaseTestActions testActions = this.SqlTest1Data;
    // Execute the pre-test script
    // 
    System.Diagnostics.Trace.WriteLineIf((testActions.PretestAction != null), "Executing pre-test script...");
    SqlExecutionResult[] pretestResults = TestService.Execute(this.PrivilegedContext, this.PrivilegedContext, testActions.PretestAction);
    // Execute the test script
    // 
    System.Diagnostics.Trace.WriteLineIf((testActions.TestAction != null), "Executing test script...");
    SqlExecutionResult[] testResults = TestService.Execute(this.ExecutionContext, this.PrivilegedContext, testActions.TestAction);
    // Execute the post-test script
    // 
    System.Diagnostics.Trace.WriteLineIf((testActions.PosttestAction != null), "Executing post-test script...");
    SqlExecutionResult[] posttestResults = TestService.Execute(this.PrivilegedContext, this.PrivilegedContext, testActions.PosttestAction);
}

每个测试用例中都会有三个action,这三个Action的用途如下:

  • PretestAction做的是测试前的准备工作,具体过程中可以为每个特定的case插入或更新测试需要的数据。
  • TestAction为调用存储过程进行测试,将实际结果和预期结果进行对比。
  • PosttestAction做的是测试完成后的清理工作,这里可以对PretestAction中的插入或者更新的数据进行回滚,恢复初始环境

最后的这个PosttestAction为我们的数据隔离提供了一种方法,所谓恢复初始环境的意思是执行一个case之前和之后数据库中的数据完全一样。

这里有个问题,在PretestAction中进行数据插入还比较好恢复,如果是删除和更新呢?这就需要你记录下删除的和更新前的数据。太麻烦了。如果你的系统性能足够好,或者对运行UT的时间没有要求,可以用另外一种方法:restore DB。前面不是说过了么,我们在数据库升级之后做了一个备份,我们在这里使用它。在什么地方执行restoreDB?对,在TestCleanup() 中进行。

[TestInitialize()]
public void TestCleanup()
{
   restoreDB();
}

总结

具体的流程就说完了,总结一下:

准备数据库

运行测试用例流程

数据清理的两种方法

  • 在PretestAction中添加数据恢复语句;
  • TestCleanup()中restore DB。

实例

接下来我们从头到尾演示一下用VS2013 + SQL Server 2012是如何做数据库UT的。

创建一个简单的数据库DBUTDemo

  • 创建两张表。
create table EmployeeBasicInfo(
   EmployeeNo int NOT NULL primary key,
   Name nvarchar(50) NOT NULL,
   TelephoneNum varchar(50) NOT NULL  
);

create table EmployeeRevenue(
   EmployeeNo int NOT NULL primary key,
   BasicRevenue int NOT NULL,
   MealSubsidy int NULL,
   Bonus int NULL,
   foreign key(EmployeeNo) references EmployeeBasicInfo(EmployeeNo)  
);
  • 创建一个存储过程
create procedure GetBasicRevenueByName(@name nvarchar(50))  
as
begin
    select bi.Name,r.BasicRevenue from EmployeeRevenue r join EmployeeBasicInfo bi on r.EmployeeNo = bi.EmployeeNo where bi.Name = @name
end

创建UT工程

  • 点击File->New->Project...

  • 选择Unit Test Project,输入工程名,选择创建路径,点击OK

添加一个类

  • 右键DBUTDemo->Add->New Item...

    选择SQL Server Unit Test,输入名字,点击Add。
  • 第一次添加数据库测试类需要配置数据库:
    点击New Connection

输入Server name,选择我们刚才创建的数据库DBUTDemo,点击Test Connection。如果成功会弹出对话框。连续两次点击OK。数据库配置就完成了。

创建三个Actions

点击Click here to create来创建TestAction,点击之后发现多了一个resx文件。

输入下面的测试代码:

declare @return_value  int,
        @name  nvarchar(50)

EXEC    @return_value = [dbo].[GetBasicRevenueByName]
        @name = N'three zhang'

SELECT  'Return Value' = @return_value

接下来创建另外两个Action:

分别输入如下代码:

insert into EmployeeBasicInfo