使用 Gherkin 语言进行集成测试





5.00/5 (1投票)
集成测试基础设施的设计。
引言
我想分享我们在商业项目中实施集成测试的经验。集成测试并非万能药;它有利有弊。在项目初期,许多细节并不明显。
我希望这篇文章能帮助那些即将将集成测试投入到他们项目中的开发人员,但又不了解他们可能面临的挑战。
解决方案基础设施
作为项目基础设施的示例,我将使用一个通用的例子来涵盖大多数解决方案。
解决方案架构
当涉及到测试时,我们需要检查来自用户界面和外部系统的调用是否按预期被我们的系统处理。对于用户界面来说,手动测试很容易。然而,如果我们的系统行为依赖于与外部系统的调用,那么测试过程就会变得相当繁琐。这种情况下的主要问题是解决方案更改后的重新测试。这种重新测试会占用大部分的测试预算。
在这种情况下,我们必须考虑使用自动化测试。我们可以使用单元测试和集成测试方法。
当开发一个复杂的处理算法时,应该使用单元测试,该算法实现在一个简单的模块中,具有简单的输入和输出,这些在测试中很容易截获。
这是单元测试的测试基础设施的示意图
蓝色矩形表示解决方案中的代码。黄色矩形表示为测试目的而创建的代码。
这里,上面的黄色矩形是测试的程序代码,例如,单元测试的代码。通常它包含以下部分:
- 容器构建器,用于构建被测代码的环境。通常,未被测试的组件会被存根(stubs)替代。
- 存根的测试数据。例如,使用各种模拟框架来指定存根的行为,以模拟期望的场景。
- 被测代码的测试数据。
- 预期结果——用于与实际结果进行比较的数据。实际结果不仅仅是被测代码的直接响应,它还可能是代码发送给存根的一些数据。
- 测试代码,用于指定测试的行为。我们可以在测试中调用各种方法。有时这些调用的顺序并不简单。
下面我将展示Gherkin语言如何帮助您实现这个黄色矩形。
第二个黄色矩形是未被测试的组件的存根。尽管如此,您仍然需要花费时间来创建这些存根。更重要的是,新的测试可能需要新的存根。所以,不要忘记在您的项目中考虑这个“支出项”。
我想指出的是,单元测试并不能确保不同的单元正确地实现了相同的接口。粗略的例子:第一个模块可能向第二个模块发送一个null值,而第二个模块并不将null值视为正确的输入。
问题在于,其中一位开发人员错误地实现了接口要求。单元测试也错误地实现了。因此,即使您的项目被100%的单元测试覆盖,也不能保证您的系统正常工作。
我认为单元测试是开发人员实现算法的辅助机制。我坚信单元测试不应被用来确保整个系统正常工作。
我们需要集成测试来确保系统满足所有需求。
集成测试的测试基础设施
蓝色矩形表示解决方案中的代码。黄色矩形表示为测试目的而创建的代码。下面我将详细解释这个基础设施。但是,在我们继续之前……
做好准备
当您开始开发系统时,应该牢记您的解决方案将成为集成测试基础设施的一部分。换句话说,在项目开始时,您必须决定您的系统中的某些组件将被存根取代。请记住,被替换为存根的组件的代码将不会被自动测试。您应该将该代码设计得尽可能简单。如果它只是没有条件和循环运算符的映射,那就完美了。在这种情况下,简单的手动测试足以检查这种映射是否正常工作。如果这样的代码没有条件运算符,那么一个测试用例将检查整个代码。
我给您举个例子。
一个系统需要发送电子邮件。下面是组件图的一部分。
在生产环境中,系统逻辑使用标准的SmtpClient(https://msdn.microsoft.com/en-us/library/system.net.mail.smtpclient(v=vs.110).aspx)类向用户发送电子邮件。这里的SMTP服务器是一个外部系统。
为了使这种配置可自动测试,应该怎么做?
第一种方法是使用一个存根来替换SMTP服务器。例如,Artezio Fake SMTP服务器(https://fakesmtp.codeplex.com/)。说实话,它不能用于自动化测试,因为它没有API。但是,它提供了很好的用户界面用于手动测试。
这个存根应该支持SMTP协议,并提供一个管理API来在测试前配置内部状态,并在测试后进行检查。别忘了这个存根必须以某种方式托管在测试基础设施中。
第二种方法是使用一个存根组件来替换SmtpClient组件。
这样,您就不用考虑存根的托管问题了,因为它是一个存根类,在测试配置中替换了生产类。您也不需要实现SMTP协议的支持。
然而,这种方法的缺点是SmtpClient没有被自动测试。
因此,在系统开发初期,您必须尽可能简化此组件的设计。正如我之前所说,简单的组件可以通过手动测试,不需要自动化测试。
辅助逻辑的存根
别忘了授权。您应该使用一个能够模拟测试站上任何用户权限的解决方案。我推荐使用OAuth标准。
并请记住,您的用户界面将由一个机器人使用。UI必须有一些钩子,使其易于机器人使用。例如,在Web应用程序中使用HTML元素的ID。这些ID可以帮助机器人找到网页上的必要元素。
测试基础设施的细节
夹具和测试代码
夹具和代码是测试基础设施的关键节点。它们必须
- 存储将在存根中使用以及作为主算法参数的测试数据。
- 指定测试的步骤顺序。请注意,每个步骤可能包含测试数据(就像函数有其参数一样)。
- 包含测试运行的预期数据。请注意,可能需要检查的不仅仅是算法的直接结果。有时我们必须检查存根或数据存储中的结果数据。因此,测试必须通过管理API访问数据存储或存根。
- 能够清除测试过程中所做的更改。
Gherkin语言
这就是Gherkin语言大放异彩的地方。它实现了上面列表的前3点。我认为它最大的优势在于其可视化特性。看看下面两个测试的文本。它们实现了相同的检查。
使用SQL和C#实现
SQL:测试前准备Devices表
MERGE INTO [Devices] AS Target
USING (VALUES
(21 ,'76824e68','23sdf123f' ,'1111' ,'TRX9' )
) AS Source
ON (Target.[Id] = Source.[Id])
WHEN MATCHED THEN
UPDATE SET
[StrId] = Source.[StrId],
[SerialNumber] = Source.[SerialNumber],
[IMEI1] = Source.[IMEI1],
[ModelCode] = Source.[ModelCode],
WHEN NOT MATCHED BY TARGET THEN
INSERT([Id],[StrId],[SerialNumber],[ModelCode])
VALUES(Source.[Id],Source.[StrId],Source.[SerialNumber],Source.[IMEI1],Source.[ModelCode])
WHEN NOT MATCHED BY SOURCE THEN
DELETE
;
C#:测试的第二部分——更新设备并执行测试。
[Test]
public async System.Threading.Tasks.Task CalculateTasksForDevice()
{
EnvironmentStub.UtcNowStub = new DateTime(2017, 06, 08, 0, 0, 0, DateTimeKind.Utc);
//device with IntId = 21 tries to update its record
var delta = new Delta<DeviceDto>();
delta.TrySetPropertyValue("ModelCode", "beda13");
delta.TrySetPropertyValue("CarrierName", "Carrier1");
delta.TrySetPropertyValue("IMEI1", "1234567890");
delta.TrySetPropertyValue("Manufacturer", "United China");
delta.TrySetPropertyValue("SimOperator", "Carrier1");
delta.TrySetPropertyValue("Platform", (byte?)1);
delta.TrySetPropertyValue("APILevel", (byte?)7);
delta.TrySetPropertyValue("FirmwareVersion", "tz9_1.1.0.7");
var device = await deviceController.PatchDevice("76824e68", delta, 21);
var tfdController = new TasksForDeviceController(new TelemetryClient());
tfdController.InitializeDomainManagerForTest();
var tfdList = tfdController.GetAllTasksForDevices(21).ToList();
Assert.AreEqual(2, tfdList.Count(), "Exactlty two records are expected. Deleted records must be returned as well");
var expectedIds = new List<int> { 38, 39 };
CollectionAssert.AreEqual(expectedIds, tfdList.Select(a => a.TaskId).ToList(), "Expected task wasn't found");
}
这种实现难以阅读。只有代码的作者才能轻松理解它。例如,没有注释,就无法理解“deviceController.PatchDevice
”方法是数据准备,而“tfdController.GetAllTasksForDevices(21)
”方法是被测代码。
使用Gherkin实现
@DBSetup @DeviceContainer @MobServiceBDD.ComplexTaskGeneration
Scenario: Generation of tasks for device
Given following records were added into the "Devices" table
| Id | StrId | SerialNumber | ModelCode | APILevel | Platform | TestDevice |
| 1000 | 6ee0ab01 | 00001234567890 | VSTD | 6 | 1 | 0 |
And following records were added into the "Tasks" table
| Id | Deleted | ActionType | StartDate | FinishDate | ExecType |
| 1000 | 0 | 1 | 2017-01-01 | 2027-01-01 | 2 |
| 1001 | 1 | 1 | 2017-01-01 | 2027-01-01 | 2 |
When device updates its properties at "2017-06-09 23:45:34"
| Property | Value |
| IntId | 1000 |
| Id | 6ee0ab01 |
| ModelCode | beda13 |
| CarrierName | Carrier1 |
| IMEI1 | 1234567890 |
| Manufacturer | United China |
| SimOperator | Carrier2 |
| Platform | 1 |
| APILevel | 7 |
| FirmwareVersion | tz9_1.1.0.7 |
Then The device gets the following list of the tasks
| TaskId |
| 1000 |
如您所见,Gherkin的实现更具可视化。它包含了测试的步骤以及测试数据。很容易区分测试准备步骤和测试步骤。
当然, there is a C# code that interprets the strings of Gerkin tests. That C# code is hard to read. But that code is hidden and don’t hamper to understand the test.当然,There is a C# code that interprets the strings of Gerkin tests. That C# code is hard to read. But that code is hidden and don’t hamper to understand the test.有解释Gherkin测试字符串的C#代码。这段C#代码很难读。但这段代码是隐藏的,并不妨碍理解测试。
更重要的是,Gherkin测试的字符串在各种测试中使用不同的参数——测试数据。例如,在我们的项目中,一位开发人员创建了一个Gherkin测试并填写了示例测试数据。开发人员还实现了每个Gherkin字符串的C#代码。然后,一位测试人员复制/粘贴了这个测试,并设计了各种测试数据。测试人员使他们所有的测试场景都能自动运行。这样,我们就能保证我们的系统在每次构建后都能正常工作。
让我总结一下用Gherkin编写的测试的优点
- 它们是可视化的。很容易阅读操作摘要及其参数。
- 它们包含步骤以及用例中的测试数据。因此,很容易创建一个Gherkin测试,从分析中形成一个用例。
- 它们不仅可以由开发人员编写,还可以由测试人员和分析师编写。
Gherkin语言的解释
让我们深入了解Gherkin测试的内部工作原理。技术上讲,这类测试执行了所有单元测试所做的工作——构建容器,用测试数据指定存根,按必要顺序调用主方法,比较预期结果和实际结果。
我将描述我使用SpecFlow的经验。您可以在此处找到更多关于SpecFlow的信息:http://specflow.org/getting-started/。它在Visual Studio中有实现。
Ghirkin字符串和测试上下文
SpecFlow的主要方法是将每个Gherkin字符串实现为一个C#方法。例如,下面的Gherkin字符串
And following records were added into the "Tasks" table
| Id | Deleted | ActionType | StartDate | FinishDate | ExecType |
| 1000 | 0 | 1 | 2017-01-01 | 2027-01-01 | 2 |
| 1001 | 1 | 1 | 2017-01-01 | 2027-01-01 | 2 |
被实现为以下C#实现:
[Binding]
public class DatabaseSteps
{
public DeviceSteps(ScenarioContext scenarioContext) : base(scenarioContext)
{
}
protected int SomeValue
{
get { return (int)scenarioContext["DatabaseSteps_SomeValue"]; }
private set { scenarioContext["DatabaseSteps_SomeValue"] = value; }
}
[Given(@"following records were added into the ""(.*)"" table")]
public void GivenFollowingRecordsWereAddedIntoTheTable(string tableName, Table table)
{
TableSaver.ProcessQuery(tableName, table);
}
}
稍后我们将讨论TableSaver.ProcessQuery()
方法。
我想强调的关键是,Gherkin字符串是C#类中的一个方法。而有一个特点——来自一个Gherkin场景(测试)的字符串可能实现为不同C#类中的方法。因此,我们面临着上下文问题。不同类中的方法必须能够访问当前场景的上下文。
SpecFlow使用一个简单的解决方案——场景上下文被用作构造函数参数。这里是存储必要数据的地方。您在上面的代码示例中可以看到一个简单的实现。SomeValue被存储在上下文中,并且可以被该类及其子类的方法访问。
因此,我们有一个存储通用数据的地方,例如依赖注入容器。
我们也在以下情况下使用上下文。被测算法以表作为参数并返回表结果。看起来我们需要为Gherkin字符串指定两个表。不幸的是,Gherkin语法一次只允许指定一个表。变通的方法是在第一个字符串中指定参数表,并将结果保存在上下文中。第二个字符串指定预期结果表,它只是从上下文中获取已保存的结果并与预期数据进行比较。
C#方法实现的技巧
首先,类层次结构。SpecFlow对包含Gherkin字符串方法的类没有重要的限制。唯一的要求是类要有Binding属性。据我所知,SpecFlow测试运行器只是创建了所有带有Binding属性的类的实例,并使用它们的方法来处理Gherkin字符串。
然而,我建议使用以下方法。
- 创建类层次结构。根类包含可在所有场景实现中使用的属性和方法。例如,根类包含容器属性。这样,任何类都可以方便地在容器中注册和解析必要的类。它也是清理每个场景后环境的方法的好地方。
- 子类包含应该用于特定Gherkin功能的属性和方法。
其次,Gherkin测试的块。例如,每个场景都有Given、When和Then块。使用“BeforeScenarioBlock”之类的属性,您可以注册将在每个块开始时调用的方法。
这是构建依赖注入容器的合适位置。
例如,Given块中的Gherkin字符串只是在容器中注册模拟实例。当涉及到When块时,您首先构建容器,然后主算法从构建的容器中解析模拟实例。
如果我们讨论的是集成测试,那么我们就必须将必要类的实际实现放入容器中。在我的例子中,我将实际的DAL类放入了容器中。这样,被测算法就可以访问真实的数据库,该数据库包含了在测试的Given块中准备好的数据。
这是在第一个When块开始时构建容器的示例
[BeforeScenarioBlock(Order = 0)]
private void BuildContainer()
{
if (ScenarioContext.Current.CurrentScenarioBlock == ScenarioBlock.When)
{
if (InitializationComplete != true)
{
//Lets build container before any string from "When" block is called
Mock.ReplayAll();
Container = Builder.Build();
InitializationComplete = true;
}
}
}
第三,场景标签。在上面的Gherkin测试示例中,您可以看到以下标签:
@DBSetup @DeviceContainer @MobServiceBDD.ComplexTaskGeneration
第一个标签——@DBSetup——指定测试使用数据库,该数据库应在测试前准备好并在测试后清理。我想提一下,我们讨论的是集成测试。所以,我们使用一个使用真实SQL数据库的测试站,并且我们需要一种方法在每次测试后恢复数据库。
我们在根类中有以下方法来实现它:
[BeforeScenario("DBSetup")]
private void DBSetup()
{
DbSetup.DbBackup();
}
[AfterScenario("DBSetup")]
private void DBCleanUp()
{
DbSetup.DbRestore();
}
请参阅下面“数据存储解决方案”主题中的实现细节。
第二个标签——@DeviceContainer——指定测试需要一些与Device实体相关的类注入到容器中,这些类是此场景所必需的。我们在子类中有一个方法:
[BeforeScenario("DeviceContainer")]
private void ContainerSetupForDeviceTests()
{
Builder.RegisterType<DeviceController>();
}
第三个标签——@MobServiceBDD.ComplexTaskGeneration——用于Visual Studio中的测试浏览器。SpecFlow将所有标签添加为测试类别属性。
[TestCategory ("MobServiceBDD.ComplexTaskGeneration")]
这样,我们将所有必要的场景归为一组。
调用主算法并比较结果
在上面的测试示例中,主算法由两个方法组成:更新某个设备并获取该设备的任务。您可以在上面的Gherkin测试中看到两个字符串:“When device updates its properties...”和“Then The device gets the following list of the tasks...”
当实现第一个字符串时,我只是调用了DeviceController中的相应方法。第二个字符串从控制器的另一个方法获取结果。
这样,我实现了WebAPI的集成测试。然而,Web UI也可以被测试。在这种情况下,我们需要从C#方法中访问UI。一个可能的解决方案是使用Selenium项目。这样,Gherkin字符串可以是“用户在登录表单上指定‘User1’和‘Pass1’,然后点击Login按钮”。相应的C#方法调用Selenium驱动程序来访问网页上的控件元素。
我坚信对于桌面应用程序也有这样的方法。
外部系统存根
当您刚开始开发系统时,您应该考虑如何测试与第三方系统的交互。一方面,可以使用第三方系统的第二个实例。然而,这种解决方案会暴露重要的功能:
- 在每次测试之前,很难将系统设置到期望的状态。在每次测试后清除更改也很困难。
- 通常没有管理API来检查第三方系统的内部状态。换句话说,您无法获得对该系统的调用日志,以检查您的系统在交互期间是否进行了正确的调用。
我在上面的“做好准备……”段落中已经描述了外部系统存根的解决方案。
数据存储
您的数据存储也应该满足相同的要求。我将描述我们为MS SQL Server使用的解决方案。我没有为各种noSQL数据库提供配方。
为了测试目的,我们使用了一个专用的MSSQL服务器实例。它可以是测试站的一部分,也可以是开发人员机器上的一个实例。
测试前设置
我们使用了两个步骤来在测试前向数据库填充数据。
首先,数据库项目中的TestData脚本。当然,我们使用了Visual Studio数据库项目来存储数据库的DDL语句。数据库项目有一个非常好的功能,即PostDeployment Scripts。我们把第一批数据放入数据库就是在这里。
我们在DB项目的发布配置文件中创建了一个TestSet参数(关于数据库项目变量的更多信息)。对于每个测试站,我们创建了一个publish.xml文件,将TestSet变量设置为一个测试集的名称(例如,参考Vadim的回答了解变量使用示例)。部署后脚本检查TestSet发布参数的值,并执行包含在测试集中的所有表的合并脚本。因此,测试集是一组SQL脚本。每个脚本用一组固定数据填充一个表。您可以在“使用SQL和C#实现”段落中看到合并脚本的示例。请注意,PreDeployment脚本会清除数据库中所有表的数据。
我们使用merge script generator来创建这些脚本。在开发人员手动填充了必要的表并提供数据后,他或她会将数据提取到合并脚本中,并将其添加到数据库项目中。
这也是填充新站点的良好解决方案,以便包含一些数据。例如,测试人员通常希望获得一个已经包含一些正确指定数据的测试站。使用这种方法,我们可以轻松地将一个测试集部署到站点。
此外,集成测试可以使用这些数据并跳过数据初始化的阶段。尽管如此,如果一个测试需要一些额外的数据,我们有一个简单的解决方案:
Gherkin测试中的表可以用来向表中添加必要的数据。
请参阅上面Gherkin测试中的“以下记录已添加到‘Devices’表中”的字符串示例。我们创建了一个特殊的TableSaver类(请参阅附件中的源代码),该类将SpecFlow Table类中的值插入到SQL表中。该表保存器的主要特点是它不需要在Gherkin表中指定所有表列。表保存器从数据库读取表列列表并为每一列生成值。首先,它使用Gherkin表中的值。如果Gherkin表中没有指定某列,则表保存器尝试使用null值。如果列不允许为null,则表保存器使用默认值,例如,int列为0,varchar列为空字符串。
请注意,表保存器现在有一个问题。它无法将空字符串插入到可为空的varchar列中。Gherkin表中的空值将被转换为null值。
这种方法允许Gherkin测试尽可能地可视化。对于不适用于特定测试的列,没有数据。
测试后清除
在集成测试中,测试后恢复系统是一项必要的任务。
正如我上面提到的,外部系统的存根必须有一个管理API来设置初始数据。显然,该API应该允许在每次测试后恢复存根的内部状态。
我们开发了一个简单的DBSetup类(请参阅附件中的源代码),该类在测试后恢复SQL数据库(请参阅附件中的源代码)。它使用快照备份。在此处查看如何创建数据库快照以及如何从快照恢复数据库。
数据库快照的主要优点是它是最快的数据库备份/恢复机制。例如,在我们的测试站上,平均测试时间为7秒。这个时间包括数据库备份/恢复时间。
数据库快照在MSSQL开发者版和MSSQL 2017 sp3 express版中都可用。这两个版本对开发都是免费的。
目前,我无法为您提供Azure存储或DocumentDB等其他数据存储系统的工具。
集成测试的成本
上面我提到了集成测试需要创建额外的组件。在这里我想把它们放在一个地方。
这张图显示了测试基础设施的组件,用黄色矩形表示。
在开始讨论黄色矩形之前,有必要提到集成测试需要一个专用站点。除了测试站点,还需要在您的持续集成服务器上配置新的作业。这是DevOps在集成测试方面的支出。
上图中的第一个黄色矩形是一个测试项目。它包括:
- MSTest或nUnit测试项目(实际上可以是任何您喜欢的测试框架)。这些框架的测试报告功能非常有用。
- 定义行为、测试数据和预期结果的Gherkin测试。
- 支持Gherkin测试的SpecFlow组件。
- 用于调用被测组件的附加API。如果您的系统只有API方法,您可以直接从测试中调用它们,例如,调用WebAPI的控制器。然而,如果您的系统有UI,那么您必须编写一些API来以编程方式与UI交互。Selenium项目是Web应用程序的一个例子。
- 数据存储的管理API。这是一个通往存储的后门,允许在测试前初始化存储;测试后检查存储中的数据;测试后清除数据。
- 外部系统存根的管理API。例如,如果您将外部系统的存根实现为一个具有REST接口(包括存根的管理API)的独立服务,那么您需要在测试项目中创建一些C#代理类,以通过REST接口与存根的管理API进行交互。
第二个黄色矩形是辅助逻辑,在测试时需要被替换。有必要使用所有现有的角色来测试您的系统。因此,您需要一种方法将标准身份验证系统替换为一个提供必要角色的存根。
关于日志系统,如果每次测试的所有日志都能在测试报告中收集到,那就太好了。
第三个黄色矩形是外部系统存根。外部系统存根的开发必须包含在您的开发流程中。它们还必须在持续集成服务器上构建,并部署到测试站。
结论
集成测试是确保系统质量的强大工具。然而,能力越大,责任越大。您必须像对待主系统一样,精心设计和实现集成测试系统的组件。否则,它将成为一个无用的、吞噬预算的系统。