行为驱动开发(BDD) - 一个快速的描述和示例
BDD表示乙 ehavior ð里文ð才有发展。用于描述行为的语法是Gherkin。
这个想法是尽可能自然地描述一种语言应该发生什么。
如果你熟悉单元测试,并且很容易编写单元测试,那么你熟悉它们的阅读方式。根据测试需要覆盖的程度,可以很难弄清楚它的作用,因为毕竟只是代码。
只有开发人员才能真正了解那里发生了什么。
BDD以不同的方式解决问题。
让我们来隐藏代码,开始一个对话,所以现在任何人都可以阅读一个场景并了解它的测试。
举一个例子:
给定第一个数字4
和第二个数字3
当添加两个数字时
那么结果是7
这里没有代码。这种情况可以像故事一样阅读。我们可以将它交给业务分析师,以确保我们正在处理正确的事情,或者将其提供给测试人员,或者稍后重新审视,并重新记住事情需要如何工作,为什么我们建立一定的事情办法。
我们在这里描述一些行为,在这种情况下,它可能是一个数学运算子系统,我们已经明确定义了该系统的一个行为。当然,更多的测试将被写入以覆盖整个行为并处理边缘情况。
如果这一切都开始听起来像写单元测试,那就是一件好事。
BDD和单元测试在某些方面是相似的,不妨碍开发人员使用这两者,如果这是合适的。
使用Gherkin语法可以很容易地解释什么是以自然语言进行测试,甚至非开发人员可以阅读和理解。
例如,质量保证人员或业务分析师可以复制和粘贴此类测试,更改数字并提供自己的测试用例,而无需编写任何代码,甚至不会看到代码。
如果您对细节感兴趣,这是一个非常好的写在小黄瓜:https://github.com/cucumber/cucumber/wiki/Gherkin
现在我们进行了测试,从这里开始如何工作?
测试中的每一行都称为步骤,每一步都将成为一个单独的方法,每个方法都按照写入的顺序进行调用。
在我们的示例中,前两行(给定和And)将设置初始数据,“ 什么时候”将会调用我们要测试的方法,然后Then将发生assert。
由于每个步骤是一个单独的方法,希望现在很明显,我们需要能够在步骤之间共享一些状态。
不要担心,这不是你想象的状态,它不会破坏任何测试原则,特别是说测试不应该改变状态或者应该依赖于另一个测试创建的状态。
这只是意味着每个测试需要能够拥有自己的状态,并且该测试中的每个步骤需要该状态。
Specflow给了我们一个ScenarioContext,它只是一个字典,用于存储执行测试所需的数据。此上下文在测试结束时被清除,并在下一次测试运行时再次为空。
以每个步骤为单独的方法,这里要考虑的最后一点是可以在多个测试之间重复使用该步骤。看看我们的测试示例中的前两个步骤。如果我们将该数字作为输入参数传递给这个步骤方法,我们可以重用它,无论我们重新使用这些步骤。
测试看起来更像这样:
给定第一个数字{parameter1}
第二个数字{parameter2}
当添加两个数字时
那么结果是{预期结果}
现在这是更通用的,希望能够清楚地显示出每个步骤的可重用性。我们不必在每次测试中使用相同的步骤,甚至不需要按照相同的顺序!稍后我们来看一下这个。
随着我们不断添加测试,我们编写的实际代码变得越来越小,因为对于我们正在测试的每个系统行为,我们将了解到我们只是重新使用已经编码的现有步骤。
所以即使我们花了一点时间最初写测试代码; 随着我们的进步,最终花费在写入额外步骤的时间减少到零。
用于BDD的软件
我们需要看看哪些工具可以帮助我们充分利用BDD的全部功能。本文是从后端的角度来看的,但是还有纯粹的前端工作的替代方法,但是在本文中将不再讨论。
我会用:
- Visual Studio 2017(bit.ly/dnc-vs-download)
- Specflow - Visual Studio扩展 - 这将有助于Gherkin语法和测试与步骤代码之间的链接。
- NUnit - 用于断言。你可以在这里使用别的东西, FluentAssertions也是一样。
有一个NuGet软件包安装了Specflow和NUnit,我会使用一个,因为它使事情变得更容易。
所以,首先安装Visual Studio Specflow扩展。这将给我们提供文件模板和语法着色。
Specflow
Specflow Visual Studio扩展将允许您创建功能文件。这些文件是测试场景的占位符。
该扩展还为功能文件添加了语法着色,这是一个很好的视觉指示器,您所做的工作以及您仍然需要做什么。它在每个测试场景的步骤和它们之后的测试方法之间创建一个连接,这是非常方便的,特别是当你有很多功能文件和大量的测试。
一旦创建了一个特征文件,它将如下所示:
功能文本描述了问题。
该场景基本上是一个测试,我们可以在一个功能文件中有多个场景。
标签在测试资源管理器窗口中使用,它允许我们以合乎逻辑的方式对测试进行分组。我们的初步测试可能如下所示:
请注意如何删除对UI元素的引用。这可以追溯到最初所说的 - 专注于功能,以及在做某些事情的核心部分; 不是如何显示事物和在哪里。
在Visual Studio解决方案中,我们仍然需要使用NuGet软件包SpecFlow.NUnit安装Specflow和NUnit:
我创建了一个MathLib类库并添加了这个NuGet包。
一旦我们安装了所有这些软件包,打开测试资源管理器窗口,构建解决方案,你应该看到以下内容:
我被Traits过滤,然后显示我们创建的标签。我使用了两个,MathLib显示库中的所有测试(Add,Divide等),但是我可以通过Math操作以及Add标签下的组合来看到它们。这只是个人喜好。标签可以是一种非常有效的方法,以对您有意义的方式对测试进行分组。
所以现在我们有一个功能文件,还有一个测试,但是我们还没有写任何测试代码。
我们接下来需要的是一个步骤代码文件,我们所有的测试步骤都可以进行。我们将从一个文件开始,但是我们可以将步骤分成多个步骤文件,以避免在一个文件中存在太多的代码。
如果你再看看我们的测试,你会看到这些步骤是紫色的。这是一个视觉指标,没有代码。
我们创建一个步骤代码文件,它只是一个标准的C#文件。
代码如下所示:
using TechTalk.SpecFlow; namespace MathLibTests { [Binding] public sealed class Steps { } } |
我们唯一添加的是Binding属性在类的顶部。这是一个Specflow属性,它使此文件中的所有步骤都可用于此项目中的任何功能文件,无论它们位于何处。
现在,返回功能文件,右键单击任何步骤,您将在上下文菜单中看到“ 生成步骤定义”选项:
单击生成步骤定义选项,然后将方法复制到剪贴板:
注意四个步骤如何显示在窗口中。将为其中每一个生成代码。
现在只需将代码粘贴到前面创建的步骤文件中:
using TechTalk.SpecFlow; namespace MathLibTests { [Binding] public sealed class Steps { [Given( @"a first number (.*)" )] public void GivenAFirstNumber( int p0) { ScenarioContext.Current.Pending(); } [Given( @"a second number (.*)" )] public void GivenASecondNumber( int p0) { ScenarioContext.Current.Pending(); } [When( @"the two numbers are added" )] public void WhenTheTwoNumbersAreAdded() { ScenarioContext.Current.Pending(); } [Then( @"the result should be (.*)" )] public void ThenTheResultShouldBe( int p0) { ScenarioContext.Current.Pending(); } } } |
保存文件,然后再次查看功能文件。我们最初的情景,其中有紫色的所有步骤,现在看起来像这样:
注意颜色如何变成黑色,数字是斜体的,这意味着它们被视为参数。为了使代码更清晰一些,我们来改一下一下:
using TechTalk.SpecFlow; namespace MathLibTests { [Binding] public sealed class Steps { [Given( @"a first number (.*)" )] public void GivenAFirstNumber( int firstNumber) { ScenarioContext.Current.Pending(); } [Given( @"a second number (.*)" )] public void GivenASecondNumber( int secondNumber) { ScenarioContext.Current.Pending(); } [When( @"the two numbers are added" )] public void WhenTheTwoNumbersAreAdded() { ScenarioContext.Current.Pending(); } [Then( @"the result should be (.*)" )] public void ThenTheResultShouldBe( int expectedResult) { ScenarioContext.Current.Pending(); } } } |
在这一点上,我们有步骤,我们有起点,我们可以添加一些有意义的代码。
我们添加实际的数学库,这是我们实际测试的数学库。
创建一个类库,使用Add()方法添加一个MathLibOps类:
using System; namespace MathLib { public sealed class MathLibOps { public int Add( int firstNumber, int secondNumber) { throw new NotImplementedException(); } } } |
现在让我们写出足够的测试代码来进行测试。
我们再来看一下“步骤”文件。注意所有这些ScenarioContext.Current.Pending()行在每一步?这是我们以前谈论的语境。这就是我们所有需要的数据。将其视为字典,带有键/值对。关键将用于检索正确的数据,所以我们将给出一些有意义的价值观,使我们的生活更轻松。
using MathLib; using NUnit.Framework; using TechTalk.SpecFlow; namespace MathLibTests { [Binding] public sealed class Steps { [Given( @"a first number (.*)" )] public void GivenAFirstNumber( int firstNumber) { ScenarioContext.Current.Add( "FirstNumber" , firstNumber); } [Given( @"a second number (.*)" )] public void GivenASecondNumber( int secondNumber) { ScenarioContext.Current.Add( "SecondNumber" , secondNumber); } [When( @"the two numbers are added" )] public void WhenTheTwoNumbersAreAdded() { var firstNumber = ( int )ScenarioContext.Current[ "FirstNumber" ]; var secondNumber = ( int )ScenarioContext.Current[ "SecondNumber" ]; var mathLibOps = new MathLibOps(); var addResult = mathLibOps.Add(firstNumber, secondNumber); ScenarioContext.Current.Add( "AddResult" , addResult); } [Then( @"the result should be (.*)" )] public void ThenTheResultShouldBe( int expectedResult) { var addResult = ( int )ScenarioContext.Current[ "AddResult" ]; Assert.AreEqual(expectedResult, addResult); } } } |
看看前两个给定方法,注意我们如何将参数传递给方法,然后将其添加到具有清除键的上下文中,以便我们知道它们代表什么。
在当步骤从上下文使用这两个值,实例化Math类和与这两个数调用Add()方法,然后将其结果存储回的上下文。
最后,Then步骤从特征文件获取预期结果,并将其与存储在上下文中的结果进行比较。当然,当我们运行测试时,我们会失败,因为我们没有正确的代码。
要运行测试,请在“测试资源管理器”窗口中右键单击该测试,并使用“运行所选测试”选项:
结果将如下所示:
结果是如预期的,所以现在让我们修复lib代码并使其通过:
namespace MathLib { public sealed class MathLibOps { public int Add( int firstNumber, int secondNumber) { return firstNumber + secondNumber; } } } |
现在让我们再试一次,我们应该看到一些更开朗的东西:
很酷,所以在这一点上,我们应该相当熟悉它们如何挂在一起。
现在最大的问题是:
好的,这是非常好的,但是这与单元测试有什么不同,它实际提供了什么价值?我得到什么
我有一个功能文件,这很好,我想,但我可以很容易地写一个单元测试,并完成它。业务分析师不会关心我的基本添加两个数字的事情。
所以,我们来看看我们如何实现一些更复杂的东西。
Specflow有更多的功能,我们只是碰到了几个。一个非常好的功能是能够处理数据表。当数据不像数字那么简单时,这很重要。例如,假设你有一个具有五个属性的对象,这将使它更难处理,因为我们现在需要五个参数,而不是一个。
所以让我们来一个比较严肃的项目,让我们为一个网站实现一个Access框架,而这个Access Framework会告诉我们一个用户是否可以在我们的网站上执行各种操作。
一个复杂的问题描述
我们有一个网站,人们可以访问,然后搜索和申请工作。限制将根据其成员类型适用。
会员类型(白金,金,银,免费)
铂金可以搜索50次/天,每天50次。
黄金可以每天搜索15次,每天可以申请15个工作
银可以每天搜索10次,每天可以申请10个工作
免费可以搜索5次/天,并适用于1个工作/天。
我们需要的
我们需要定义用户
2.我们需要定义会员类型
3.我们需要定义每个会员类型的限制
4.我们需要一种方法来检索用户每天所做的搜索和应用程序。
前三个是配置,最后一个是用户数据。我们可以用它来定义与系统交互的方式。
实际代码
我们创建一个类来表示成员资格类型。它可能看起来像这样:
namespace Models { public sealed class MembershipTypeModel { public string MembershipTypeName { get ; set ; } public RestrictionModel Restriction { get ; set ; } } } |
该RestrictionModel类包含每天最大搜索,每天最大的应用:
namespace Models { public sealed class RestrictionModel { public int MaxSearchesPerDay { get ; set ; } public int MaxApplicationsPerDay { get ; set ; } } } |
接下来,我们要一个UserModel,它将保存用户需要的数据:
namespace Models { public sealed class UserModel { public int ID { get ; set ; } public string Username { get ; set ; } public string FirstName { get ; set ; } public string LastName { get ; set ; } public string MembershipTypeName { get ; set ; } public UserUsageModel CurrentUsage { get ; set ; } } } |
该UserUsageModel将告诉我们有多少搜索和应用的用户已经完成的那一天:
namespace Models { public sealed class UserUsageModel { public int CurrentSearchesCount { get ; set ; } public int CurrentApplicationsCount { get ; set ; } } } |
最后,我们需要一个能够保存AccessFramework调用结果的类:
namespace Models { public sealed class AccessResultModel { public bool CanSearch { get ; set ; } public bool CanApply { get ; set ; } } } |
正如你所看到的,我保持这样很简单,我们不想迷失实施细节。
我们确实希望了解BDD如何帮助我们解决不仅仅是Hello World应用程序的问题。
所以现在我们有了我们的模型,我们来创建几个接口,这些将负责数据检索部分。
首先,处理通用配置数据的一个:
using Models; using System.Collections.Generic; namespace Core { public interface IConfigurationRetrieval { List<MembershipTypeModel> RetrieveMembershipTypes(); } } |
第二个处理用户特定的数据:
using Models; namespace Core { public interface IUserDataRetrieval { UserModel RetrieveUserDetails( string username); } } |
这两个接口将成为AccessFrameworkAnalyser类的参数,它们将允许我们模拟测试所需的数据:
using Core; using Models; using System; using System.Linq; namespace AccessFramework { public sealed class AccessFrameworkAnalyser { IConfigurationRetrieval _configurationRetrieval; IUserDataRetrieval _userDataRetrieval; public AccessFrameworkAnalyser(IConfigurationRetrieval configurationRetrieval, IUserDataRetrieval userDataRetrieval) { if ( configurationRetrieval == null || userDataRetrieval == null ) { throw new ArgumentNullException(); } this ._configurationRetrieval = configurationRetrieval; this ._userDataRetrieval = userDataRetrieval; } public AccessResultModel DetermineAccessResults( string username) { if ( string .IsNullOrWhiteSpace(username)) { throw new ArgumentNullException(); } var userData = this ._userDataRetrieval.RetrieveUserDetails(username); var membershipTypes = this ._configurationRetrieval.RetrieveMembershipTypes(); var userMembership = membershipTypes.FirstOrDefault(p => p.MembershipTypeName.Equals(userData.MembershipTypeName, StringComparison.OrdinalIgnoreCase)); var result = new AccessResultModel(); if (userMembership != null ) { result.CanApply = userData.CurrentUsage.CurrentApplicationsCount < userMembership.Restriction.MaxApplicationsPerDay ? true : false ; result.CanSearch = userData.CurrentUsage.CurrentSearchesCount < userMembership.Restriction.MaxSearchesPerDay ? true : false ; } return result; } } } |
我们这里做的不多 我们简单地在我们的两个接口中使用依赖注入,然后根据当前的搜索和应用程序,比较有多少个搜索和应用程序可用于所选用户的成员资格类型。
请注意,我们并不关心这个数据是如何实际加载的,通常会有一个实现到每个接口到一个数据库,但是在这个例子中,我们并不在乎。
我们需要知道的是,我们将有一种获取数据的方法,并且可能会在实际的UI项目中使用某种类型的IOC来连接实际的实现,这需要真正的数据。既然我们真的不在乎这一点,我们就不会实现它,我们将简单的展示一些所需的测试。
我们的功能文件可能如下所示:
管道表示处理表格数据的Specflow方法。
第一行包含标题,后面的行包含数据。重要的是要注意我们设置了多少数据,以及它们的可读性。事情变得更简单,因为这里没有代码,没有隐藏实际的数据。在这一点上,我们可以简单地复制和粘贴测试,更改数据,并再次准备就绪。
关键是非开发人员也可以做到这一点。
我们来看看第一种情况。
您可以看到,首先我们设置我们要使用的会员类型。记住我们不关心真实数据,我们关心这里的功能和业务规则,这就是我们正在测试的。这使得我们很容易以任何我们喜欢的方式设置数据。
第二步设置用户及其现有的搜索和应用程序数量。
最后,当使用AccessFrameworkAnalyser类时,我们期待一定的结果。
这里有一些重要的事情要提及。
我们如何在步骤代码中加载表格数据?
以下是加载成员资格数据的示例:
private List<MembershipTypeModel> GetMembershipTypeModelsFromTable(Table table) { var results = new List<MembershipTypeModel>(); foreach ( var row in table.Rows) { var model = new MembershipTypeModel(); model.Restriction = new RestrictionModel(); model.MembershipTypeName = row.ContainsKey( "MembershipTypeName" ) ? row[ "MembershipTypeName" ] : string .Empty; if (row.ContainsKey( "MaxSearchesPerDay" )) { int maxSearchesPerDay = 0; if ( int .TryParse(row[ "MaxSearchesPerDay" ], out maxSearchesPerDay)) { model.Restriction.MaxSearchesPerDay = maxSearchesPerDay; } } if (row.ContainsKey( "MaxApplicationsPerDay" )) { int maxApplicationsPerDay = 0; if ( int .TryParse(row[ "MaxApplicationsPerDay" ], out maxApplicationsPerDay)) { model.Restriction.MaxApplicationsPerDay = maxApplicationsPerDay; } } results.Add(model); } return results; } |
在尝试加载任何东西之前,始终检查一个标题是否存在是个好主意。这是非常有用的,因为根据您正在构建的内容,您并不总是同时需要所有的属性和对象。您可能只需要几个属性进行一些特定测试,在这种情况下,您不需要充满数据的表。你只需使用你需要的,忽略其余的,一切仍然有效。
现在加载会员类型的实际步骤变得非常简单:
[Given( @"the membership types" )] public void GivenTheMembershipTypes(Table table) { var membershipTypes = this .GetMembershipTypeModelsFromTable(table); ScenarioContext.Current.Add( "MembershipTypes" , membershipTypes); } |
这就像以前一样 - 在上下文>作业完成后加载数据>存储。
另一个有趣的一点是我们如何模拟我们所需要的。
我用的是NSubstitute,代码很简单:
[When( @"access result is required" )] public void WhenAccessResultIsRequired() { //data from context var membershipTypes = (List<MembershipTypeModel>)ScenarioContext.Current[ "MembershipTypes" ]; var user = (UserModel)ScenarioContext.Current[ "User" ]; //setup the mocks var configurationRetrieval = Substitute.For<IConfigurationRetrieval>(); configurationRetrieval.RetrieveMembershipTypes().Returns(membershipTypes); var userDataRetrieval = Substitute.For<IUserDataRetrieval>(); userDataRetrieval.RetrieveUserDetails(Arg.Any< string >()).Returns(user); //call to AccessFrameworkAnalyser var accessResult = new AccessFrameworkAnalyser(configurationRetrieval, userDataRetrieval).DetermineAccessResults(user.Username); ScenarioContext.Current.Add( "AccessResult" , accessResult); } |
初始数据来自在此之前运行的步骤,然后我们设置mocks,最后调用AccessFramework并将结果存储在上下文中。
最后一步,实际的断言如下所示:
[Then( @"access result should be" )] public void ThenAccessResultShouldBe(Table table) { var expectedAccessResult = this .GetAccessResultFromTable(table); var accessResult = (AccessResultModel)ScenarioContext.Current[ "AccessResult" ]; expectedAccessResult.ShouldBeEquivalentTo(accessResult); } |
在这里我使用了另一个NuGet软件包FluentAssertions。这可以让我比较对象,而不用担心每个属性将需要多少个断言。我仍然可以只有一个断言。
附上完整的代码,请看看,在Visual Studio中跟踪事情要容易得多。注意解决方案的结构,一切都在一个单独的项目中,一切都引用了它所需要的,没有什么更多:
希望现在你开始看到使用BDD的优点。对我而言的要点是,一旦实际需求清楚,我们就不需要看代码来解决它的功能。我们需要做的就是查看功能文件。
用票号标记场景是一个好主意,以便您了解每个测试涵盖的要求。这提供了业务的可见性方面,我们已经涵盖了多少和剩下的事情。
遇到错误时,编写一个复制错误然后修复错误的测试是个好主意。这样一来,您可以确定某个bug一旦修复,它就会保持固定。
如果您需要调试BDD测试场景,您可以简单地在一个步骤上设置一个断点,然后右键单击“测试资源管理器”窗口,选择“调试所选测试”并关闭您。
BDD Downsides
所以,你向我们展示了蛋糕,这种做法的缺点是什么?
只有一个我发现到目前为止,这不是BDD问题具体,而是一个工具问题。
一旦你有几个功能文件和健康的测试数量,你可能会有不少的步骤。没有简单的方法可以告诉任何功能文件不使用步骤方法。Codelens不会在这里帮忙。你不能确定这个特定步骤是否被十个场景调用。在任何地方都不算任何地方,这可能意味着你可以使用孤儿步法。
当然,您可以随时删除一步法,然后检查任何功能文件是否受到影响,但可能需要一段时间,具体取决于您拥有的功能文件数量。
正如我所说,这不是一个BDD问题,它是一个Specflow问题,很可能只有更好的时间过去。
对我来说,使用BDD的好处大大超过了Specflow的问题。
下载本文的全部源代码(Github)。