接下来的几章将带您通过一个成功的例子。虽然它可能会比通常使用的应用程序更小,更简单,但它使用真实世界的工具并解决现实世界中的问题。您不仅可以快速学会如何使用Cucumber将您的规范变成自动验收测试,而且您将很快找到如何处理复杂问题,如异步过程和数据库事务。
而且由于我们知道您正在处理现实世界的问题,我们还将向您展示强大的技术,使您的自动化验收测试易于维护和快速运行。
现在是把本书第一部分所学的东西放在一起并在实践中使用的时候了。关于黄瓜还有一些先进的概念,我们想要向你解释,他们会更容易用一个例子来演示。本书的这一部分我们要做的很多事情将模糊测试和开发之间的界限。如果你是测试人员而不是开发人员,那么不要让你担心:我们要构建的Java代码只是简单而已。接下来,你将会对我们喜欢的工作有一个很好的理解,并且学习一些关于黄瓜工作的新知识。
在结束返回结果,我们刚刚在新建项目打造软件的ATM开始工作。我们对系统的最重要的行为有一个单一的场景:让某人走向机器并提取现金。
现在,我们将采取这种情况,并在外面进行设计,就像我们在一个真实的项目上一样。在本章中,我们将通过为我们的ATM取出一个简单的域模型来实现这个场景。然后,在下一章中,当我们发现我们的方案中有一个缺失的步骤时,我们会发现一个令人讨厌的惊喜。最后,我们将通过围绕领域模型引入一个用户界面来演示精心设计的测试代码的好处。 到本章结束时,您将了解如何管理步骤定义之间共享的状态。我们将编写一些自定义帮助器方法,这些方法将在我们的步骤定义和我们正在构建的应用程序之间引入一层解耦。我们将向您展示如何使用变换来减少步骤定义中的重复,并使得使用有意义的数据类型更容易。最后,我们将向您展示我们如何组织项目中的文件,以便他们易于使用和维护。
勾勒出领域模型
任何面向对象程序的核心都是领域模型。当我们开始构建新的系统时,我们喜欢直接使用领域模型。这使我们能够快速迭代并快速了解我们正在处理的问题,而不会被用户界面小控件所分散。一旦我们有一个真正反映我们对系统理解的领域模型,很容易将它包装在一个漂亮的皮肤中。
我们将让Cucumber驱动我们的工作,直接在步骤定义中构建域模型类。像往常一样,我们首先对我们的场景运行mvn clean test来提醒我们接下来要做什么:
当我们上一次处理这个场景时,我们刚刚达到了为每个步骤定义编写正则表达式并实现第一个步骤的地步。以下是我们的步骤文件的外观:
在第一步定义中,我们创建了一个新的Account类的实例。黄瓜然后告诉我们,我们需要工作在我们的第二步定义,仍然标记为待定。在我们这样做之前,让我们回顾一下步骤定义中的代码,看看我们的想法。有几件事情我们不高兴:
一些不一致的语言正在蔓延; 该步骤涉及存款,但代码将资金交给账户构造者。
这一步对我们说谎!它说,鉴于我已经存入了100美元在我的帐户,并通过了。但是从我们的实施中我们知道,任何地方都没有任
银行余额并不总是包含整数美元,但我们的步骤定义使用int。我们应该可以存入美元和美分。
在我们进入方案的下一步之前,我们将通过上述每一点进行研究。
把话说得对
在做别的事情之前,我们要先澄清一下这个措辞,所以让我们考虑一下如何让步骤定义中的代码更像步骤中的文本。我们可以回去重新说一下这个步骤,就像给定一个100美元余额的账户一样。但实际上,一个账户有一个平衡的唯一途径就是有人存入资金。所以,让我们改变我们在步骤定义中与领域模型交谈的方式来反映:
这似乎更好。
麻烦我们的措辞中还有其他的东西。在这一步,我们谈论我的账户,这意味着在与账户有关系的情况下,或许是客户,存在主角。这是一个迹象,我们可能错过了一个领域的概念。但是,在我们遇到一个需要处理多个客户的情况之前,我们希望保持简单,并且专注于设计运行这个场景所需的最少的类。所以,现在我们暂停这个担心。
告诉真相
现在我们对Account类的接口感到高兴了,我们可以从我们的代码审查中解决下一个问题。在我们把资金存入账户后,我们可以用一个断言来检查它的余额:
我们在这里使用了JUnit断言,但如果您更喜欢另一个断言库,请随时使用它。在给定步骤中放置一个断言可能看起来很奇怪,但是它向未来的读者传达了这个代码的状态,我们期望系统在该步骤运行之后处于什么状态。我们需要为账户添加一个余额方法,以便我们可以运行这个代码:
注意,我们只是画出了类的接口,而不是添加任何实现。这种工作方式是外部开发的基础。我们尽量不要去想如何的帐户将会仍然工作,但集中在什么它应该是能够做到的。
现在当我们运行测试时,我们得到了一个很好的有用的失败信息:
现在我们的步骤定义更为稳健,因为我们知道,如果它不能像我们要求的那样将资金存入账户,那么它将会发出警报。将断言添加到“ 给定”和“ 当这样的步骤”意味着如果在项目后期有回退,则诊断将变得更加容易,因为情况会在问题出现的地方发生。这个技巧在你画草图的时候非常有用。最终,我们可能会将这个检查进一步向下移动到测试堆栈,进入Account类的单元测试,并将其从步骤定义中移出。
做最简单的事情
我们在这里是一个决定点。我们已经有效地完成了我们的第一步定义,但是我们不能移动到下一个定义,直到我们对Account类的实现进行了一些更改,以便该步骤通过。
在这里暂停,将Account类移入一个单独的文件,并开始用单元测试来驱除我们想要的行为。我们现在要尽力抵制这种诱惑,并留在账户类别之外。如果我们能够从这个角度全面了解这个场景,那么一旦我们进入并开始实施它,我们就会对这个类的界面的设计更有信心。
所以,我们将继续致力于我们非常简单的Account类的实现,这个类显然是不完整的,但是正好足够让第一步通过。想想这个,就像在建筑工地搭脚手架一样,我们最终会把它放下去,但是这样可以帮助事情站起来。
改变账户看起来像这样,现在第一步应该通过:
好。我们还有一个问题留在我们的名单上,这就是我们使用int作为我们的平衡。现在我们已经走过了,我们可以自信地重构。
保持诚实的变革
第一步定义的另一个问题是我们的正则表达式捕获一个整数,但是我们希望能够将美元和美分存入账户。所以让我们改变这个功能来演示这个:
现在当我们运行mvn clean test时,它会报告我们有一个未定义的步骤定义,并告诉我们现在需要使用什么正则表达式来匹配我们的功能:
黄瓜已经识别了这个步骤中的两个数字,并生成了一个正则表达式,分别捕获每个数字,并将它们作为两个整数传递给我们的步骤定义。我们将使用专门为这个例子编写的Money类,而不是传递两个整数,你可以在src / main / java / nicebank中找到它。
塞布说:我们是否真的要重塑金钱?
你可能会认为像Java这样的语言会有自己的货币类,但是到目前为止,它并没有。有很多类可用,比如Joda Money,但是我们仍然在等待JSR 354(它将定义一个Java Money类)来发布。
在我们的步骤定义中,我们现在可以创建Money类的一个实例:
而我们改变了我们的账户的实施来处理金钱存款:
自动转换魔术
您是否想知道Cucumber如何知道在它生成的步骤定义片段中使用什么参数?首先,它在正则表达式中为每个捕获组生成一个参数。然后,对于每个捕获组,如果它只匹配数字,则会创建一个int参数; 否则它会创建一个String参数。例如:
如果你想操纵数字作为一个字符串呢?没问题 - 这些片段只是黄瓜给你的提示。如果您想使用不同的类型,那么只需更改步骤定义的签名,如下所示:
在引擎盖下,Cucumber将正则表达式中的每个捕获组表示为一个字符串。然后,在调用步骤定义时,它将字符串转换为期望的类型。如果无法执行转换,则会抛出一个cucumber.runtime.CucumberException,否则转换会自动进行 - 就像通过魔术一样。
这很好,但是这仍然意味着我们必须在每一个美元和美分的步骤定义中创建一个Money实例。如果Cucumber可以直接将Money对象传递给步骤定义,则会更好。
我们需要做的第一件事是改变步骤定义,以便:
正则表达式捕获一个捕获组中的全部数量
它的签名需要一个Money参数
现在我们需要做的就是告诉Cucumber如何将一个String对象转换成一个Money对象。一种方法是给我们的Money类一个单一的参数构造函数,接受一个字符串。当调用步骤定义时,Cucumber会自动调用这个构造函数,传入与我们的捕获组中正则表达式匹配的原始String。
但是如果Money没有String构造函数而不是我们的修改呢?在这种情况下,我们需要了解另一个Cucumber特性,即Transformer类,它允许我们创建Money的实例,而不需要给它一个新的构造函数。 变形金刚工作捕捉的论点。每个转换负责将捕获的字符串转换为更有意义的字符串。例如,我们可以使用Transformer来获取包含金额的String参数,并将其转换为Money类的一个实例。让我们创建一个MoneyConverter转换器,并把它放在一个新的文件夹,测试/转换:
然后我们在步骤定义中注释参数来告诉Cucumber哪个Transformer使用:
棒!该代码看起来更干净,更容易阅读。
我们可以通过将美元符号移动到捕获组来进一步整理这一点。这使得代码更加紧密,因为我们把整个正则表达式语句汇集起来,以获取存入的资金数额。这也使我们有可能在将来获得其他货币。
让我们再看看我们的待办事项列表。使用转换已经清除了最初的代码审查的最后一点。随着我们的进展,我们收集了一个新的待办事项清单项目:我们需要正确实施帐户,并进行单元测试。让我们暂时将其放在列表中,然后转到该方案的下一步。
添加自定义帮助器方法
我们已经实施了我们的方案的第一步,建立一个足够的平衡,撤回应该工作的帐户。毕竟谈论变换之后,很难记住下一步需要做什么,但是我们总是可以依靠黄瓜来提醒我们:
第一步是按照我们的预期通过,第二步是通过挂起消息失败。那么,我们下一步的任务就是实施一个模拟客户从ATM取款的步骤。这是空的步骤定义:
我们需要从某个地方收回现金,这意味着我们需要在这种情况下带来一个新的演员。这个新班级将处理我们的要求,从我们的帐户提取现金。如果我们在现实生活中走进一家银行,这个角色将由一个出纳员来扮演。再次思考,让我们勾画出我们想要的代码:
这看起来不错。出纳员需要知道从哪个账户拿走现金以及需要多少钱。然而,有一点不一致之处在于:步骤定义谈到要求现金,但在代码中,我们正在撤回。撤销是我们最经常使用的术语,所以让我们改变场景中的文本以匹配。
好多了 现在,情景中的语言更能反映下面的代码中发生了什么。再次运行mvn clean test,应该提示您创建一个Teller类。我们来创建一个:
再一次,我们刚刚勾画出界面,而不添加任何实现。有了这个,尝试再次运行mvn clean test:
A-HA。我们在第一步定义中定义了myAccount,但是当我们尝试在第二步定义中使用它时,Java看不到它。我们如何使myAccount可用于两个步骤定义?答案在于理解Cucumber如何执行步骤定义。
在步骤之间共享状态
在Cucumber执行一个步骤定义之前,它会创建一个定义步骤定义的类的实例。每个步骤定义类只有一个实例在执行场景时创建。一旦场景完成,Cucumber会抛弃所有这些步骤定义实例,以确保每个场景都与其他所有场景分离。
就像常规Java类中的方法一样,我们可以使用实例变量在同一个类中定义的步骤定义之间传递状态。下面是代码看起来如何使用myAccount作为实例变量存储在步骤定义中:
尝试一下。有用!
这个解决方案是可以的,但是我们不喜欢在步骤定义中保留实例变量。实例变量的问题是,如果你不设置它们,它们只会保持为空。我们讨厌null,因为他们在你的系统周围蠕动,造成难以追踪的奇怪的错误。例如,如果有人后来一起去,并使用在那些尚未设置另一种情况下,第二步骤定义我的帐户,一个空会得到传入Teller.withdrawFrom。 黄瓜使用依赖注入来促进步骤之间的共享状态。(我们说说这在本章稍后,在依赖注入)。现在,我们将创建一个辅助类的步骤之间分享我们的状态。
创建一个自定义帮助类
在普通类中,我们可以通过将实例变量放在访问器方法后面来避免null,如下所示:
我们可以在黄瓜做同样的事情。让我们定义一个新的帮助类,然后在我们的步骤定义中使用它。以下是我们可以做的事情:
我们定义getMyAccount上一类KnowsMyAccount。然后我们创建一个步骤定义构造函数,在这里我们创建这个辅助类的一个实例:
这意味着我们可以摆脱从第一步定义初始化Account的代码,并且可以去掉Account实例变量,所以步骤定义可以使用getMyAccount方法:
有了这个改变,运行mvn clean test,前两个步骤现在应该通过。
设计我们的终点线
现在让我们试着让我们的最后一步通过。我们可以看到,前两个步骤正在通过,最后一个正在等待。差不多了!我们来看看最后一步的定义:
这里最大的问题是:现金在哪里分配?我们可以检查系统的哪一部分,以证明它是否将这笔钱分配出去?似乎我们错过了一个领域概念。在自动取款机中,现金最终会从ATM上的一个插槽中拔出,如下所示:
看起来不错。当我们勾我们的代码到真正的硬件,我们将需要谈论它的一些方法,这个对象将正常工作作为检验双其间。让我们开始运行mvn clean test来解决这个问题。首先它告诉我们,我们当然需要定义getCashSlot方法。让我们添加另一个方法到我们的助手类,并重新命名它,以反映它的新角色。
我们再次运行mvn clean test,这次我们要定义CashSlot类,所以我们继续按照我们的说法去做。再次,我们只是用最小的实现来构建我们想要使用的接口的草图:
现在当你运行mvn clean test的时候,你会看到我们已经接近了我们的目标:所有的类和方法都是连接起来的,最后一步是失败,就是因为没有现金从CashSlot出来:
为了让最后一步通过,有人需要告诉CashSlot在客户退出时分配现金。这是负责交易的出纳员,但目前它对CashSlot没有任何了解。我们将使用依赖注入来将CashSlot传递给Teller的构造函数。现在,我们可以想像一个新CashSlot该方法柜员机可以用它来告诉它分配现金:
这似乎是Teller的最简单的实现,我们需要让场景通过。奇怪的是,当我们从外部设计这个方法时,我们认为我们需要帐户参数,但现在我们似乎并不需要它。让我们保持专注,虽然; 我们将在我们的待办事项清单上做一个说明,以便稍后查看,并继续执行此步骤。
我们现在需要做两个改变。我们需要将新的分配方法添加到CashSlot中,并且我们必须更改第二步定义以正确创建出纳员:
这个对新Teller()的调用现在在步骤定义中显得不合适。我们所有的其他课程都是在我们的助手课程里面创建的,所以让我们和Teller一样。这意味着我们的步骤定义变得不那么混乱:
步骤定义代码现在都很精美。把一些细节推到我们的帮助类中意味着步骤定义代码处于更高的抽象层次。当您从面向业务的场景进入步骤定义时,这使得它不那么精神上的飞跃,因为代码不包含太多细节。
但是,直到我们在CashSlot上做了一些工作之后,这种情况才能通过。我们的新班级仍然缺少分配方法。一个简单的实现应该得到这个工作:
最后一次
运行mvn clean test,你会看到场景通过。
优秀!去喝一杯,然后我们可以坐下来,检查代码,并做一些重构。
组织守则
在我们结束这个会议之前,让我们对我们的未来自己好一点,整理一下。在我们的工作中,我们只是在src / test / java / nicebank / Steps.java文件中创建了所需的所有内容。我们将把大部分东西都搬出去,并把它放到一个更传统的地方。以下是我们想要解决的问题的列表:
1、应用程序的域模型类应该移动到src / main / java树中。
2、该KnowsTheDomain类可以进入自己的文件。
3、该步骤文件可以分割,以更好地组织步骤定义。对于只有三个步骤定义的项目来说,这可能是不必要的,但我们仍然会这样做来说明我们如何在一个更大的项目上做到这一点。
分离应用程序代码
在Java项目中传统的方法是将系统代码存储在项目根目录的src文件夹中。通常情况下,您的生产代码将创建在一个相关的命名包中。我们公司正试图从品牌NiceBank中脱颖而出,所以我们将生产代码放在src / main / java / nicebank文件夹中,同时还有已经写好的Money类。 让
我们三个班,移动账户,取款,且CashSlot,进入文件Account.java,Teller.java和CashSlot.java中的src / main / JAVA / nicebank文件夹。当我们这样做的时候,我们需要指定这些类是在nicebank包中并且使它们公开。
由于Steps也在nicebank包中,所以不需要输入。
保存文件并运行mvn clean test。
干运转
当我们开始移动文件时,使用Cucumber的“干运行”功能测试一切仍然匹配是有用的。干运行的目的是解析你的功能和步骤定义,但实际上并没有运行任何一个。
如果您只想检查未定义的步骤并确保所有路径设置正确,这比真实的测试运行要快得多。习惯使用dryRun来帮助重构场景和步骤定义,但请记住,由于没有实际调用您的步骤定义,因此不会测试您的转换或转换。
分离支持代码
我们也应该收拾帮手类。这就是我们所说的支持代码,所以我们把它放在一个新的文件夹src / test / java / support中。将KnowsTheDomain类移入一个名为src / test / java / support / KnowsTheDomain.java的新文件。当我们在KnowsTheDomain中添加更多的方法时,我们将把它分成多个类。不过,现在这样好了。
由于KnowsTheDomain在支持包中,因此我们需要为域类Account,CashSlot和Teller 导入语句。
组织我们的步骤定义
我们已经用一个名为Steps.java的单步定义文件来实现这个目标,对于这个大小的项目,我们可能还需要一段时间。随着步骤定义的数量开始增长,我们将要分割文件,以使代码更加紧密。我们发现,我们最喜欢的组织步骤定义文件的方法是将每个域实体的文件进行组织。所以,在我们的例子中,我们有三个文件:
在我们做这个重构的第一次尝试中,我们给每个步骤类自己的构造函数创建一个KnowsTheDomain的实例:
让我们运行mvn clean test,看看会发生什么:
我们的最后一步是再次失败。你能明白为什么吗?
以前,我们通过使用在步骤构造函数中创建的KnowsTheDomain类的本地实例来共享状态,但现在我们有三个单独的步骤定义类和三个单独的KnowsTheDomain实例。我们只需要一个KnowsTheDomain的实例,这就是Cucumber的依赖注入功能来拯救的地方。
依赖注入
依赖注入(DI)是一种技术,允许我们从一个具体的依赖关系中隔离一个类,直到运行时。[34]通常这被用来推迟我们将要使用的接口的实际实现的决定。然而,在这种情况下,Cucumber使用依赖注入框架来创建一个类的单个实例,并在所有需要使用它的步骤定义类之间共享该实例。(我们将谈论黄瓜在第11章使用DI要多得多, 从而简化了设计与依赖注入)。 黄瓜运输与几个流行的DI框架集成,以供选择。我们将使用PicoContainer [35],它可能是最轻量级的,也可以在BSD许可下使用。让我们修改我们的pom.xml以添加PicoContainer作为依赖:
现在我们需要做的是在每个步骤定义中更改构造函数。以下是一个例子:
现在当我们运行mvn clean test时,所有的步骤都会再次通过。尝试一下!
我们刚刚学到的东西
看起来我们在这里建立的系统只是一个玩具。没有任何用户可以与之交互的具体组件:我们的CashSlot只是一个普通的旧Java类,并没有任何按钮供用户推送。但是,我们所做的只是领域模型的开始,对问题有更深的理解。外在并不总是意味着从用户界面开始; 它意味着从任何你想发现的外面开始。
我们知道这并不总是可行的。您通常会将测试添加到旧版系统中,或者在您要求开发代码时已经定义好用户界面的项目上工作。即使在这些情况下,在Java类中建模您的域也将有助于您的理解,并使测试代码在长期内更易于维护。
以下是本章所涉及的一些更具体的主题:
通过删除恼人的重复代码来处理从步骤捕获的参数,转换有助于维护性。
支持步骤定义的Java代码可以分解为不同的类。
每个域实体组织一个文件的步骤定义文件是一个好习惯。
您可以使用由Cucumber与几个依赖注入框架之一进行实例化和管理的帮助器类在步骤之间传递状态。
通过添加我们自己的KnowsTheDomain类,我们已经使得步骤定义代码更易于阅读,并且我们已经开始将步骤定义与系统分离。随着系统本身的发展,这种解耦的好处将会越来越晚。事实上,在下一章中,我们将向您展示如何引入Web用户界面来提取现金,而不必在步骤定义中更改行。
尝试这个
现在有很多地方可以采用这个例子,我们已经开始运行了。让我们看看你的一个想法尝试。
大改写
现在我们已经一起发现了领域模型,为什么不看看你是否可以再次实践呢?删除特征,变换和步骤定义以外的所有内容。然后删除每个步骤定义的主体,并将其更改回挂起。关闭这本书,运行mvn clean test,然后离开你!
试着忘掉我们所做的事情,并享受为自己发现域模型的过程。
Bug狩猎
在待办事项列表中还有一个项目,提醒我们需要调查为什么我们最初设计的Teller.withdrawFrom()方法需要两个参数,但我们只使用其中的一个。
看看你是否能够弄清楚这种不一致的含义,并考虑你想要解决的变化。玩代码并尝试一些解决方案。我们将在下一章的开头讨论这个问题,所以如果你不确定答案的话,你将不用等待。
边缘情况
我们在这里有一个单一的场景,通过撤回现金的过程中的快乐的道路。你能想到一些简单的变化会导致不同的结果吗?例如,如果您的帐户余额较低,会发生什么情况?
用同样的cash_withdrawal.feature文件写出你的新场景,如果你面临挑战,那就去自动化吧。