C-7-测试驱动开发实践指南-全-

C#7 测试驱动开发实践指南(全)

原文:zh.annas-archive.org/md5/91ee761e90c542d975f879ce615f9e82

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

随着软件项目规模和复杂性的增长,维护它们往往变得更加困难、耗时且昂贵。通过测试驱动开发(TDD),你可以学会开发可测试、可扩展和可维护的软件应用程序。

这本书面向谁

这本书面向那些对 TDD 只有初步了解且希望全面了解 TDD 如何为他们和他们的应用程序带来益处的软件开发人员。那些对 C#和.NET 框架有中级理解或对 JavaScript 和 React 有深入理解的软件开发人员可能会跟随书中使用的所有代码示例。

这本书涵盖的内容

本书涵盖了从为什么 TDD 很重要到设置测试环境,以及如何开始测试绿色应用程序的所有内容。随着读者的熟悉程度提高,他们将被介绍到更高级的 TDD 主题,如抽象第三方代码、从 TDD 角度解决问题,以及如何处理没有考虑可测试性的遗留代码。

第一章,为什么 TDD 很重要,询问什么是 TDD 以及为什么你应该关心?在这一章中,你将了解 TDD 是什么以及为什么它很重要。将提出一个令人信服的 TDD 论点,并展示其好处,更重要的是,展示其执行过程。

第二章,设置.NET 测试环境,解释了如何设置你的 IDE 并配置测试框架,以便你能够轻松地在 C#和.NET 中运行测试,包括 Speaker Meet API 的更多细节和更多复杂性的示例。

第三章,设置 JavaScript 环境,配置 JavaScript 测试框架,以便你可以在 IDE 中轻松运行测试。它提供了更多细节和更多关于 Speaker Meet React 应用程序增长复杂性的示例。

第四章,开始之前需要了解的内容,更深入地探讨了 TDD 的为什么和怎么做。你将学习定义和测试边界以及抽象第三方代码(包括.NET 框架)的重要性,你还将发现更高级的概念,如间谍、模拟和伪造,以及如何避免过程中的陷阱。

第五章,Tabula Rasa - 带着 TDD 思维接近一个应用程序,解释了如何开始一个新的应用程序。你将应用之前章节中学到的知识,并以 Speaker Meet 为例,采用同样的方法来处理一个完整规模的应用程序。

第六章,解决问题的方式,将整体应用程序的更广泛问题分解成可以独立开发的具有意义的块。你将学习不同的应用程序开发方法,例如从前到后、从后到前和从内到外。

第七章, 测试驱动 C#应用程序,将需求和相关用户故事组装成工作软件,使用 TDD。它解释了如何利用你迄今为止所掌握的所有技能来测试边界,测试小的、独立的单元。

第八章, 抽象问题, 探讨了如何抽象第三方库,包括.NET 框架。它涵盖了如何移除对 DateTime 和 Entity Framework 等事物的依赖。它解释了如何将应用程序与其特定实现解耦,不仅使应用程序可测试,而且更加灵活,未来也更容易修改。

第九章, 测试 JavaScript 应用程序,现在你已经有了一个工作的 API,重点关注使用 React 在 JavaScript 中创建单页应用程序。它关注测试驱动的动作和 reducers 以及应用程序中的任何功能。

第十章,探索集成,解释了如何编写集成测试以确保应用程序正常运行。

第十一章, 需求变更, 关注需求变更时会发生什么。如果发现了一个错误怎么办?没问题,更改一个测试或编写一个新的测试来覆盖新的需求或防御已发现的错误。现在,编写一些新的代码或更改一些现有的代码,以确保所有新的/修改后的测试通过。如果你做得正确,你应该可以放心地做出这些更改,因为你的现有测试套件将防止你引入新的错误。

第十二章,遗留问题,解释了现在有很多应用程序没有足够的(任何?)测试覆盖率,而且更少的是以测试优先的方式编写的。你将发现一些没有考虑可测试性的遗留应用程序的主要问题;这些问题将被识别,并且还将讨论最佳恢复方法。

第十三章,解开混乱,深入探讨了如何安全地修改没有考虑测试的遗留应用程序。如何在修改现有代码时添加测试以最小化引入新错误的可能性?将使用一个极端的例子来探讨这些主题以及更多内容。

第十四章,“更好的起点”,强调 TDD 是一个个人选择。你不需要任何人的许可来做好的工作。本章将涵盖如何继续 TDD 的成功之旅、如何将 TDD 引入您的团队以及如何作为 TDD 专家重新融入世界。

为了充分利用本书

想要跟随书中示例的读者应具备以下条件:

  • 具备 C#和/或 JavaScript 的中级理解

  • 虽然不是必需的,但之前接触过 React 将有所帮助

  • 熟悉 N 层架构

下载示例代码文件

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择“支持”标签。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

下载文件后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • Windows 系统上的 WinRAR/7-Zip

  • Mac 系统上的 Zipeg/iZip/UnRarX

  • Linux 系统上的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Practical-Test-Driven-Development。我们还有其他来自我们丰富图书和视频目录的代码包可供下载,网址为github.com/PacktPublishing/。请查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/PracticalTestDrivenDevelopment_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

"babel": {
   "presets": [
     "react-app"
   ]
 },

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都按以下方式编写:

>npm install mocha chai sinon enzyme

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要提示如下所示。

小贴士和技巧看起来像这样。

联系我们

我们欢迎读者的反馈。

总体反馈:请发送邮件至 feedback@packtpub.com 并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送邮件给我们。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 发送邮件给我们,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.

评论

请留下评论。一旦您阅读并使用了本书,为何不在购买该书的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 方面可以了解您对我们产品的看法,我们的作者也可以看到他们对本书的反馈。谢谢!

如需更多关于 Packt 的信息,请访问 packtpub.com.

第一章:为什么 TDD 很重要

你选择这本书是因为你想了解更多关于测试驱动开发(TDD)的知识。也许你之前听说过这个术语。也许你认识编写单元测试的软件开发者,并想了解更多。我们将向你介绍 TDD 相关的术语、结构和理念。到这本书的结尾,你将拥有足够的知识重新进入这个世界,作为一个测试驱动开发者,并在你漫长而繁荣的职业生涯中自信地使用你的技能。

为什么这本书?当然,关于 TDD 主题的书籍有很多。我们写这本书的目的是希望它能为你,读者,提供关于我们在进行 TDD 时所使用的思维方式的深入了解。我们也希望这本书能提供一些关于我们在过去 10 年进行 TDD 时所学到的一些概念和教训的更新观点。

那么,为什么 TDD 如此重要?随着越来越多的企业和行业依赖软件解决方案,确保这些解决方案稳健且无错误变得越来越重要。它们越便宜、越一致,就越好。以 TDD 为目标的开发的应用程序本质上更容易测试、更容易维护,并显示出在其他情况下难以达到的某种正确性水平。

在本章中,我们将了解以下内容:

  • 定义 TDD 并探索基础知识

  • 在 C#和 JavaScript 中创建我们的第一个测试

  • 探索红、绿、重构的基本步骤

  • 通过测试增长复杂性

首先,一些背景信息

在你的职业生涯中,你可能已经接触过单元测试。你很可能已经编写过一个或两个测试。不幸的是,许多开发者还没有机会体验到测试驱动开发(Test-Driven Development)的乐趣。

约翰关于测试驱动开发(TDD)的故事

我第一次接触 TDD 是在大约五年前。我正在面试一个小型初创公司的首席开发者职位。在面试过程中,CTO 提到开发团队正在实践 TDD。我告诉他我没有实际的 TDD 经验,但我确信我可以适应。

实话实说,我有点紧张。到那时,我甚至从未写过单个单元测试!我把自己带进了什么境地?一个提议被提出,我接受了。一旦我加入了这家小公司,我就被告知,虽然 TDD 是目标,但他们还没有完全达到。谢天谢地;危机解除。然而,我仍然很感兴趣。直到几个月后,团队才深入 TDD 的世界,正如人们所说,其余的就是历史了。

克莱顿关于 TDD 的故事

我对 TDD 的介绍与约翰的不同。我在 20 世纪 90 年代初上初中时就开始写代码了。从那时起直到 2010 年,我总是挣扎于在引入新需求时编写不需要进行重大架构更改的应用程序。2010 年,我终于厌倦了不断的重写,并开始研究工具和技术来帮助我解决这个问题。我很快发现了 TekPub,这是一个当时由 Rob Conery 拥有和运营的在线学习网站。通过 TekPub 我开始学习 SOLID 原则和 TDD。在几乎六个月的时间里不断碰壁后,我开始理解 TDD 是什么以及如何使用这些原则。结合 SOLID 原则,TDD 帮助我编写易于理解的代码,这些代码足够灵活,可以应对业务可能提出的任何需求。我最终来到了约翰工作的同一家公司,与他一起工作,正如他所说,其余的就是历史了。

SOLID 原则(稍后将会详细解释),是帮助产生干净、可维护和灵活代码的指导原则。它们有助于减少僵化、脆弱和复杂性。通常被认为是面向对象的原则,我发现它们适用于所有编程范式。

那么,TDD 究竟是什么呢?

在网上搜索,你肯定会发现 TDD 是 Test-Driven Development 的缩写。事实上,这本书的标题也会告诉你这一点。然而,我们使用一个稍微更有意义的定义。那么,TDD 究竟是什么呢?用最简单的话说,TDD 是一种软件开发方法,旨在减少错误并使应用程序内部具有灵活性。如果做得正确,TDD 是快速、准确和无所畏惧的应用程序开发的基石。

测试驱动开发是一种让测试驱动系统设计的方法。这究竟意味着什么呢?这意味着你不能带着解决方案开始,你必须让测试驱动正在编写的代码。这有助于最小化不必要的复杂性,并避免过度架构的解决方案。测试驱动开发的规则

TDD 的坚定支持者规定,在没有编写一个失败的单元测试的情况下,你甚至不能写一行生产代码,编译失败也是一种失败。这意味着你先写一个简单的测试,然后观察它失败,接着编写一些代码让它通过。随着测试和生产应用功能的增长,系统会逐渐演变。

TDD 不是关于测试的,它是关于设计的。

许多人会争论说,TDD 是关于测试的,进而也是关于应用程序的测试覆盖率。虽然这些都是 TDD 的绝佳副作用,但它们并不是实践背后的驱动力。

此外,如果代码覆盖率和指标成为目标,那么开发者可能会引入无意义的测试,只是为了增加数字。也许这与其说是一个风险,不如说是一个保证会发生的事情。让交付的功能和满意的客户成为衡量成功的指标。

TDD 是关于设计的。通过 TDD,应用程序的功能将增长,而不会引入不必要的复杂性。如果你只编写小的测试和足够的生产代码来使测试通过,那么引入复杂性是非常困难的。重构,即在不添加或更改行为的情况下修改代码结构,也不应该引入复杂性。

一种 TDD 方法

TDD 也被称为测试优先开发。在两个名称中,关键方面是测试必须在应用程序代码之前编写。被开发社区亲切地称为“Uncle Bob”的 Robert C. Martin 创建了 TDD 的三条法则。它们如下:

  1. 除非是为了使失败的单元测试通过,否则不允许编写任何生产代码

  2. 你不允许编写比足够使失败的单元测试失败更多的单元测试,编译失败也是失败。

  3. 你不允许编写比足够通过一个失败的单元测试更多的生产代码。

你可以在 butunclebob.com/ArticleS.UncleBob.TheThreeRulesOfTdd 上了解更多关于这些法则的信息。

通过遵循这些规则,你将确保你的测试代码和生产代码之间有一个非常紧密的反馈循环。敏捷软件开发的主要组成部分之一是努力减少反馈周期。反馈周期越小,项目在出现问题的第一个迹象时就能进行纠正。同样,这也适用于测试反馈周期。你的测试越小,最终结果就越好。

想要了解敏捷的视频,可以查看 Martin Esposito 和 Massimo Fascinari 的 Getting Started with Agilewww.packtpub.com/application-development/getting-started-agile-video)。

一种替代方法

TDD 的原始方法在多年以来引起了一些混淆。问题在于原则和方法的结构性不够。2006 年,Dan North 在《Better Software》杂志上发表了一篇文章(www.stickyminds.com/better-software-magazine/behavior-modification)。这篇文章的目的是消除一些混淆,并帮助减少开发者在学习 TDD 流程时遇到的陷阱。这种新的 TDD 方法被称为 行为驱动开发BDD)。BDD 为测试提供了一个结构,以及业务需求和单元测试之间沟通的一种几乎无缝的方式。

流程

没有目标开始任何旅程都是困难的。有一些技巧和窍门可以帮助你开始 TDD。首先是“红色”,“绿色”,“重构”。

红色,绿色,重构

我们已经讨论了在编写生产代码之前编写失败的测试。目标是通过对一系列微小改进的逐步构建系统。这通常被称为“红色”,“绿色”,“重构”。我们编写一个小测试(红色),然后通过编写一些生产代码使其通过(绿色),然后在我们再次开始之前重构我们的代码(重构)。

许多 TDD 实践者提倡先进行“存在性”测试。这将有助于确定你的环境设置是否正确,你不会收到错误的阳性结果。如果你编写了一个“存在性”测试并且一开始没有失败,你就知道有问题。一旦你收到第一个失败,你就可以安全地创建正在测试的类、方法或函数。这也会确保你在确定系统正常工作之前,不会一头扎进代码中,写下一行又一行的代码。

一旦你有了第一个失败和第一个工作示例,就是时候缓慢地扩展应用程序了。选择下一个最有趣的步骤,并编写一个失败的测试来覆盖这一步骤。

在每次迭代中,你应该暂停并评估是否可以进行任何清理工作。你能简化代码块吗?或许需要一个更具描述性的变量名?是否可以在此时安全地纠正代码中的错误?评估生产代码和测试套件都很重要。两者都应该是干净、准确且可维护的。毕竟,如果代码如此混乱,以至于没有人能够理解,那么代码还有什么价值?

程序员阻塞

TDD 也会帮助你避免作家们常说的“作家阻塞”和我们所称的“程序员阻塞”。程序员阻塞发生在你坐在键盘前试图解决问题,但不知道从何开始的时候。我们从开始的地方开始。写下你能想象的最简单、最简单的测试。写“存在性”。

我们为什么要关心?

我们是专业人士。我们希望做好工作。如果有人发现我们的代码有问题,我们会感到难过。如果 QA 发现了一个错误,我们会感到悲伤。如果我们的系统用户遇到了错误,我们可能会哭泣。我们应该努力交付高质量的、无错误的代码和一个功能齐全、特性丰富的应用程序。

我们也很懒惰,但这是好懒惰。我们不想不得不运行整个应用程序来验证一个简单的函数是否返回正确的值。

TDD 的反对意见

对 TDD 的反对意见,有些是合理的,有些则不然。很可能你已经听说过一些,而且很可能你自己也重复过其中的一些。

测试需要时间

当然,测试需要时间。编写单元测试需要时间。遵循 TDD 的 红、绿、重构 循环确实需要时间。但是,如果不通过测试,你还能如何检查你的工作呢?

你验证了你编写的代码是否工作吗?没有测试你怎么做这件事?你是手动运行应用程序吗?这需要多长时间?在应用程序中有没有需要考虑的条件场景?你需要在手动测试应用程序时设置这些场景吗?你会跳过一些并只是 相信它们会工作 吗?

关于回归测试呢?如果你在一天、一周或一个月后进行更改怎么办?你是否必须手动回归测试整个应用程序?如果其他人进行了更改呢?你会信任他们像你一样彻底地进行测试,我相信你会的

如果你的代码被一个你可以点击按钮运行的测试套件所覆盖,你会节省多少时间?

测试是昂贵的

通过编写测试,你实际上是在增加你正在编写的代码量,对吧?好吧,是的,也不是。好吧,在极端情况下,你可能会接近双倍代码。再次强调,在极端情况下

不要把测试作为一项单独的条款。

在某些情况下,咨询公司已经将单元测试写入合同中,并附有条款和金额。不可避免的是,这给了客户机会争论删除这一条款,从而节省他们的钱。这是绝对错误的方法。测试将会进行,这是肯定的,无论是手动由开发者运行应用程序来验证其工作,还是由 QA 测试人员,或者由自动化测试套件。测试不是一个可以协商或删除的条款(哎呀!)。

你永远不会购买一个没有通过质量控制检验的汽车。灯泡必须通过检验。客户、客户或公司永远不会通过放弃测试来节省钱。问题变成了,你是早期在编写代码时编写测试,还是稍后手动编写?

测试是困难的

测试可能是困难的。这尤其适用于没有考虑到可测试性的应用程序。如果你在代码中使用了静态方法和散布着具体引用的实现,你将在以后添加测试时遇到困难。

我们不知道怎么做

我不知道怎么测试 真的是唯一可以接受的回答,前提是它很快就被 但我愿意学习 所跟随。我们是开发者。我们是房间里专家。我们被付钱来知道答案。承认自己不知道某事是可怕的。开始新事物更是如此。请放心,一切都会好起来的。一旦你掌握了 TDD,你会 wonder 你以前是怎么做到的。你会把那些时光称为 黑暗时代,在轮子的发现之前

TDD 的支持性论点

我们在这里想关注的是积极的方面,是支持 TDD 的论点。

减少了手动测试的劳动强度

我们已经提到,作为专业人士,我们不会在没有首先确定它是否工作的情况下发布任何东西。把东西扔给质量保证团队、用户或公众,并希望一切按预期工作,这并不是我们的业务方式。我们将验证我们的代码和应用程序是否按预期工作。在开始时,当应用程序规模小且功能有限时,我们可以手动测试我们能想到的一切。但是,随着应用程序规模和复杂性的增长,开发者或其他人手动测试整个应用程序是不切实际的。手动做这件事既耗时又昂贵。我们可以通过自动化测试来节省时间和我们的客户以及公司的金钱。我们可以从开始就通过 TDD 轻松地做到这一点。

减少错误数量

随着我们的应用程序增长,我们的测试也在增长。或者说,我们的测试套件已经增长,通过使我们的测试通过,我们的应用程序也在增长。随着两者的增长,我们已经覆盖了快乐路径(例如:2 + 2 = 4)以及潜在的失败(例如:2 + 香蕉 = 异常)。如果正在测试的方法或函数可以接受输入参数,那么存在失败的可能性。通过编写代码来防范这些场景,您可以减少意外行为、错误和异常的可能性。当您编写测试来表示潜在的失败时,您的生产代码将自然而然地变得更加健壮,并且更不容易出错。如果某个错误悄悄溜过并进入质量保证阶段,甚至进入生产环境,那么添加一个新的测试来覆盖新发现的缺陷是非常容易的。

以这种方式处理错误的附加好处是,相同的错误很少会在以后的某个时间再次出现,因为新的测试可以防止这种情况。如果相同的错误确实出现了,您知道,尽管发生了相同的结果,但错误是以新的和不同的方式发生的。通过添加另一个测试来覆盖这个新场景,这很可能是您最后一次看到这个老问题。

确保一定程度的正确性

通过一套全面的测试套件,您可以展示一定程度的正确性。在某个时候,某个地方的人会问您是否已经完成。您将如何展示您已经向应用程序添加了所需的功能?

消除了重构的恐惧

让我们面对现实,我们所有人都曾参与过我们不敢触碰的遗留应用程序的开发工作。想象一下,如果您负责修改的类被一套全面的单元测试所覆盖,那会多么容易。想象一下,做出改变后,知道一切如常,因为所有的单元测试仍然通过,这是多么简单。

更好的架构

编写单元测试往往会推动你的代码向解耦设计发展。紧密耦合的代码很快就会变得难以测试,因此,为了使生活更轻松,测试驱动开发者会开始解耦代码。解耦的代码更容易替换,这意味着,而不是修改一团糟的生产代码,开发者通常只需要替换一个子组件的新模块代码,就能做出必要的更改。

更快的开发

起初可能感觉不到(事实上,一开始肯定感觉不到),但编写单元测试是加快开发速度的绝佳方式。传统上,开发者从业务那里接收需求,坐下来,开始从指尖发射闪电,让代码倾泻而出,直到编写出一个可执行的应用程序。在 TDD 之前,开发者会写几分钟代码,然后启动应用程序,看看代码是否工作。当发现错误时,开发者会修复它,再次启动应用程序以检查修复是否有效。通常,开发者会发现她的修复破坏了其他东西,然后她必须追查她破坏了什么,并编写另一个修复。描述的过程可能是你以及世界上每个其他开发者都熟悉的过程。想象一下,你在进行开发者测试时发现并修复的 bug 浪费了多少时间。这还不包括由 QA 或客户在生产中发现的 bug。

现在,让我们想象另一个场景。在学习 TDD 之后,当我们从业务那里收到需求时,我们会迅速将这些需求直接转换为测试。随着每个测试通过,我们知道根据需求,我们的代码确实做了它被要求做的事情。我们可能会在过程中发现一些边缘情况,并创建测试来确保代码对每个边缘情况都有正确的行为。在测试通过后才发现测试失败的情况是很少见的。但是,当我们导致测试失败时,我们可以通过使用编辑器中的撤销命令快速修复它。这使我们几乎不需要运行应用程序,直到我们准备好将更改提交给 QA 和业务。尽管如此,我们仍然试图在提交之前验证应用程序的行为是否符合要求,但现在我们不是每几分钟手动做这件事。相反,每次你保存文件时,让你的单元测试验证你的代码。

不同类型的测试

在这本书的整个过程中,我们将倾向于一种特定的测试风格,但了解其他人将使用的术语很重要,这样你才能在他们谈论某种类型的测试时建立联系。

单元测试

让我们直接进入最被误用和最不被理解的测试类型。在 Kent Beck 的书籍《通过示例进行测试驱动开发》中,他定义单元测试为一个简单地在与其他测试隔离的情况下运行的测试。这意味着,为了使一个测试成为单元测试,唯一必须发生的事情是测试不能受到其他测试副作用的影响。一些常见的误解是,单元测试不能击中数据库,或者它不能使用被测试的方法或函数之外的代码。这些说法并不正确。我们在测试中通常在第三方交互上划线。任何你的测试将要访问你正在编写的应用程序之外代码的情况,你应该抽象出这种交互。我们这样做是为了在测试设计上获得最大的灵活性,并不是因为它不是一个单元测试。有些人认为单元测试是唯一应该编写的测试。这是基于原始定义,而不是基于术语的常见用法。

验收测试

直接受业务需求影响的测试,如 BDD 中建议的,通常被称为验收测试。这些测试位于应用程序的最外层,并测试了大量的代码。为了减少测试和产品代码之间的耦合,你可以几乎完全编写这种风格的测试。我们的观点是,如果结果不能在应用程序外部观察到,那么它作为测试的价值就不大。

集成测试

集成测试是与外部系统集成的测试。例如,与数据库交互的测试将被视为集成测试。外部系统不必是第三方产品;然而,有时外部系统只是独立于你正在开发的应用程序开发的库,但仍被视为内部软件。另一个大多数人不考虑的例子是与系统或语言框架的交互。你可以将任何使用 C#的DateTime对象功能的测试视为集成测试。

端到端测试

这些测试验证了应用程序的整个配置和使用情况。从用户界面开始,端到端测试将程序性地点击按钮或填写表单。UI 将调用应用程序的业务逻辑,一直执行到应用程序的数据源。这些测试的目的是确保所有外部系统都已配置并正常运行。

每种测试类型的数量

许多开发者都会问这样一个问题:每种类型的测试应该使用多少个?根据 Kent Beck 的定义,每个测试都应该是一个单元测试。我们将在稍后讨论测试的变体,这些变体会对每种类型的具体数量产生影响;但一般来说,你可能期望一个应用程序只有很少的端到端测试,稍微多一点集成测试,主要由验收测试组成。

单元测试的各个部分

要开始并确保你有可读性强的代码,最简单的方法是使用安排行动断言来结构化你的测试。

安排

也称为单元测试的上下文,安排包括作为测试先决条件的任何存在的事物。这包括从存储在变量中以改善可读性的参数值,到在测试运行时配置模拟数据库中的值以注入到你的应用程序中的所有内容。

关于模拟的更多信息,请参阅第三章,“设置 JavaScript 环境”,抽象第三方软件测试双倍类型部分。

行动

动作,作为单元测试的一部分,仅仅是正在被测试的生产代码的一部分。通常,这可能是你代码中的一个单独的方法或函数。每个测试应该只有一个动作。有多个动作会导致测试更加混乱,并且对代码应该更改以使测试通过的位置的确定性更少。

断言

结果,或断言(预期的结果),正如其名。如果你期望被测试的方法将返回 3,那么你将编写一个验证该期望的断言。单一断言规则指出,每个测试应该只有一个断言。这并不意味着你只能断言一次;相反,这意味着你的断言应该只确认一个逻辑期望。作为一个快速示例,你可能有一个方法,在应用过滤器后返回一个项目列表。在设置测试上下文后调用该方法将导致只包含一个项目的列表,并且该项目将匹配我们定义的过滤器。在这种情况下,你将有一个针对列表中项目数量的程序性断言,以及一个针对我们正在测试的过滤器准则的程序性断言。

需求

虽然这本书不是关于业务分析或需求生成的,但需求将对你的有效测试驱动应用程序的能力产生巨大影响。我们将以适合高质量测试的格式提供这本书的需求。我们还将涵盖一些需求不太理想的情况,但在这本书的大部分内容中,需求都经过仔细推敲,以确保我们测试的系统有高质量的定义。

为什么它们很重要?

我们坚信,高质量的需求对于良好的解决方案至关重要。需求通知测试,而测试塑造代码。这个公理意味着,如果需求不佳,应用程序将导致质量较低的架构和整体设计。如果需求杂乱无章,产生的测试和应用程序将混乱且因素不佳。从积极的一面来看,即使考虑不周或书写不佳的需求,也不会是代码的终结。作为专业的软件开发人员,我们有责任纠正不良需求。我们的任务是提出问题,这些问题将导致更好的需求。

用户故事

用户故事通常在敏捷软件开发中用于需求定义。用户故事的形式相对简单,包括三个部分:角色请求原因

As a <Role> 
 I want <Request> 
 So that <Reason> 

角色

用户故事的角色可以提供很多信息。在指定角色时,我们有能力暗示用户的能力。用户能否访问某些功能,或者他们是否因为身体残疾而需要以不同的方式与系统交互?我们还可以传达用户的心态。新用户可能会影响用户界面的设计,这与经验用户可能期望的相反。角色可以是通用用户、特定角色、角色或特定用户。

通用用户可能是最常用同时也是最不实用的。如果一个故事不能提供对用户的洞察,那么它通过不限制我们的上下文来限制我们对这个故事的决策。如果可能的话,请向你的业务分析师或产品负责人询问对需求对象更具体的定义。

定义一个特定的角色,例如管理员、用户或访客,可能非常有帮助。特定的角色提供了用户能力信息。有了特定的角色,我们可以确定用户是否应该被允许进入我们正在定义功能的应用程序部分。可能一个用户故事会导致用户在系统中的权限修改,仅仅因为我们指定了一个角色而不是一个通用的用户。

使用一个角色是广泛的角色类型中最具说明性的。角色是对一个虚构用户的完整定义。它包括一个名字,任何重要的生理特征,偏好,对应用程序主题的熟悉程度,对计算机的熟悉程度,以及可能影响虚构用户与软件交互的其他任何事物。通过拥有所有这些信息,我们可以开始模拟用户在系统中的行为。我们可以开始做出假设或决定,关于该用户会如何接近或对建议的功能有何感受,并且我们可以从这个用户的角度来设计用户界面。

请求

用户故事中的请求部分相对简单。我们应该有一个单独的功能或对请求的功能的小幅增加。通常,如果包括任何连接词,如andor,请求就太大了。

Reason

原因是陈述业务需求的地方。这是解释功能如何为公司增加价值的机会。通过将原因与角色联系起来,我们可以增强功能的有用性的影响。

一个完整的用户故事可能看起来如下:

As a Conference Speaker 
 I want to search for nearby conferences by open submission date 
 So that I may plan the submission of my talks 

Gherkin

Gherkin 是一种常用于验收标准的需求定义风格。我们可以直接将这些需求转换为代码,QA 可以直接将它们转换为测试用例。Gherkin 格式通常与 BDD 相关联,并在 Dan North 关于该主题的原始文章中使用。

Gherkin 格式与用户故事格式一样简单。它由三个部分组成:GivenWhenThen

Given <Context> 
 And Given <More Context> 
 When <Action> 
 Then <Result> 
 And Then <More Results> 

Givens

由于 Gherkin 格式相对简单,因此 givens 被拆分为每个上下文标准一个。在指定上下文的部分,我们希望看到这个场景的所有和任何先决条件。用户是否已登录?用户是否有任何特殊权限?这个场景是否需要在执行前设置任何设置?用户是否对这个场景提供了任何输入?还有一个需要考虑的问题是,应该只有少数几个 givens。

一个场景中存在的 givens 越多,这个场景太大或 givens 可以以某种方式逻辑分组以减少数量的可能性就越大。

当我们开始编写测试时,givens 相当于测试的 Arrange 部分。

When

when 是用户执行的动作。应该只有一个动作,只有一个动作。这个动作将取决于 Given 定义的上下文,并输出 Then 期望的结果。在我们的应用程序中,这相当于一个函数或方法调用。

当我们开始编写测试时,When 相当于测试的 Act 部分。

Then

Thens 等同于动作的输出。Thens 描述了可以从方法或函数的输出中验证和测试的内容,不仅限于开发者,还包括 QA。就像 givens 一样,我们希望我们的Thens在期望上是单一的。同样,如果我们发现太多的Thens,这可能是一个迹象,表明这个场景太大,或者我们过度指定了我们的期望。

当我们开始编写测试时,Then 相当于测试的 Assert 部分。

基于之前提出的用户故事的完整验收标准可能看起来如下:

Given I am a conference speaker 
 And Given a search radius of 25 miles 
 And Given an open submission start date 
 And Given an open submission end date 
 When I search for conferences 
 Then I receive only conferences within 25 miles of my location 
 And Then I receive only conferences that are open for submission within the specified date range 

就像在生活中一样,这本书中并不是所有内容都会完美。你看到前面验收标准中的任何问题吗?继续,花几分钟时间检查它;我们会等待。

如果你已经放弃了,我们会告诉你。上面的验收标准太长了。有太多的前提条件和太多的结果。这是怎么发生的?我们怎么能犯这样的错误呢?当我们编写用户故事时,我们无意中包含了太多信息,因为我们指定的原因。如果你回顾一下用户故事,你会看到我们无意中在请求中加入了“附近”。添加“附近”似乎无害;甚至似乎更正确。作为用户,我对为了我的演讲活动走得太远并不感兴趣。

当你开始看到用户故事或验收标准像这样失控时,你有责任与业务分析师或产品所有者交谈,并与他们合作以缩小需求范围。在这种情况下,我们可以提取两个用户故事和几个验收标准。

这里是我们一直在审查的要求的完整示例:

As a conference speaker 
 I want to search for nearby conferences 
 So that I may plan the submission of my talks 
Given I am a conference speaker 
 And Given search radius of five miles 
 When I search for conferences 
 Then I receive only conferences within five miles of my location 
Given I am a conference speaker 
 And Given search radius of 10 miles 
 When I search for conferences 
 Then I receive only conferences within 10 miles of my location 
Given I am a conference speaker 
 And Given search radius of 25 miles 
 When I search for conferences 
 Then I receive only conferences within 25 miles of my location 

As a conference speaker 
 I want to search for conferences by open submission date 
 So that I may plan the submission of my talks 
Given I am a conference speaker 
 And Given open submission start and end dates 
 When I search for conferences 
 Then I receive only conferences that are open for submission within the specified date range 
Given I am a conference speaker 
 And Given an open submission start date 
 And Given an empty open submission end date 
 When I search for conferences 
 Then an INVALID_DATE_RANGE error occurs for open submission date 
Given I am a conference speaker 
 And Given an empty open submission start date 
 And Given an open submission end date 
 When I search for conferences 
 Then an INVALID_DATE_RANGE error occurs for open submission date 

我们还没有讨论过用户故事和验收标准的内容方法。我们相信,需求应该尽可能不涉及用户界面和数据存储机制。因此,在需求示例中,你会发现没有任何关于按钮、表格、模态/弹出窗口、点击或输入的引用。据我们所知,这个应用程序可能正在虚拟现实头盔中运行,具有自然用户界面。同样,它也可能作为一个 RESTful Web API 运行,或者可能是一个手机应用程序。需求应该指定系统交互,而不是部署环境。

在软件开发中,确保高质量需求是每个人的责任。如果你发现你收到的需求太大、太模糊、依赖于用户界面或只是无用的,你有责任与你的业务分析师或产品所有者合作,使需求更好,并准备好开发和质量保证。

我们的第一组 C#测试

你是否曾在 Visual Studio 中创建过新的 MVC 项目?你注意到对话框底部的复选框了吗?你是否曾经选择过“创建单元测试项目”?使用此单元测试项目创建的测试在很大程度上几乎无用。它们所做的只是验证默认的 MVC 控制器返回正确的类型。这也许比“存在”多了一步。让我们看看为我们创建的第一组测试:

using System.Web.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SampleApplication.Controllers;

namespace SampleApplication.Tests.Controllers
{
  [TestClass]
  public class HomeControllerTest
  {
    [TestMethod]
    public void Index()
    {
      // Arrange
      HomeController controller = new HomeController();

      // Act
      ViewResult result = controller.Index() as ViewResult;

      // Assert
      Assert.IsNotNull(result);
    }

    [TestMethod]
    public void About()
    {
      // Arrange
      HomeController controller = new HomeController();

      // Act
      ViewResult result = controller.About() as ViewResult;

      // Assert
      Assert.AreEqual("Your application…", result.ViewBag.Message);
    }

    [TestMethod]
    public void Contact()
    {
      // Arrange
      HomeController controller = new HomeController();

      // Act
      ViewResult result = controller.Contact() as ViewResult;

      // Assert
      Assert.IsNotNull(result);
    }
  }
}

在这里,我们可以看到测试类的基本结构,以及其中包含的测试用例。Visual Studio 默认提供 MSTest,这就是我们在这里看到的内容。测试类必须用[TestClass]属性装饰。单个测试同样也必须用[TestMethod]属性装饰。这允许测试运行程序确定要执行哪些测试。我们将在未来的章节中介绍这些属性以及更多内容。其他测试框架使用类似的方法,我们将在稍后讨论。

目前,我们可以看到 HomeController 正在被测试。每个公共方法都有一个单独的测试,你可能想要创建额外的测试,或者将来将测试提取到单独的文件中。稍后我们将介绍选项和最佳实践,帮助你以更可管理的方式组织文件。所有这些都应该是你 red, green, refactor 循环中的 refactor 步骤的一部分。

使用测试增长应用程序

也许你想要接受你的端点之一的一个参数。也许你会接受访客的名字来显示友好的问候。让我们看看我们如何实现这一点:

[TestMethod]
public void ItTakesOptionalName()
{
  // Arrange
  HomeController controller = new HomeController();

  // Act
  ViewResult result = controller.About("") as ViewResult;

  // Assert
  Assert.AreEqual("Your application description page.", result.ViewBag.Message);
}

我们首先创建一个测试,允许 About 方法接受一个可选的字符串参数。我们开始的想法是这个参数是可选的,因为我们不希望破坏任何现有的测试。让我们看看修改后的方法:

public ActionResult About(string name = default(string))
{
  ViewBag.Message = "Your application description page.";
  return View();
}    

现在,让我们使用 name 参数并将其附加到我们的 ViewBag.Message 上。等等,不是控制器。我们需要先添加一个新的测试:

[TestMethod]
public void ItReturnsNameInMessage()
{
  // Arrange
  HomeController controller = new HomeController();

  // Act
  ViewResult result = controller.About("Fred") as ViewResult;

  // Assert
  Assert.AreEqual("Your application description page.Fred", result.ViewBag.Message); 
}

现在我们将使这个测试通过:

public ActionResult About(string name = default(string))
{
  ViewBag.Message = $"Your application description page.{name}";
  return View();
}

我们在 JavaScript 中的第一个测试

为了在 JavaScript 中启动,我们将编写一个 Simple Calculator 类。我们的计算器只有一个要求,即添加或减去一组数字。你用 TDD 编写的许多代码都将从非常简单开始,就像这个例子一样:

import { expect } from 'chai'

class SimpleCalc {
  add(a, b) {
    return a + b;
  }

  subtract(a, b) {
    return a - b;
  }
}

describe('Simple Calculator', () => {
  "use strict";

  it('exists', () => {
    // arrange
    // act
    // assert
    expect(SimpleCalc).to.exist;
  });

  describe('add function', () => {
    it('exists', () => {
      // arrange
      let calc;

      // act
      calc = new SimpleCalc();

      // assert
      expect(calc.add).to.exist;
    });

    it('adds two numbers', () => {
      // arrange
      let calc = new SimpleCalc();

      // act
      let result = calc.add(1, 2);

      // assert
      expect(result).to.equal(3);
    });
  });

  describe('subtract function', () => {
    it('exists', () => {
      // arrange
      let calc;

      // act
      calc = new SimpleCalc();

      // assert
      expect(calc.subtract).to.exist;
    });

    it('subtracts two numbers', () => {
      // arrange
      let calc = new SimpleCalc();

      // act
      let result = calc.subtract(3, 2);

      // assert
      expect(result).to.equal(1);
    });
  });
});

如果前面的代码现在看起来不太明白,不要担心;这只是一个快速示例,展示一些正在工作的测试代码。这里使用的测试框架是 Mocha,使用的断言库是 chai。在 JavaScript 社区中,大多数测试框架都是基于行为驱动开发(BDD)构建的。上面代码示例中描述的每个都代表一个场景或更高层次的抽象需求;而每个 it 都代表一个特定的测试。在测试中,唯一必需的元素是 expect,没有它测试将不会产生有价值的结果。

继续这个例子,假设我们收到一个需求,即加法和减法方法必须允许链式调用。我们将如何应对这个需求?有很多方法,但在这个情况下,我认为我想要快速重新设计,然后添加一些新的测试。首先,我们将进行重新设计,再次由测试驱动。

通过在 describetest 上放置 only,我们可以隔离那个 describe/test。在这种情况下,我们想要隔离我们的 add 测试,并从这里开始做出更改:

it.only('adds two numbers', () => {
  // arrange
  let calc = new SimpleCalc(1);

  // act
  let result = calc.add(2).result;

  // assert
  expect(result).to.equal(3);
});

之前,我们已经将测试更改为使用一个接受数字的构造函数。我们还减少了 add 函数的参数数量到一个。最后,我们添加了一个必须用于评估加法结果的结果值。

测试将失败,因为它没有使用与类相同的接口,所以现在我们必须对类进行更改:

class SimpleCalc {
  constructor(value) {
    this._startingPoint = value || 0;
  }

  add(value) {
    return new SimpleCalc(this._startingPoint + value);
  }
  ... 
  get result() {
    return this._startingPoint;
  }
}

这个更改应该会使我们的测试通过。现在,是时候为 subtract 方法做出类似的变化了。首先,移除上一个例子中放置的 only

it('subtracts two numbers', () => {
  // arrange
  let calc = new SimpleCalc(3);

  // act
  let result = calc.subtract(2).result;

  // assert
  expect(result).to.equal(1);
});

现在是类中适当的更改:

subtract(value) {
  return new SimpleCalc(this._startingPoint – value);
}

我们现在的测试又通过了。接下来我们应该做的是创建一个测试来验证一切是否正常工作。如果你想要尝试,我们将把这个测试留给你作为练习。

为什么这很重要?

那么,这一切为什么很重要?为什么我们要编写比必需的更多的代码?因为它值得。说实话,大多数时候这并不是更多的代码。当你花时间用测试来扩展你的应用时,会产生简单的解决方案。简单的解决方案通常比你可能想出的光滑解决方案要少很多代码。而且不可避免的是,光滑的解决方案容易出错,难以维护,并且往往只是完全错误。

摘要

如果你之前没有,你现在应该对 TDD 是什么以及为什么它很重要有一个很好的了解。你已经接触到了 C#和 JavaScript 中的单元测试,以及编写测试如何有助于应用的增长。

随着我们继续前进,我们将更多地了解 TDD。我们将探讨编写可测试代码的含义。

在第二章 设置.NET 测试环境 中,我们将设置你的开发环境并探索单元测试的更多方面。

第二章:设置 .NET 测试环境

在本章中,我们将探讨设置你的开发环境。我们将涵盖 C# 和 .NET。在下一章中,我们将专注于设置 JavaScript 和 React 环境。我们将从经典的 FizzBuzz 代码练习开始,然后深入到 Speaker Meet 网站的实际示例。

在本章中,你将了解以下内容:

  • 安装你的 IDE

  • 如何设置你的测试框架

  • 在 C# 中编写你的第一个测试

安装 .NET Core SDK

在开始开发环境之前,你需要安装 .NET Core SDK。你需要导航到微软网站上的 .NET Core 下载页面(www.microsoft.com/net/download/core)。选择适合你系统的正确安装程序。对于 Windows 计算机,推荐下载 .exe 文件。

按照安装向导的屏幕说明安装 .NET Core SDK。

使用 VS Code 设置环境

选择 VS Code 作为你的开发工具的一个好处是,它是一个优秀的 IDE,适用于 .NET 和 JavaScript。要开始使用 VS Code,你必须首先下载这个 IDE。

下载 IDE

访问 VS Code 网站(code.visualstudio.com/)并选择适合你操作系统的版本:

安装 VS Code

按照向导中的说明安装 VS Code:

一定要阅读并接受许可协议:

在你的硬盘上选择一个位置来安装 VS Code。默认路径通常是可接受的:

选择为应用程序创建开始菜单文件夹,选择位置,或者选择不创建开始菜单文件夹:

选择额外的任务。默认设置应该适合我们的目的,如以下截图所示:

检查你的安装设置并点击安装:

安装完成后,你可以启动应用程序:

添加扩展

VS Code 是一个相对轻量级且基础简单的 IDE。要开始使用,你需要安装 C#。当你第一次启动 VS Code 时,你的浏览器应该会打开 VS Code 网站上的入门页面。如果没有,现在就去那里(code.visualstudio.com/docs)。

你可以从市场安装各种有用的扩展。目前,你只需要 C#。在撰写本文时,C# 列在顶级扩展列表的顶部。点击 C# 磁贴(或通过市场搜索)了解更多关于这个扩展的信息。

你应该会看到安装说明会指导你启动 VS Code 快速打开(Ctrl-P)并粘贴以下命令:

ext install csharp

在 VS Code 内部,将命令粘贴到快速打开部分并按Enter键。找到由 OmniSharp 提供的 C#版本并选择安装。一旦 C#扩展被安装,你需要重新加载 VS Code 以激活 C#扩展(选择重新加载)。

在 VS Code 中创建项目

现在你的 VS Code IDE 已经正确安装,并且 C#扩展已启用,你就可以创建你的第一个项目了。

在 VS Code 打开的情况下,从文件菜单中选择“打开文件夹”。选择一个易于访问的位置。许多开发者会在驱动器的根目录下创建一个Development文件夹。无论你习惯使用什么约定,都可以。你现在需要创建一个MSTest项目。

创建一个名为Sample的新文件夹。从视图菜单或使用快捷键(Ctrl + *)打开集成终端窗口。在终端窗口内部,输入dotnet new mstest并按*Enter*键。现在,你需要通过在终端窗口中输入dotnet restore`并按Enter*键来恢复你的包。

现在,你应该在Sample文件夹中看到一个名为UnitTest1.cs的文件。如果你打开该文件,它应该看起来像这样:

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Sample
{
  [TestClass]
  public class UnitTest1
  {
    [TestMethod]
    public void TestMethod1()
    {
    }
  }
}

将第一个测试方法更改为ItExists测试。通过将名称更改为ItExists并尝试声明一个尚未存在的类的实例来完成此操作:

var sampleClass = new SampleClass();

你应该会看到你的示例应用程序无法编译,并且你已经收到了错误消息:“找不到类型或命名空间'SampleClass'(你是否遗漏了 using 指令或程序集引用?)”

现在你已经有一个测试失败(记住,编译失败在这个例子中也被视为失败的测试),可以安全地继续到我们的红、绿、重构循环中的绿步骤。通过为SampleClas*s*创建一个定义来使测试通过。你可以自由地将类创建在包含你的单元测试的同一文件中,以便开始。这始终可以在稍后提取并移动到更合适的位置:

public class SampleClass
{
}

现在你已经做了更改,运行test命令dotnet test并查看结果:

Total tests: 1\. Passed: 1\. Failed: 0\. Skipped: 0.

继续探索 VS Code 并通过测试扩展你的新类。本书其余部分的所有 C#和.NET 示例都将使用 Visual Studio Community。如果你愿意,你也可以选择继续使用 VS Code。

设置 Visual Studio Community

大多数 C#和.NET 开发者都会熟悉 Visual Studio。它有多种版本可供选择,从免费版到每年数千美元不等。截至本文撰写时,企业版是最全面的功能版本,提供了测试和测试的一些最佳功能。为了我们的目的,我们将使用 Visual Studio Community。这是一个免费且功能齐全的开发环境,应该非常适合我们。

社区版确实有一些非常重要的注意事项。根据许可协议的条款,软件许可和社区版的用途存在一些限制。请在决定使用 Visual Studio 社区版开发您打算出售的软件之前,务必阅读这些条款。当前条款可以在www.visualstudio.com/license-terms/mlt553321/找到。

下载 Visual Studio Community

要开始使用,请下载 Visual Studio Community (www.visualstudio.com/downloads/)。您可以在那里自由探索和比较不同版本的 Visual Studio:

安装 Visual Studio Community

安装 Visual Studio Community 的向导与 VS Code 的安装向导略有不同。当然,要开始使用,您需要阅读并同意许可协议。

至少,如果您打算按照本书的内容进行,您将想要选择 ASP.NET 和 Web 开发以及 .NET Core 跨平台开发。我们还在右侧面板或“单个组件”标签*中选择了 ASP.NET MVC 4、.NET Framework 4.6.2 开发工具和 .NET Framework 4.7 开发工具。您可能还想探索其他组件和/或语言包。

切换到 xUnit

MSTest 早已与 Visual Studio 一起发货。在 C# 和 .NET 的测试框架方面,还有一些其他选择。许多这些框架具有功能一致性,只是在属性、断言和异常处理的选择上略有不同。在测试框架的顶级竞争者中,xUnit 是其中之一。许多开发者实际上更喜欢它,并会争论说它功能更丰富,社区支持更强。不论争论如何,从现在开始,我们将使用 xUnit 进行我们的 C# 和 .NET 测试。

如果您更喜欢 MSTest,请随意。只需知道您需要考虑语义差异(例如 TestMethodFact)和功能上的细微差异。

代码 kata

什么是代码 kata?代码 kata 仅仅是可重复的练习。通常,这些练习的完成时间不超过 20 分钟。大多数代码 kata 都针对解决特定分类的问题。我们将利用经典的 FizzBuzz 示例,作为让您更熟悉使用 xUnit 进行 TDD 的方式。

FizzBuzz

FizzBuzz 的规则相当简单。如果提供的数字能被 3 整除,那么您必须返回 Fizz。如果提供的数字能被 5 整除,那么您必须返回 Buzz。如果数字能同时被 3 和 5 整除,那么您必须返回 FizzBuzz。如果它既不能被 3 也不能被 5 整除,那么只需返回该数字本身。

解决这个问题的选项有很多。几乎在每种编程语言中都可以以不同的方式解决。这里重要的是练习简单有效地解决问题的技巧。

让我们开始吧。

创建测试项目

在 Visual Studio Community 中,通过选择文件菜单中的新建 | 项目或使用快捷键 (Ctrl - Shift - N) 来创建一个 xUnit 测试项目。在 .NET Core 下,选择 xUnit 测试项目。给你的项目命名为 CodeKata 并点击确定。你会看到一个名为 UnitTest1.cs 的文件名。这个文件可以让你开始。让我们创建我们的第一个测试。

Given3ThenFizz 测试

UnitTest1.cs 文件中的第一个测试方法是 Test1。让我们将这个方法的名称更改为 Given3ThenFizz 并编写我们的第一个测试:

[Fact]
public void Given3ThenFizz()
{
  // Arrange
  // Act
  var result = FizzBuzz(3);

  // Assert 
  Assert.Equal("Fizz", result);
}

注意,Fact 属性和 Assert.Equal 断言与我们之前 MSTest 的例子只有细微的差别。我们保留 Arrange ActAssert 注释,并建议你也这样做。这些注释将帮助你开始,同时也会帮助未来的开发者理解这个过程。

现在,通过选择测试菜单中的运行 | 所有测试,或使用快捷键 (Ctrl + R, A) 来运行测试,看看它是否通过。你应该看到一个编译错误。让我们通过在 test 类之前创建一个 FizzBuzz 方法来解决这个错误。一旦你创建了 FizzBuzz 方法,重新运行你的测试以查看它是否通过。记住,根据 TDD 的第三定律,你应该只编写足够多的代码来让它通过:

private object FizzBuzz(int value)
{
  return "Fizz";
}

Given5ThenBuzz 测试

我们接下来的要求是,当提供 5 时必须返回 Buzz。让我们写这个测试:

[Fact]
public void Given5ThenBuzz()
{
  // Arrange
  // Act
  var result = FizzBuzz(5);

  // Assert
  Assert.Equal("Buzz", result);
}

我们如何让这个测试通过?或许是一个简单的三元运算符?让我们看看它可能的样子:

private object FizzBuzz(int value)
{
  return value == 3 ? "Fizz" : "Buzz";
}

你可能已经看到了我们算法的问题。没关系!我们还没有完成。我们只走到了测试引导我们的地方,到目前为止,我们通过了所有的测试。让我们继续到下一个最有趣的测试。

Given15ThenFizzBuzz 测试

你可能想写一个名为 GivenDivisibleBy3and5ThenFizzBuzz 的测试方法,但在这个阶段这可能是一个太大的跳跃。我们知道第一个能被 3 和 5 整除的数是 15,所以从这一点开始可能更有意义:

[Fact]
public void Given15ThenFizzBuzz()
{
  // Arrange  
  // Act
  var result = FizzBuzz(15);

  // Assert
  Assert.Equal("FizzBuzz", result);
}

你会如何选择让这个测试通过?你会使用一个 if/else 语句吗?或许是一个 switch 语句?我们将把这个留作读者的练习。请随意以你舒适的方式实现这个测试通过。记住,在过程中运行你的测试以确保你没有引入破坏性的更改。如果你确实遇到了测试失败,请随意忽略一个测试(MSTest 中的 Ignore 属性,xUnit 中的 Skip 参数),但只忽略一个测试,同时你修复错误。

Given1Then1 测试

我们已经涵盖了Fizz。我们已经涵盖了Buzz。而且,我们已经涵盖了FizzBuzz。现在我们必须考虑既不能被 3 也不能被 5 整除的数字。记住,如果数字既不能被 3 也不能被 5 整除,我们只需返回提供的数字。让我们看看这个测试:

[Fact]
public void Given1Then1()
{
  // Arrange
  // Act 
  var result = FizzBuzz(1);

  // Assert 
  Assert.Equal(1, result);
}

理论

这太棒了!一切都在顺利进行。希望你现在开始逐渐掌握测试驱动开发。现在,让我们看看使用TheoryInlineData属性的一个稍微高级一点的测试方法。

回顾我们的测试,我们看到我们有一个名为Given15ThenFizzBuzztest方法。虽然这很好,但它太具体了。记住,我们的要求是,如果数字能被 3 和 5 整除,那么我们应该返回FizzBuzz。让我们通过编写一个新的测试来确保我们没有在逻辑上迈出太大的步子。这次,我们将提供一系列值,期望得到相同的结果:

[Theory]
[InlineData(0)]
[InlineData(15)]
[InlineData(30)]
[InlineData(45)]
public void GivenDivisibleBy3And5ThenFizzBuzz(int number)
{
  // Arrange
  // Act
  var result = FizzBuzz(number);

  // Assert
  Assert.Equal("FizzBuzz", result);
}

当你运行测试套件时,你现在应该会看到四个新的通过测试结果。如果你确实遇到了失败,测试资源管理器窗口中的结果面板应该会提供关于哪个测试失败的详细解释。

现在,通过创建两个使用TheoriesInlineData的更多测试用例,对FizzBuzz做同样的事情。继续添加GivenDivisibleBy3ThenFizzGivenDivisibleBy5ThenBuzzGivenNotDivisibleBy3or5ThenNumber。确保在添加每个测试和InlineData值后运行你的测试套件,并在过程中修复任何失败。

FizzBuzz 问题的解决方案

我们想出来的东西看起来像这样:

private object FizzBuzz(int value)
{
  if (value % 15 == 0)
    return "FizzBuzz";

  if (value % 5 == 0)
    return "Buzz";

  if (value % 3 == 0)
    return "Fizz";

  return value;
}

如果您选择用不同的方式解决这个问题,请不要担心。重要的是,您在这个练习中获得了知识和理解。此外,您现在有一套全面的测试,并且您对重构和/或添加功能感到舒适。

什么是演讲者见面?

我们正在使用演讲者见面应用程序作为测试驱动开发的案例研究。演讲者见面是一个致力于连接技术演讲者、用户组和会议的网站。任何帮助组织用户组或技术会议的人都知道,通常很难找到演讲者。而对于技术演讲者来说,在您的直接区域外协调演讲活动通常也很困难。演讲者见面帮助将技术演讲者和社区聚集在一起。

在撰写本文时,应用程序仍在开发中,但它是一个探索测试驱动开发概念和原则的绝佳平台,这些概念和原则与实际应用相关。演讲者见面会由.NET 中的 RESTful API 和一个使用 React 库的单页应用程序SPA)组成。

Web API 项目

在我们的第一个练习中,我们将创建一个新的 API 端点。这个新的端点将根据提供的搜索词返回一个演讲者列表。我们将在后面的章节中利用这个端点在 React 示例中。

列出演讲者(API)

通过访问后端 API,数据库将返回一个演讲者列表。在开始编写代码之前,必须首先确定一组要求。如果在定义功能之前没有达成一致,很难知道从哪里开始。

要求

下面是可能从业务分析师或产品负责人那里收到的需求,这些通常是更广泛对话的好起点。如果某些事情不清楚,最好在开始之前解决任何歧义。

As a conference organizer 
 I want to search for available speakers 
 So that I may contact them about my conference 

Given I am a conference organizer 
 And Given a speaker in mind 
 When I search for speakers by name 
 Then I receive speakers with a matching first name 

Given I am a conference organizer 
 And Given a speaker in mind 
 When I search for speakers by name 
 Then I receive speakers with a matching last name 

在与我们的产品负责人交谈后,我们确定,根据 匹配 的要求,真正期望的是一种 以...开头 的匹配。如果会议组织者搜索字符串 "Jos",搜索程序应该返回 JoshJoshuaJoseph 的结果。

一个新的测试文件

我们将首先创建一个新的测试文件。让我们把这个文件命名为 SpeakerControllerSearchTests.cs。现在,创建第一个测试,ItExists

[Fact]
public void ItExists()
{
  var controller = new SpeakerController();
}

为了使这个程序编译,你需要创建一个名为 SpeakerMeetController 的 Web API 控制器。在你的解决方案中添加一个新的 ASP.NET Core Web 应用程序项目。给你的项目命名为 SpeakerMeet.API 并选择 Web API 模板以开始。从你的测试项目中添加对这个项目的引用,并添加适当的 using 语句。

现在,让我们确保有一个可用的 Search 端点。让我们创建另一个测试:

[Fact]
public void ItHasSearch()
{
  // Arrange
  var controller = new SpeakerController();

  // Act
  controller.Search("Jos");
}

通过创建一个接受字符串的 Search 方法来使这个测试通过。

让我们确认 Search 动作结果返回一个 OkObjectResult

[Fact]
public void ItReturnsOkObjectResult()
{
  // Arrange
  var controller = new SpeakerController();

  // Act
  var result = controller.Search("Jos");

  // Assert
  Assert.NotNull(result);
  Assert.IsType<OkObjectResult>(result);
}

注意到多个 Asserts。虽然我们希望将测试限制在单个 Act 上,但有时拥有多个 Asserts 是可以接受的,甚至是必要的。

一旦 ItReturnsOkObjectResult 测试通过,你应该删除 ItExistsItHasSearch 测试。记住,我们希望完成 红、绿、重构 循环,并保持我们的代码整洁。这包括测试套件,所以如果你有不再有效或没有价值的测试,那么你应该感到自由地移除它们。你不想维护比所需更多的代码。这将帮助你的测试套件保持相关性并运行得很好。

现在,让我们测试结果是否是一个演讲者集合:

[Fact]
public void ItReturnsCollectionOfSpeakers()
{
  // Arrange
  var controller = new SpeakerController();

  // Act
  var result = controller.Search("Jos") as OkObjectResult;

  // Assert
  Assert.NotNull(result);
  Assert.NotNull(result.Value); 
  Assert.IsType<List<Speaker>>(result.Value);
}

我们在这里开始有点重复了。现在是重构我们的测试以使其更干净的好时机。让我们从构造函数中提取 SpeakerController 的创建并初始化这个值。确保在你的测试中移除创建操作并使用这个新的实例:

private readonly SpeakerController _controller;

public SpeakerControllerSearchTests()
{
  _controller = new SpeakerController();
}

最后,我们准备好开始测试结果值的测试。让我们写一个名为 GivenExactMatchThenOneSpeakerInCollection 的测试:

[Fact]
public void GivenExactMatchThenOneSpeakerInCollection()
{
  // Arrange
  // Act
  var result = _controller.Search("Joshua") as OkObjectResult;

  // Assert
  var speakers = ((IEnumerable<Speaker>)result.Value).ToList();
  Assert.Equal(1, speakers.Count);
}

为了使这个测试工作,我们需要硬编码一些数据。别担心,我们正在逐步构建这个应用程序。硬编码的数据将在稍后删除:

[Fact]
public void GivenExactMatchThenOneSpeakerInCollection()
{
  // Arrange
  // Act
  var result = _controller.Search("Joshua") as OkObjectResult;

  // Assert  
  var speakers = ((IEnumerable<Speaker>)result.Value).ToList();
  Assert.Equal(1, speakers.Count);
  Assert.Equal("Joshua", speakers[0].Name);
}

确保我们的搜索字符串不区分大小写:

[Theory]
[InlineData("Joshua")]
[InlineData("joshua")]
[InlineData("JoShUa")]
public void GivenCaseInsensitveMatchThenSpeakerInCollection (string searchString)
{
  // Arrange
  // Act
  var result = _controller.Search(searchString) as OkObjectResult;

  // Assert
  var speakers = ((IEnumerable<Speaker>)result.Value).ToList();
  Assert.Equal(1, speakers.Count);
  Assert.Equal("Joshua", speakers[0].Name);
}

接下来,我们需要测试以验证,如果提供的字符串与我们的数据不匹配,则返回一个空集合:

[Fact]
public void GivenNoMatchThenEmptyCollection()
{
  // Arrange
  // Act
  var result = _controller.Search("ZZZ") as OkObjectResult;

  // Assert
  var speakers = ((IEnumerable<Speaker>)result.Value).ToList();
  Assert.Equal(0, speakers.Count);
}

最后,我们将测试任何以我们的搜索字符串开头的演讲者都会被返回:

[Fact]
public void Given3MatchThenCollectionWith3Speakers()
{
  // Arrange
  // Act 
  var result = _controller.Search("jos") as OkObjectResult;

  // Assert  
  var speakers = ((IEnumerable<Speaker>)result.Value).ToList();
  Assert.Equal(3, speakers.Count);
  Assert.True(speakers.Any(s => s.Name == "Josh"));
  Assert.True(speakers.Any(s => s.Name == "Joshua"));
  Assert.True(speakers.Any(s => s.Name == "Joseph"));
}

下面是我们编写出的代码的样子。你的实现可能会有所不同:

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;

namespace SpeakerMeet.Api.Controllers
{
  [Route("api/[controller]")]
  public class SpeakerController : Controller
  {
    [Route("search")]
    public IActionResult Search(string searchString)
    {
      var hardCodedSpeakers = new List<Speaker>
      {
        new Speaker{Name = "Josh"},
        new Speaker{Name = "Joshua"},
        new Speaker{Name = "Joseph"},
        new Speaker{Name = "Bill"},
      };

      var speakers = hardCodedSpeakers.Where(x => x.Name.StartsWith(searchString, StringComparison.OrdinalIgnoreCase)).ToList();

      return Ok(speakers);
    }
  }

  public class Speaker
  {
    public string Name { get; set; }
  }
}

摘要

现在,你应该对你的 .NET 开发环境感到非常熟悉。.NET Core SDK 应该已经安装,并且你的 IDE 已经配置好了。你已经接触过 Visual Studio 和 VS Code 中的单元测试和持续测试运行器。

在第三章,“设置 JavaScript 环境”中,我们将专注于设置我们的 JavaScript 环境。

第三章:设置 JavaScript 环境

在本章中,我们将通过纯 JavaScript 和 React 的示例来探索设置你的 JavaScript 开发环境。

在本章中,你将了解:

  • 安装你的 IDE

  • 如何设置你的测试框架

  • 在 JavaScript 中编写你的第一个测试

Node.js

Node.js,通常称为 Node,实际上对于现代 Web 应用程序开发是必需的。在本节中,我们将讨论 Node 究竟是什么,提供你需要 Node 的原因,并最终讨论你可以在哪里找到 Node 安装说明。

如果你已经熟悉这些主题,那么请随意跳转到下一节,在那里我们将以类似的方式讨论 NPM。

什么是 Node?

Node 是在 2009 年底由 Ryan Dahl 创建的。基于 Chrome 的 V8 引擎,Node 提供了一个为提供事件驱动的、非阻塞的 I/O(输入/输出)而构建的 JavaScript 运行时,用于服务 Web 应用程序。

当时,Chrome 已经创建了最快的 JavaScript 引擎。同时,他们决定开源其代码。出于这两个极其有说服力的原因,Node 决定使用 V8 引擎。

Ryan Dahl 对当时非常流行的 Apache HTTP 服务器的性能感到不满。Apache 处理并发连接的方式中存在的问题之一是它为每个连接创建了一个新线程。在这些线程之间创建任务和任务切换都是 CPU 和内存密集型的。出于这些原因,Dahl 决定用事件循环和回调模式编写 Node,而不是使用线程进行并发连接。

我们为什么需要 Node?

要在现代 Web 应用程序中进行 TDD(测试驱动开发)JavaScript,我们绝对需要 Node。在编写现代 Web 应用程序时,你很可能正在使用以下流行的框架之一:ReactJS、Angular、Ember.js、Vue.js 或 Polymer。这些应用程序中的大多数都需要在 Node 中进行编译步骤。

使用 Node 的另一个原因是我们要利用 JavaScript 中的新特性。Node 本身不支持这些特性,但已经编写了库,可以将 JavaScript 的新版本(ECMAScript 2015+)转换为你的目标浏览器所支持的 JavaScript 版本。

最后,为了这本书的目的,我们需要 Node 来运行我们的测试。稍后,我们将讨论我们如何在编写代码的同时持续运行我们的测试。这被称为持续测试,对于快速开发是必不可少的。

安装 Node

在你的机器上安装 Node 有多种选择。我们将涵盖手动安装和从软件包管理仓库安装。

使用软件包管理仓库的好处有很多。你想要这样安装的主要原因可能是版本管理的便利。Node 经常更新版本,使用软件包管理器可以帮助你通知可用的更新。它还可以帮助以简单高效的方式安装这些更新。我们将从手动安装开始,然后是使用 Linux 软件包管理器、Mac OSX 软件包管理器,最后是 Windows 软件包管理器安装。

要手动安装 Node,打开你喜欢的浏览器并访问 nodejs.org。你应该会看到以下截图类似的内容。无论你的操作系统是什么,Node 网站都会有当前和长期支持LTS)版本的 Node 安装文件的下载链接。对于 Windows 和 Mac,Node 网站提供安装程序。对于 Linux,Node 提供二进制文件和源代码。假设你熟悉你的操作系统,安装过程相当直接,不应该有任何问题。

软件包管理器极大地简化了许多应用程序的安装。如果你对软件包管理器不熟悉,它们基于有一个应用程序和工具仓库的概念,这些应用程序和工具可用于在软件包管理器针对的系统上安装。现在几乎每个可用的系统都有相应的软件包管理器。Linux 有许多发行版的不同的软件包管理器。Mac 使用名为 Homebrew 的系统,而 Windows 有一个名为 Chocolatey 的软件包管理器。

Linux

首先,我们将介绍如何使用 Ubuntu 软件包管理器 apt,因为 Ubuntu 是最受欢迎的 Linux 系统之一。如果你使用的是不同的发行版,过程应该非常相似。唯一的区别是软件包管理器的名称。打开终端窗口并输入以下命令来为 Ubuntu 安装 Node:

$ sudo apt-get update
$ sudo apt-get install nodejs

这很简单;现在最新的 Node 版本已经安装好了,你可以开始使用了。这些相同的命令会在有新版本可用时更新 Node。

Mac OSX

Mac 默认没有预装软件包管理器。要安装 Homebrew,你必须打开终端并执行以下命令:

ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

现在你已经安装了 Homebrew,你还需要满足另一个要求。你必须安装苹果的 Xcode,你可以在 App Store 中搜索到它。就像 Mac 上的任何其他应用程序一样,一旦找到它,只需点击安装应用程序按钮,Xcode 就会下载并安装:

现在我们已经在系统上安装了 Node 的所有先决条件,安装过程非常简单。从终端窗口,执行以下命令:

$ brew install node

更新 Node 同样很简单。偶尔,当你想要更新时,执行以下命令:

$ brew update
$ brew upgrade node

现在,你的 Mac 上已经安装了最新版本的 Node。

Windows

Windows 也有包管理器。就像 Mac 一样,Windows 的包管理器不是预安装的。Windows 的包管理器名为 Chocolatey,可以在 chocolatey.org 找到:

要安装 Chocolatey,以管理员身份打开命令提示符 (cmd.exe) 并执行以下命令:

@"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin"

在 Chocolatey 安装完成后,您可能需要在能够使用它之前重启命令窗口。以管理员身份重启命令提示符,然后执行以下命令,使用 Chocolatey 包管理器在 Windows 上安装 Node:

C:\>choco install nodejs
C:\>refreshenv

执行第一个命令后,您将被提示执行一个脚本。您需要同意运行脚本以安装 Node。

要使用 Chocolatey 升级 Node,执行以下命令:

C:\>choco upgrade nodejs

您可能需要同意运行安装脚本。如果提示,只需按下 Y 键并按 Enter。现在您已经拥有了最新的 Node 版本。

NPM

NPM 是 Node 环境和社区的一个关键部分。没有 NPM,Node 就不会像现在这样蓬勃发展。在本节中,我们将讨论 NPM 是什么,NPM 不是什么,为什么您需要 NPM 来进行 Node 开发,以及最后,您可以从哪里获取 NPM 以及如何安装它。

什么是 NPM?

NPMNode Package Manager)最初于 2010 年初发布。NPM 由 Isaac Z. Schlueter 编写,现在由一个开发团队维护。尽管 NPM 的缩写中有 Node,但它也可以用来管理浏览器包。

在过去几年中,许多包管理器是专门为浏览器包创建的。其中最突出的是 Bower。这些二级包管理器之所以被创建,是因为人们认为 NPM 是最适合,或者可能仅适合管理 Node 包的包管理器。这种看法已经减弱;然而,Bower 的官方网站现在建议不要使用它。

我们为什么需要 NPM?

虽然您需要安装 NPM,但您不一定必须使用它。在 2016 年晚些时候,Facebook 发布了一个名为 Yarn 的替代包管理器,它使用 NPM 注册表,因此您可以使用所有喜欢的包。

很可能还有其他 NPM 的包管理替代方案。这些替代包管理器很重要,因为它们推动了 NPM 的改进,但最终,它们可能会逐渐消失,NPM 仍将是 Node 以及 JavaScript 的一般首选包管理器。如果您决定使用 Yarn 等替代包管理器,您将需要使用 NPM 来安装它。

如何安装 NPM?

好消息;如果您已经完成了 Node 的安装过程,那么您已经安装了 NPM。您可能偶尔想要在 Node 的发布周期之外升级 NPM。要尝试升级,只需打开您操作系统首选的控制台或终端窗口,并执行以下命令:

>npm install -g npm

NPM 只是你电脑上的另一个可执行文件。它接受一系列参数或参数。在这种情况下,我们要求 NPM 安装一个包。第二个参数-g告诉 NPM 我们想要全局安装请求的包。最后,我们要求 NPM 安装的包是 NPM。

对 JavaScript IDE 的简要介绍

虽然你不需要一个真正的IDE集成开发环境),但你将需要一个文本编辑器。为什么不选择一个能为你做些重活的文本编辑器呢?实际上,对于 JavaScript 开发,有两种类型的 IDE 可用。第一种实际上更像是一个文本编辑器,而第二种则是一个功能齐全的编辑器,内置编译和源代码控制。

虽然你可以只用简单的文本编辑器和控制台/终端窗口来处理 JavaScript,但我们建议使用功能更强大的工具。

Visual Studio Code

如 C#部分所述,Visual Studio Code 是一个基于 Electron 框架的轻量级编辑器,使用 TypeScript 开发,这是一种由微软设计来扩展 JavaScript 的静态类型语言。TypeScript 编译成 JavaScript,所以最终 Visual Studio Code 是一个 JavaScript 应用程序。

为什么选择 Visual Studio Code?

对于使用 JavaScript,你可能选择 Visual Studio Code 或其他基于 Electron 的编辑器有几个原因。VSCode 轻量级,拥有广泛的插件架构,与源代码控制集成,并且非常容易设置和使用。

安装 Visual Studio Code

安装 VSCode 极其简单。如果你已经跟随了C#部分,那么你很可能已经安装了 VSCode。如果没有,这里有一些未讨论的替代安装方法。

Linux

要在 Linux 上安装(再次提醒,这是一个针对 Ubuntu 的示例),只需执行以下命令:

sudo apt-get update
sudo apt-get install code # or code-insiders

Mac

很遗憾,我没有一个花哨的命令行方法在 Mac 上安装 VSCode。相反,请访问 Visual Studio Code 的主页code.visualstudio.com,并遵循那里的安装说明。

Windows

对于 Windows 系统,就像 Node 一样,我们可以使用 Chocolatey 来安装 VSCode。要使用 Chocolatey(chocolatey.org/install)安装,请在控制台窗口中执行以下命令。记住,你可能需要以管理员身份运行控制台:

C:\> choco install visualstudiocode

安装所需的插件

我们推荐安装的两个插件是npmnpm-intellisense,因为它们将有助于流程并提供提示,当你不确定是否使用了正确的包名时。

配置测试环境

Visual Studio Code 提供了内置的测试运行功能。然而,我们不会选择这些选项进行 JavaScript 开发。为了测试驱动我们的应用程序并展示我们的测试方法,我们认为使用 VSCode 内可用的终端将更加合适,并且更适合将要使用的流程。

WebStorm

WebStorm 是由 JetBrains 用 Java 编写的完整 IDE。

为什么选择 WebStorm?

WebStorm 基本上包含了您开发基于 JavaScript 的应用程序所需的一切。它还支持许多 JavaScript 生态系统中的 JavaScript 替代品,如 TypeScript、Flow 和 React JSX。WebStorm 还与许多代码质量工具无缝集成,如 ESLine、TSLint 和 JSHint。

WebStorm 的唯一缺点是它确实需要付费。但是,当你这么看的时候,付费产品实际上是一件好事。制作付费产品的公司有很好的理由继续维护它。JetBrains 提供通过单次购买或订阅购买 WebStorm。我们建议选择订阅,因为您的前期成本最小化,并且 JetBrains 有更多的动力让您保持满意。

安装 WebStorm

要安装 WebStorm,我们将使用 JetBrains 创建的新程序,称为The JetBrains Toolbox App。Toolbox App 旨在跟踪版本更新并为所有 JetBrains 产品提供一个共同的启动点。一旦安装,安装任何 JetBrains 工具都变得非常容易。

Linux

在撰写本文时,似乎没有从apt-get安装 ToolBox 或 WebStorm 的方法。因此,我们只能采取困难的方式。请访问 ToolBox 下载页面www.jetbrains.com/toolbox/app/并下载 Linux tarball。然后,打开一个终端到您的下载目录。一旦到达那里,执行以下命令:

mv jetbrains-toolbox-<version>.tar.gz <application directory root>/jetbrains-toolbox-<version>.tar.gz
cd <application directory root>
tar –xj jetbrains-toolbox-<version>.tar.gz
chmod –R 777 jetbrains-toolbox-<version>
cd jetbrains-toolbox-<version>
./jetbrains-toolbox 

Mac

在 Mac 上,我们还可以再次使用 Homebrew 进行安装。只需执行以下命令:

brew cask install jetbrains-toolbox

Windows

在 Windows 上,我们能够使用 Chocolatey 安装 Toolbox。执行以下命令然后启动应用程序:

choco install jetbrainstoolbox

我们安装 ToolBox 的最终目标是安装 WebStorm。因此,当 ToolBox 打开时,如果您购买了任何 JetBrains 产品,请登录;如果您只是想试用,请跳过登录。接下来,在产品列表中找到 WebStorm 并点击安装按钮。安装完成后,您将能够点击一个启动按钮,该按钮将替换安装按钮。

安装您需要的插件

我们为使用 WebStorm 的插件带来了好消息。WebStorm 提供了一个庞大的插件社区,你几乎可以找到你想要的任何插件,这些插件都可以通过应用程序内置的插件管理系统访问。然而,对于本书的目的而言,你实际上并不需要任何插件。因此,我们已经完成了插件的安装!实际上,WebStorm 内置的功能太多了,我们将忽略或甚至关闭其中的一些功能,以便我们可以按照自己的方式工作。

配置测试环境

就像在 Visual Studio Code 中一样,对于 WebStorm,我们不会介绍如何设置内置的测试运行能力。WebStorm 提供了一个可以开启的终端显示,并支持同时打开多个上下文。

Create React App

现在你已经安装了最新版本的 Node 和 NPM,请将注意力转向你想要测试的应用程序。由于其不断增长的流行度,我们选择通过测试一个 React 应用程序来解释和演示测试驱动开发。

根据 React 网站,React 是一个用于构建用户界面的 JavaScript 库。我们将专注于使用它来构建前端浏览器应用程序,但它也可以用来创建移动和桌面应用程序。

React 是由 Facebook 创建和维护的。React 是为了解决 Facebook 在其用户界面中遇到的问题而创建的,现在它正在互联网上掀起一场风暴。Facebook 还创建了一个名为 Create React App 的库,以便快速启动 React 应用程序。

Create React App 是什么?

Create React App 是由 Facebook 创建的一个 NPM 包,旨在提供一种零配置的方式来创建 React 应用程序。React 需要相当多的设置才能开始,手动配置一个 React 应用程序可能需要几天时间。Create React App 可以将这个时间缩短到一分钟以内。

安装全局模块

在你能够使用命令行工具实际创建 React 应用程序之前,必须安装 Create React App 的全局 NPM 包。要在控制台或终端窗口中安装最新版本的 Create React App 全局脚本,请执行以下命令:

>npm install -g create-react-app

创建 React 应用程序

一旦全局模块安装完成,你就可以开始使用 Create React App 了。创建一个 React 应用程序的过程非常流畅和简单。在我的系统中,我有一个名为\projects的目录,用于存放所有我的前端应用程序项目。在你的机器上打开一个具有相同目的的目录的命令行/终端窗口,并执行以下命令来创建一个新的 React 应用程序:

\projects>create-react-app <projectName>

在我们的案例中,我们的测试用例名称是 Speaker Meet,所以作为一个例子,我的命令如下所示:

\projects>create-react-app speakermeet-spa

如同在 C#部分提到的,SpeakerMeet 既有后端(RESTful Web API),也有前端(基于 React 的SPA单页应用程序))。

运行 Create React App 脚本

create-react-app 脚本运行完成后,会显示一个可用命令列表。您需要确保一切创建成功。您可以通过执行以下命令来启动应用程序:

>npm start

如果一切安装正确,您的默认浏览器将打开,并运行一个新的 React 应用程序。

Mocha 和 Chai

Create React App 支持开箱即用的测试。最初,Create React App 使用名为 Jest 的测试库。我们想使用 Mocha 和 Chai,因为它们在 JavaScript 社区中非常受欢迎。

Jest

Jest 是由 Facebook 编写的测试框架。就像 Create React App 一样,Jest 设计为无配置工具。Jest 还支持持续测试和代码覆盖率分析。

Jest 是设计在常见的 BDD行为驱动开发)范式下工作的,许多其他 JavaScript 测试框架也是如此。因此,测试函数 describeit 都可以用来编写测试。

Mocha

Mocha 是另一个 JavaScript 测试框架,是我们想使用的框架。至于库交互差异,基础交互方面似乎没有太多不同。差异主要体现在断言库和模拟库上。

模拟,将在第四章 Chapter 4“开始之前需要了解的内容”中详细说明,本质上是一种提供对象、类和函数的替代实现的方法,特别是为了帮助测试过程。

Mocha 本身不包含断言库,因此必须提供。断言库是控制测试结果以及如何验证代码是否正确执行的关键。大多数使用 Mocha 的开发者依赖于 Chai 进行断言。

如前所述,另一个考虑因素是您想使用哪个模拟库。对于许多 Mocha 用户来说,这个库无疑是 Sinon

我们将解释本书中使用的 Mocha 的任何和所有部分的目的。如果您想了解更多信息或想在开发时快速参考文档,您可以去 Mocha 主页mochajs.org

Mocha 可以使用以下命令安装到 JavaScript 应用程序中:

>npm install mocha

Chai

Chai 是一个 BDD 断言库。Chai 使用流畅的 API 来允许极其灵活的断言。Chai 最受欢迎的两种使用方式是通过提供的 shouldexpect 接口。Chai 的工作方式,以及实际上每个测试框架断言的工作方式,是在断言的检查失败时抛出异常。

例如,如果您有一个名为 foo 的变量,其值为 3,并且当测试运行时您的断言是 expect(foo).to.equal(5),那么这个断言将抛出一个包含消息“预期 3 等于 5”的异常。

要将 Chai 安装到您的项目中,请运行以下命令:

>npm install chai

在您安装 Chai 之后,还需要进行一个步骤才能在您的项目中使用它。您必须在应用程序中每个测试文件的顶部包含以下导入:

import { expect } from ‘chai’;

如果您想使用 should 断言,您可以将 expect 替换为 should,或者在大括号内添加 should,用逗号将其与 expect 分隔。

更多信息或查阅文档,请访问 Chai 主页 chaijs.com

Sinon

我们不会在 第四章 “开始之前需要了解的内容” 中介绍模拟,但 Sinon 是 Mocha + Chai 用户中普遍首选的模拟库。一些测试框架,如 Jest 和 Jasmine,自带模拟库功能,但 Mocha 没有自带,而 Sinon 提供了出色的模拟体验。

要将 Sinon 安装到您的项目中,请执行以下命令:

>npm install sinon

安装完成后,您在使用 Sinon 之前需要导入它。使用以下导入语句来启用 Sinon 的使用:

import sinon from ‘sinon’;

Enzyme

Enzyme 是一个旨在帮助测试 React 组件的库。

要将 Enzyme 安装到您的项目中,请执行以下命令:

>npm install enzyme react-test-renderer react-dom

列出的额外库 react-test-rendererreact-dom 是 Enzyme 正确运行所需的依赖项。

与本节中提到的其他测试实用工具一样,我们将根据需要讨论使用方法,同时讨论本书涵盖的主题。但这里有一个使用 Enzyme 的快速示例,该示例来自 Enzyme 文档 github.com/airbnb/enzyme

import React from 'react';
import { expect } from 'chai';
import { render } from 'enzyme';
import Foo from './Foo';

describe('<Foo />', () => {
  it('renders three `.foo-bar`s', () => {
    const wrapper = render(<Foo />);

    expect(wrapper.find('.foo-bar').length).to.equal(3);
   });

   it('renders the title', () => {
      const wrapper = render(<Foo title="unique" />);

      expect(wrapper.text()).to.contain('unique');
   });}); 

退出 React 应用

不幸的是,这就是我们与 Create React App 分道扬镳的地方。为了使用 Mocha,我们需要找到一种替代方法来与应用程序一起工作。由于 Create React App 的零配置设置,我们无法简单地更新正在使用的测试框架,这对我们来说是个问题。

幸运的是,Create React App 以退出应用的形式为我们提供了一个出路。退出 React 应用程序会将所有必要的配置文件和实用工具安装到我们的项目中,并移除 Create React App。一旦退出过程完成,我们将能够访问所有配置文件,并将能够切换到使用 Mocha。

要退出 Create React App,请执行以下命令:

>npm run eject

如果您查看项目根目录下的 package.json,您将看到添加了很多信息和配置。

在对 package.json 进行任何重大修改后,删除 node_modulespackage-lock.json,然后重新运行 npm install 是一个好主意。

配置以使用 Mocha 和 Chai

在您退出 React 应用之后,在您进行任何进一步的修改之前,您应该确保一切仍然正常工作。在做出任何进一步的修改之前,请执行以下命令:

>npm start

现在检查是否启动了浏览器,并且应用程序正在正确运行。你将需要按 Ctrl + C 退出运行进程:

>npm run build

在此命令之后,检查是否在项目根目录中创建了一个构建文件夹,并且控制台没有显示错误:

>npm test

尽管你即将更改测试配置,但你将使用一些 Create React App 提供的库。你想要确保当你退出时,这些先决条件已经正确过渡。与 npm start 一样,你将需要按 Ctrl + C 退出此进程。

假设所有命令都无问题执行,你现在可以开始将测试环境切换到 Mocha 的过程。执行以下命令以确保安装必要的依赖项:

>npm install mocha chai sinon enzyme

打开 package.json 并更新以下行:

"babel": {
   "presets": [
     "react-app"
   ]
 },

将前面的代码更改为:

"babel": {
   "presets": [
     "react",
     "es2015"
   ]
 },

你还需要安装 BabelJS 预设 ES2015:

>npm install babel-preset-es2015

接下来,找到并删除 package.json 中的 jest 设置。你现在可以更改 test 脚本来执行 Mocha 而不是 Jest。找到 NPM 脚本并更新测试脚本如下:

"test": "node scripts/test.js --env=jsdom"

将前面的代码更改为:

"test": "mocha --require babel-core/register ./scripts/test.js --require babel-core/register ./src/**/*.spec.js"

你刚才所做的更改将导致 Mocha 执行所有测试。不过,它只会执行一次。你想要一个在工作的同时持续运行测试的方法,所以你需要添加一个额外的脚本。在你修改的行末尾添加一个逗号,然后在其下方添加以下脚本:

"test:watch": "npm test -- -w"

现在,你需要更新当你退出时提供的 test.js 文件。打开 <项目根目录>/scripts/test.js 并将里面的所有代码替换为以下内容:

'use strict';

import jsdom from 'jsdom';

global.document = jsdom.jsdom('<html><body></body></html>');
global.window = document.defaultView;
global.navigator = window.navigator;

function noop() {
  return {};
}

// prevent mocha tests from breaking when trying to require a css file
require.extensions['.css'] = noop;
require.extensions['.svg'] = noop;

此文件只是为测试设置基础环境,以便测试在内部执行。注意 noop 函数及其用法。目前,当你进行测试时,你正在忽略生产代码所需的 csssvg 扩展。在测试过程中,如果你在需要不同扩展时遇到问题,你可能需要回到此文件并将有问题的扩展添加到列表中。

你几乎完成了;在你正式切换到 Mocha 之前,你只需要进行一个修改。在你的 src 目录中找到 App.test.js 文件,并将其重命名为 App.spec.js,然后更新内容如下:

import React from 'react';
import ReactDOM from 'react-dom';
import { expect } from 'chai';

import App from './App';

describe('(Component) App', () => {
  it('renders without crashing', () => {
    const div = document.createElement('div');
    ReactDOM.render(<App />, div);
  });
});

你在这里真正做的只是导入 Chai 并添加一个 describe 块。其余的测试代码保持不变,是 Create React App 提供的默认测试。

一个快速 kata 来检查我们的测试设置

对于这个设置测试,你需要执行回文代码 kata。这个 kata 可能会变得复杂,但你只需要关注最基本的形式。

要求

这个 kata 的要求如下:

Given a string value
 And Given the provided string is not a palindrome
 When checked
 Then return false

Given a string value
 And Given the provided string is a palindrome
 When checked
 Then return true

执行

正如你一直应该做的,你将从一个模板化的测试文件开始,用于验证单元测试框架是否配置正确:

import { expect } from 'chai';

describe('Test Framework', () =>  {
  it('is configured correctly', () => {
    expect(1).to.equal(0);
  }
}

我们已经展示了这个模板的失败版本。在你开始编写实际的 kata 测试之前,运行test:watch NPM 脚本来验证测试失败。同时,验证它是否因为预期的1但实际结果是0而失败。在测试正确失败后,将零改为一,并验证测试现在是否通过。只要这两个验证都正确工作,我们就会继续,并开始处理代码 kata。

开始 kata

对于这个 kata,你将要做的是编写一个ItExists测试。同样,这些类型的测试有助于推动工作并防止写作障碍。你将用以下代码替换检查框架的代码。

红色阶段;编写一个失败的测试,期望存在一个isPalindrome函数:

describe('Is Palindrome', () => {
  it('exists', () => {
    expect(isPalindrome).to.exist;
  });
});

验证这个测试是否失败。在继续使测试通过之前看到失败是很重要的。这将有助于确认你的测试设置是否正常工作。

现在是绿色阶段;让测试通过。定义一个isPalindrome函数,并再次运行测试以查看它是否通过。

function isPalindrome() {
}

对于下一个测试,我们想要考虑一个会产生结果的简单测试用例。同样,我们正在跳过不良数据问题。我们可以想到的最简单的会产生结果的测试用例是一个单个字母。对于你将在这些测试中使用的回文定义,单个字母是一个回文。在之前的测试下添加以下测试:

it('a single letter is a palindrome', () => {
  // arrange
  const value = 'a';

  // act
  const result = isPalindrome(value);

  // assert
  expect(result).to.be.true;
});

现在,为了让它通过:

function isPalindrome() {
  return true;
}

现在你有一个通过测试,但你总是返回 true。当你编写下一个测试时,你希望它失败。所以,你应该编写一个测试,当传入的值不是回文时。最简单的非回文就是两个不相同的字母:

it('two non-matching letters is not a palindrome', () => {
  // arrange
  const value = 'at';

  // act
  const result = isPalindrome(value);

  // assert
  expect(result).to.be.false;
});

现在,让它通过:

function isPalindrome(value) {
  if(value.length === 1) {
    return true;
  }

  return false;
}

好吧,所以现在你只返回单个字母的 true。这为我们打开了下一个测试,回到一个回文;为相同的两个字母编写一个测试:

it('two matching letters are a palindrome', () => {
  // arrange
  const value = 'oo';

  // act
  const result = isPalindrome(value);

  // assert
  expect(result).to.be.true;
});

现在,让它通过:

function isPalindrome(value) {
  if(value.length === 1) {
    return true;
  }

  if(value.length === 2 && value[0] === value[1]) {
    return true;
  }

  return false;
}

下一个测试是要有一个三个字母的回文单词。目前,这个应该失败:

it('three letter palindrome', () => {
  // arrange
  const value = 'mom';

  // act
  const result = isPalindrome(value);

  // assert
  expect(result).to.be.true;
});

为了让这个测试通过,想想你到目前为止学到的东西。检查回文的一个算法就是从两端开始,检查最外层的两个字母。如果这两个字母匹配,那么就分别在两边各移动一个字母。重复这个检查,直到你到达单词或短语的中心。如果中心是一个字母,那么它就是一个回文;否则,检查中间的两个字母是否匹配。让我们通过使用递归来尝试这个概念,以便让最新的测试通过:

function isPalindrome(value) {
  if(value.length === 1) {
    return true;
  }

  if(value.length === 2 && value[0] === value[1]) {
    return true;
  }

  if(value[0] === value[value.length -1]) {
    return isPalindrome(value.substring(1, value.length - 1));
  }

  return false;
}

现在,我们需要检查一个四字母回文是否通过:

it('four letter palindrome', () => {
  // arrange
  const value = 'abba';

  // act
  const result = isPalindrome(value);

  // assert
  expect(result).to.be.true;
});

它通过了;太棒了!我们将以两个练习结束这个代码 kata。第一个练习是添加一个测试来检查“a man a plan a canal panama”,并让它通过。第二个练习是重构isPalindrome的代码。虽然这是一个小的函数,但它仍然可以进行一些整理,并且可能需要进行一些优化。

摘要

您现在应该已经安装了 Node,并且配置好了您的 JavaScript 开发环境。本书余下的 JavaScript 示例将假设您使用 WebStorm。

但是,在深入之前,第四章,开始之前需要了解的内容,将专注于在开始之前您还需要了解什么。

第四章:开始之前需要了解的内容

你已经取得了相当不错的进展。到现在为止,你应该开始对测试驱动开发背后的基本概念感到舒适。你知道 TDD 的基本前提以及如何在 C#和 JavaScript 中编写单元测试。

在本章中:

  • 我们将介绍更多 TDD 背后的实践

  • 将提供具体的建议,以避免在过程中遇到陷阱

  • 我们将解释定义和测试边界的重要性,以及抽象第三方代码(包括.NET 框架)

  • 我们将开始介绍更高级的概念,例如间谍、模拟和伪造

首先,让我们了解一下在尝试测试现有应用程序时可能会遇到的一些问题。希望这能帮助你避免在下一个绿色田野应用程序中遇到问题。

无法测试的代码

有许多明显的迹象表明一个应用程序、类或方法将难以测试,甚至可能根本无法测试。当然,有一些方法可以绕过以下一些例子,但通常最好是避免这些解决方案和程序性杂技。简单通常是最好的,你的未来自己和/或未来的维护者会感谢你保持事物的简单性。

依赖注入

如果你是在构造函数或方法内部创建外部资源实例,而不是将它们传递进来,将非常难以编写测试来覆盖这些类和方法。通常,在当今的现代应用程序中,依赖注入框架用于创建和提供外部依赖项给一个类。许多人选择定义一个接口作为依赖项的契约,提供一种更灵活的测试方法,并减少对外部资源的耦合。

静态

你可能需要访问静态第三方类或方法。而不是直接访问静态资源,最好通过接口来访问这些资源。以 C#中的DateTime为例,Now是一个静态属性,这阻止了你控制被测试的类或方法使用的DateTime值。这使得验证测试用例和确保程序逻辑根据特定日期或时间正确行为变得更加困难。

单例

单例是共享状态的本质。为了确保你的测试在隔离环境中运行,最好避免使用它们。如果需要单例(例如,LoggingData Context等),大多数依赖注入框架允许用非单例类替换单例实例,从而提供单例的功能和灵活性。对于生产代码,这允许你控制单例实例的作用域。

全局状态

很早就已经明白,应用程序中的全局状态会对系统造成破坏,并导致难以追踪的意外行为。在一个地方更改代码可能会对系统的其余部分产生深远的影响。为了可测试性,这通常意味着在设置上需要更多的努力,并且测试执行会变慢。

抽象第三方软件

随着你的应用程序增长,你可能会引入外部依赖。当然,这些系统、应用程序和库的开发者已经彻底测试了他们的产品。你应该专注于测试你的应用程序,而不是测试第三方代码。你的应用程序应该足够健壮,能够处理边缘情况,并且你需要考虑到预期和非预期的行为。你希望抽象掉第三方代码的细节,并测试预期的(以及意外的)结果。

那么,什么是第三方代码?任何你未编写的代码。这包括.NET 框架本身。实现第三方代码抽象的一种方法是通过使用测试替身。

测试替身

测试替身是函数和类,通过允许你验证功能或绕过难以测试的依赖项来帮助测试过程。测试替身被用于所有级别以隔离正在测试的代码。很多时候,对测试替身的需求推动了代码的架构。

C#中的DateTime对象就是一个这样的例子。System.DateTime是.NET 框架的一部分,通常你不会认为你需要在代码中抽象这部分。大多数开发者的本能是在一个using 语句中简单地引用它,然后在他们的代码中直接访问DateTime.Now

一个无法重复的测试是一个糟糕的测试。

这通常很难测试。如果我们尝试使用DateTime.Now测试一个方法,我们将无法阻止DateTime.Now的默认功能。DateTime.Now返回存储在DateTime对象中的当前日期和时间。无法操纵此对象的返回值会导致我们的测试变得不可预测和不可重复。一个无法重复的测试是一个糟糕的测试。

许多开发者已经理解了可预测性的必要性。你可能听说过这样的话,“如果无法重现,就不是 bug”或类似的观点。这是因为,为了验证我们已修复了一个 bug,我们必须能够可预测地重复该错误。这样,一旦重现错误的步骤不再产生 bug,我们就可以自信地说 bug 已经被修复。至少对于那一系列步骤是这样的。

测试与修复 bug 没有不同;它遵循所有相同的步骤。我们只是确切知道是什么导致了 bug;代码尚未编写,或者我们刚刚尝试的重构失败了。

创建测试替身有时可能会变得有些复杂。因此,为了支持这些测试替身创建,几乎为每种拥有测试框架的语言都创建了框架。这些框架通常被称为模拟框架或库。在 C#中,目前最广泛使用的框架是Moq,发音为mock。同样,在 JavaScript 中,最常引用的模拟库似乎是Sinon,发音为sign on

模拟框架

模拟框架是缓解大型项目中测试压力的绝佳工具。当尝试围绕遗留系统编写测试时,它们尤其有用。在这个例子中,遗留系统被定义为尚未围绕它编写测试的应用程序。这个定义来自 Michael Feather 的书籍《与遗留代码有效工作》。

在学习测试驱动开发和使用模拟框架时,请谨慎行事。模拟框架提供了一个非常有吸引力的替代方案,可以让你不必仔细考虑你的代码。你可能会编写一套完整的测试,最终却只真正测试了模拟框架。

许多模拟框架在这方面功能过于强大。在 C#中,存在一种模拟框架的分类,允许你替换外部代码。这些外部代码包括DateTime.Now和任何其他你无法控制的类。在 JavaScript 中,这被称为 monkey patching,每个框架都允许你这样做。

你问,这有什么害处吗?TDD(测试驱动开发)的一个好处是它鼓励做出明智的架构选择。当你有权力覆盖第三方代码的功能时,你就不再需要为了测试而进行抽象。

为什么那是个问题呢?如果我们想要保持代码的灵活性,并且想要遵循 SOLID 原则,那么对第三方代码的抽象是必要的。

SOLID 原则

SOLID 原则是一组最初由 Robert C. Martin(即“Uncle Bob”)组合起来的概念。通常被宣传为面向对象原则,你应该把它们看作是纯粹的好的架构选择。SOLID 原则包括五个原则:单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。

关于 SOLID 原则的原始文章可在butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod找到。

单一职责原则

在 Uncle Bob 的文章中,单一职责原则(SRP)被定义为:“一个类应该只有一个改变的理由。”

这意味着什么?这是困难的部分;有很多人理解其含义的方法。一种看法是,类应该只支持一个业务用户。另一种看法是,在应用程序内部,类应该只在有限的或特定的范围内使用。还有一种看法是,类应该具有有限的功能范围。这些都是正确的,但还不够充分。确保你遵守这一原则的一种方法是我们将要提到的“三到五规则”。

如果我们在讨论需求,例如,当一个需求有三个到五个验收标准时,那么它很可能在细节程度上是适当规模的。同样,如果我们正在讨论一个方法或函数,那么三到五行代码可能也是适当规模的。

三到五规则是一种通用的方式来了解你是否在遵守 SRP。该规则表述为:“少于三个是好的。三个到五个是不错的。超过五个则强烈考虑重构。”它并不像许多其他法律、原则和规则那样优雅,但三到五规则很容易遵循。这个规则只是一个指导方针,不应被视为最终命令。你应该尝试将这个规则应用到软件开发中的几乎所有事情上。你已经在本书中看到了它的应用。这个规则被用来确定第一章,“为什么 TDD 很重要”中需求的范围,以及到目前为止所包含的所有代码示例。

如果你使用三到五规则,几乎可以保证你正在遵循单一职责原则(SRP),并且它使你的代码、文件结构和需求保持小而易于维护。

开放/封闭原则

开放/封闭原则表述为:“软件实体(类、模块、函数等)应该对扩展开放,但对修改封闭。”SOLID 原则中的第二个听起来似乎并没有说很多,但它有着巨大的影响。

有许多方式来遵守这一原则。你可以或你的开发团队可以制定一个规则,只允许新的开发。也就是说,现有的功能不能被更新或改变,只能通过新的方法或类来替换。当我们到达创建代码分隔线的时候,你可以使用这些分隔线来创建一个进行功能交换的地方。

开放/封闭原则还使持续集成和部署成为可能。这是因为,如果你的应用程序从未违反与用户、自身或第三方之间的合同,那么它总是可以部署而不用担心引起生产问题。

李斯克夫替换原则

Liskov 替换原则可能由于其复杂和数学定义而难以理解。从 Barbara Liskov 的 数据抽象和层次结构 [pdfs.semanticscholar.org/36be/babeb72287ad9490e1ebab84e7225ad6a9e5.pdf] 中,该原则被表述如下:

这里所需要的是类似以下替换属性。如果对于类型 S 的每个对象 o1,都存在一个类型 T 的对象 o2,使得对于所有以 T 定义的程序 P,当用 o1 替换 o2 时,P 的行为保持不变,那么 S 是 T 的子类型。

Uncle Bob 将这个定义简化为,使用基类指针或引用的函数必须能够在不知道的情况下使用派生类的对象。 看这个原则,它似乎只是继承。但是,它不仅仅是继承。这个原则意味着替换其他对象的那个对象不仅必须实现与原始对象相同的接口或合同,还必须遵守与原始对象相同的期望。

违反此原则的经典例子是在矩形类的地方使用正方形类。一个典型的矩形类需要具有长度和宽度属性。在数学上,正方形只是矩形的一种特殊类型。因此,许多人会认为创建具有长度和宽度的正方形类可以接受地替换矩形类。

这里的问题是,正方形要求长度和宽度具有相同的值。因此,当你更改正方形类中的任何一个时,该类将更新另一个以具有相同的值。这是一个问题,因为使用该对象的应用程序并不期望这种行为。因此,应用程序必须意识到长度或宽度可能在没有通知的情况下更改的可能性。

无法满足应用程序的期望被称为拒绝遗赠。拒绝遗赠可能导致应用程序行为不一致,并且至少需要更多的代码来补偿不匹配。

接口隔离原则

接口隔离原则是关于保持你的类小而简洁的交互合同。更具体地说,你的类所呈现的合同应该只有一个责任。

有时,拥有一个具有小而单一责任合同的类可能很困难或不是所希望的。在这些情况下,类应该实现多个合同,而不是创建一个组合合同。我们希望多个合同可以减少远距离依赖的数量。

每次修改基类或接口时,子类也必须进行修改。至少,子类现在必须重新编译。通过限制合同的范围,我们可以减少更改该合同的影响,并改善整体系统架构。

依赖倒置原则

依赖项倒置之所以重要,有几个原因,其中之一是倒置的依赖项增加了灵活性,减少了易碎性,并有助于代码的潜在重用。

依赖项倒置允许插件式架构。通过定义交互合同,一个模块可以确定它想要如何与依赖项交互。然后依赖项依赖于该合同。

因为顶层模块没有外部依赖,它可以独立部署。独立部署应用程序的一部分几乎从未发生过,但拥有一个可以独立部署的库在依赖项更改时不需要重新编译具有巨大的好处。

在正常开发中,依赖项的波动比高级模块要多得多。这种波动导致需要重新编译。当你的应用程序依赖项向下流动时,依赖项的重新编译也会触发依赖库的重新编译。因此,实际上,改变一个微小但常见的库中的实用辅助类将触发整个应用程序的重新编译。

然而,如果你正在倒置依赖项,这样的变化只会触发实用辅助库和应用程序库的重新编译。它不会触发介于两者之间的每个库的重新编译。

这就是 SOLID 原则的全部内容。如果你选择使用模拟框架,请记住它们。确保你不允许模拟框架诱使你构建一个僵化、易碎、不可移动的系统。

及时的问候

在扩展经典的Hello World示例时,如果你想要根据一天中的时间改变你的问候语怎么办?以下是一个示例:

As a visitor to the site 
 I want to receive a time-appropriate greeting 
 So that I may plan the submission of my talks 

Given it is before noon 
 When greeting is requested 
 Then morning message is returned

Given it is afternoon 
 When greeting is requested 
 Then afternoon message is returned

你可能会想,这很简单;我只需编写一个快速的方法来返回适当的消息。当然,你会是对的。这是一个相当容易的任务。你可能会想出像这样的一些东西:

public string GetGreeting()
{
  if (DateTime.Now.Hour < 12)
    return "Good morning";

  return "Good afternoon";
}

记住,在第一章,“为什么 TDD 很重要”中,我们讨论了 TDD 的三个定律。至关重要的第一个定律指出,你不允许在没有失败的测试的情况下编写任何生产代码。

易碎的测试

“但是,这是一个如此简单的方法,”你可能会说。如果你遇到了一个错误怎么办?如果你想在事后为这个方法编写一些测试怎么办?你是否需要在一个特定的时间运行你的测试套件以确保测试通过?你是否需要根据你运行测试的时间改变你的测试?

假阳性和假阴性

如果我们像在消息示例中那样直接留下代码,并编写一个测试来覆盖该方法,它可能看起来像这样:

[Fact]
public void GivenEvening_ThenAfternoonMessage()
{
  // Arrange
  // Act
  var message = GetGreeting();

  // Assert
  Assert.Equal("Good afternoon", message);
}

你能发现这个测试的问题吗?测试本身并没有固有的错误。问题是生产代码将根据一天中的时间返回不同的消息。这意味着如果你在下午运行测试,它会通过。如果你在早上运行测试,它会失败。

抽象 DateTime

DateTime 是 .NET Framework 的一部分,因此,它应该从我们的系统中抽象出来。通常,我们希望我们的系统依赖于接口,这样我们就可以在运行时替换实现。

以下是一个 ITimeManager 的示例:

public interface ITimeManager
{
  DateTime Now { get; }
}

为了测试目的,你可能会得到一个看起来像这样的 ITimeManager 实现:

public class TestTimeManager : ITimeManager
{
  public Func<DateTime> CurrentTime = () => DateTime.Now;

  public void SetDateTime(DateTime now)
  {
    CurrentTime = () => now;
  }

  public DateTime Now => CurrentTime();
}

这允许我们设置 Now 的值,以便我们可以向测试方法提供已知值。现在,让我们回顾我们的测试:

[Theory]
[InlineData(12)]
[InlineData(13)]
[InlineData(14)]
[InlineData(15)]
[InlineData(16)]
[InlineData(17)]
[InlineData(18)]
[InlineData(19)]
[InlineData(20)]
[InlineData(21)]
[InlineData(22)]
[InlineData(23)]
public void GivenAfternoon_ThenAfternoonMessage(int hour)
{
  // Arrange
  var afternoonTime = new TestTimeManager();
  afternoonTime.SetDateTime(new DateTime(2017, 7, 13, hour, 0, 0));
  var messageUtility = new MessageUtility(afternoonTime);

  // Act
  var message = messageUtility.GetGreeting();

  // Assert
  Assert.Equal("Good afternoon", message);
}

[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(7)]
[InlineData(8)]
[InlineData(9)]
[InlineData(10)]
[InlineData(11)]
public void GivenMorning_ThenMorningMessage(int hour)
{
  // Arrange
  var morningTime = new TestTimeManager();
  morningTime.SetDateTime(new DateTime(2017, 7, 13, hour, 0, 0));
  var messageUtility = new MessageUtility(morningTime);

  // Act
  var message = messageUtility.GetGreeting();

  // Assert
  Assert.Equal("Good morning", message);
}

我们的生产代码最终可能看起来像这样:

public class MessageUtility
{
  private readonly ITimeManager _timeManager;

  public MessageUtility(ITimeManager timeManager)
  {
    _timeManager = timeManager;
  }

  public string GetMessage()
  {
    if (_timeManager.Now.Hour < 12)
      return "Good morning";

    return "Good afternoon";
  }
}

Test double 类型

测试替身有很多种类。这些种类通常可以分为 dummies、stubs、spies、mocks 和 fakes。接下来,我们将讨论不同类型,并为每种类型提供 C# 和 JavaScript 的示例。

Dummies

Dummies 是测试替身中最简单的一种形式。Dummies 没有可感知的功能。我们实际上并不期望在测试的类或方法的结果中使用 dummy 类或方法。

Dummies 通常在测试的类具有依赖关系,而你正在测试的方法或函数没有使用该依赖关系时使用。

你可以通过创建一个类或方法的新副本或实例来创建一个 dummies,然后在代码体中做任何事情。Void 方法将是空的,而期望返回值的函数或方法在调用时将抛出异常或返回该返回值的简单形式。

Dummy logger

Logging 服务是一个完美的例子,可以用 dummies 替换。当你正在测试特定方法时,不太可能(也不推荐)同时测试日志功能。

C# 示例

以下是一个 C# 中的 DummyLogger 示例。你会注意到当调用 Log 时,没有任何操作发生。

enum LogLevel
{
  None = 0,
  Error = 1,
  Warning = 2,
  Success = 3,
  Info = 4
}

interface ILogger
{
  void Log(LogLevel type, string message);
}

class DummyLogger: ILogger
{ 
  public void Log(LogLevel type, string message)
  {
    // Do Nothing
  }       
}

JavaScript 示例

以下是一个 JavaScript 中的 DummyLogger 示例。你会注意到当调用 infowarnerrorsuccess 时,没有任何操作发生。

export class DummyLogger {
   info(message) {
   }

   warn(message) {
   }

   error(message) {
   }

   success(message) {
   }
 }

Stubs

Stubs 是比 Dummies 高一级的测试替身。Stub 测试替身将提供与传入的参数无关的相同响应。

当你想要测试代码中的不同执行路径时,会使用 Stubs。一个例子是在特定条件下必须抛出的错误。

Stubs 是通过创建需要返回 stub 值的类或方法的副本或覆盖,然后将其设置为返回所需值来创建的。记住,stubs 不评估参数,所以你只需要返回所需的值。

C# 示例

以下是一个 C#中的StubSpeakerContactServiceError的示例。你会注意到,当调用MessageSpeaker时,会抛出一个新的UnableToContactSpeakerException错误。

class StubSpeakerContactServiceError : ISpeakerContactService
{
  public void MessageSpeaker(string message) 
  {
    throw new UnableToContactSpeakerException();
  } 
}

JavaScript 示例

以下是一个 JavaScript 中stubSpeakerReducer的示例。你会注意到,无论传递什么操作,都会在状态中的错误数组中推送一个新的*UNABLE_TO_RETRIEVE_SPEAKERS*错误。

 import { SpeakerErrors } from './errors';
 import { SpeakerFilters } from './actions';

 const initialState = {
   speakerFilter: SpeakerActions.SHOW_ALL,
   speakers: [],
   errors: []
 };

 export function *stubSpeakerReducer*(state, action) {
   state = state || initialState;

   state.speakerFilter = action.filter || SpeakerFilters.SHOW_ALL;
   state.errors.push(SpeakerErrors.UNABLE_TO_RETRIEVE_SPEAKERS);

   return state;
 }

间谍(Spies)

间谍(Spies)是测试替身(test doubles)的下一进化阶段。间谍返回一个类似于存根(stub)的值,但它有一个极其重要且有用的区别。间谍可以报告与函数调用相关的信息。

当你想要验证一个函数是否以特定参数被调用时,间谍(Spies)通常被使用。这在你的应用程序的第三方边界处非常有用。例如,了解你的应用程序是否正确配置了数据库连接,使用了某些配置服务提供的凭据,这一点很重要。在某些情况下,测量被测试的方法或函数的副作用可能很困难。在这些情况下,你可以使用间谍来确保你首先调用了该方法或函数。

间谍(Spies)是通过从存根(stub)开始,并添加确定函数是否被调用、函数被调用的次数,或者报告传递给该函数的值的函数来创建的。

C#示例

以下是一个 C#中的SpySpeakerContactService的示例。SpySpeakerContactService允许你确定服务是否被调用,以及它可能被调用多少次。

class SpySpeakerContactService : ISpeakerContactService
{
  public bool MessageSpeakerHasBeenCalled { get; private set; }

  public int MessageSpeakerCallCount { get; private set; }

  public void MessageSpeaker(string message)
  {
    MessageSpeakerHasBeenCalled = true;
    MessageSpeakerCallCount++;
  }
}

JavaScript 示例

以下是一个 JavaScript 中spySpeakerReducer的示例。spySpeakerReducer允许你确定它可能被调用多少次。

import { speakerReducer as original_speakerReducer } from './reducers';

 export let callCounter = 0;

 export function *spySpeakerReducer*(state, action) {
   callCounter++;

   return original_speakerReducer(state,action);
 } 

模拟(Mocks)

模拟(Mocks)基本上是可编程的间谍。当你想要在多个测试中使用相同的测试替身时,模拟(Mocks)非常有用。模拟能够返回你设置的任何值。需要注意的是,模拟仍然没有执行任何逻辑。它们返回指定的值,并不检查传递给函数的参数。

模拟(Mocks)用于所有使用存根(dummies)、存根(stubs)和间谍(spies)的情况。模拟是测试替身的一个更重的实现,这也是为什么你可能不想总是使用它们的原因。与之前的测试替身相比,模拟的重用性较低,因为模拟的数据必须为每个测试设置,而存根、存根或间谍有一个固定的返回值,不需要配置。设置返回的测试数据通常比创建整个存根或间谍类更困难。

模拟(Mocks)是通过复制一个类或方法并创建一个可以设置为方法返回值的属性来创建的;然后,在正在模拟的方法中,返回该属性值。一旦创建,在每次测试之前,必须设置模拟的返回值。

C#示例

以下是一个 C#中的MockDateTimeService示例。MockDateTimeService允许您设置服务返回的DateTime,以便可靠地测试系统其他部分基于特定DateTime可能的行为。

class MockDateTimeService
{
  public DateTime CurrentDateTime { get; set; } = new DateTime();

  public DateTime UTCNow()
  {
    return CurrentDateTime.ToUniversalTime();
  }
}

JavaScript 示例

以下是一个 JavaScript 中的MockDateTimeService示例。与 C#中的MockDateTimeService类似,这允许您设置服务返回的DateTime,以便可靠地测试系统其他部分基于特定DateTimes可能的行为。

export class MockDateTimeService {
   constructor() {
     this.currentDateTime = new Date(2000, 0, 1);
   }

   now() {
     return this.currentDateTime;
   }
 }

模拟

模拟是最后也是最强大的测试双胞胎类型。模拟是一个试图表现得像它不是一个测试双胞胎的类。虽然模拟不会连接到数据库,但它会尝试表现得就像它正在连接到数据库一样。模拟不会使用系统时钟,但它会尝试拥有一个尽可能接近系统时钟的内部时钟。

模拟要么添加额外的测试功能,要么防止第三方库和系统的外部干扰。大多数应用程序都连接到某些数据源。可以创建一个使用其自己的内存数据源的模拟仓库,但除此之外,它的行为就像一个正常的数据连接。

模拟是通过生成一个全新的类或方法,然后编写足够的功能以使其与生产类或方法不可区分来创建的。对于模拟与生产类或方法之间的唯一重要区别是,模拟不进行外部连接,并且可能允许测试人员控制底层数据集。

C#示例

以下是一个FakeRepository及其相关接口的示例。FakeRepository是一个泛型仓库的模拟实现。

public interface IRepository<T>
{
  T Get(Func<T, bool> predicate);
  IQueryable<T> GetAll();
  T Save(T item);
  IRepository<T> Include(Expression<Func<T, object>> path);
}

public interface IIdentity
{
  int Id {get;set;}
}

public class FakeRepository<T> : IRepository<T> where T : IIdentity
{
  private int _identityCounter = 0;
  public IList<T> DataSet { get; set; } = new List<T>();

  public T Get(Func<T, bool> predicate)
  {
    return GetAll().Where(predicate).FirstOrDefault();
  }

  public IQueryable<T> GetAll()
  {
    return DataSet.AsQueryable();
  }

  public T Save(T item)
  {
    return item.Id == default(int) ? Create(item) : Update(item);
  }           

  public IRepository<T> Include(Expression<Func<T, object>> path)
  {
    // Nothing to do here since this function is for EntityFramework
    // We are using Linq to Objects so there is not need for Include
    return this;
  }

  private T Create(T item)
  { 
    item.Id = ++_identityCounter;
    DataSet.Add(item);
    return item;
  }

  private T Update(T item)
  {
    var found = Get(x => x.Id == item.Id);

    if(found == null)
    {
      throw new KeyNotFoundException($"Item with Id {item.Id} not    
        found!");
    }

    DataSet.Remove(found);
    DataSet.Add(item);

    return item;
  }
}

JavaScript 示例

以下是一个 JavaScript 中的FakeDataContext示例。

export class FakeDataContext {
   _identityCounter = 1;
   _dataSet = [];

   get DataSet() {
     return this._dataSet;
   }

   set DataSet(value) {
     this._dataSet = value;
   }

   get(predicate) {
     if (typeof(predicate) !== 'function') {
       throw new Error('Predicate must be a function');
     }

     const resultSet = this_dataSet.filter(predicate);

     return resultSet.length >= 1 ? {...resultSet[0]} : null;
   }

   getAll() {
     return this._dataSet.map((x) => {
       return {...x};
     });
   }

   save(item) {
     return item.id ? this.update(item) : this.create(item);
   }

   update(item) {
     if (!this._dataSet.some(x => x.id === item.id)) {
       this._dataSet.push({...item});
     } else {
       let itemIndex = this._dataSet.findIndex(x => x.id === item.id);
       this._dataSet[itemIndex] = {...item};
     }

     return {...item};
   }

   create(item) {
     let newItem = {...item};
     newItem.id = this._identityCounter++;
     this._dataSet.push({...newItem});

     return {...newItem};
   }
 } 

N 层示例

现在,将您的注意力转回到第二章中的 API 控制器,“设置.NET 测试环境”。直接从控制器返回硬编码的数据并不能为构建应用程序提供一个坚实的基础。大多数现代的任何规模的.NET 应用程序都是用某种 N 层架构编写的。您会希望将您的业务逻辑与您的表示层分离,在这个例子中,就是 API 端点的表示层。

我们将引入一个演讲服务的接口,为使用依赖注入向控制器提供具体实现做准备,然后验证新服务中的正确方法是否被调用。您可能需要重新排列一些测试,以从控制器中移除业务逻辑。

表示层

要开始,添加一个新的测试来验证控制器是否接受ISpeakerService接口

[Fact]
public void ItAcceptsInterface()
{
  // Arrange 
  ISpeakerService testSpeakerService = new TestSpeakerService();

  // Act
  var controller = new SpeakerController(testSpeakerService);

  // Assert
  Assert.NotNull(controller);
}

现在,通过在SpeakerController中创建一个接受ISpeakerService接口的构造函数,引入一个字段变量和构造函数到你的speaker controller类中,来使测试通过:

public SpeakerController(ISpeakerService speakerService)
{
}

你的测试项目现在应该无法编译。这是因为在我们之前的例子中,从第二章,设置.NET 测试环境,我们在测试类的构造函数中定义了控制器实例。修改构造函数以创建一个实现ISpeakerService接口的TestSpeakerService实例,并将其传递给SpeakerController。你可以在测试类中自由创建TestSpeakerService

public SpeakerControllerSearchTests()
{
  var testSpeakerService = new TestSpeakerService();

  _controller = new SpeakerController(testSpeakerService);
}

现在,你将想要验证SpeakerServiceSearch方法是否被控制器调用。但是,如何做到这一点呢?一种方法是通过一个名为Moq的模拟框架。

Moq

要将Moq添加到你的单元测试项目中,右键单击你的测试项目,选择管理 NuGet 包。搜索Moq,并选择安装最新稳定版本。我们不会深入探讨Moq,但我们会展示模拟框架如何帮助促进对应用程序边界的测试。

添加一个测试来验证SpeakerServiceSearch方法是否从控制器的Search动作结果中调用一次:

[Fact]
public void ItCallsSearchServiceOnce()
{ 
  // Arrange
  // Act
  _controller.Search("jos");

  // Assert
  _speakerServiceMock.Verify(mock => mock.Search(It.IsAny<string>()), 
      Times.Once());
}

为了使测试通过,你还需要在test类的构造函数中进行一些额外的设置:

private readonly SpeakerController _controller;
private static Mock<ISpeakerService> _speakerServiceMock;

public SpeakerControllerSearchTests()
{
  var speaker = new Speaker
  {
    Name = "test"
  };

  // define the mock
  _speakerServiceMock = new Mock<ISpeakerService>();

  // when search is called, return list of speakers containing speaker
  _speakerServiceMock.Setup(x => x.Search(It.IsAny<string>()))
      .Returns(() => new List<Speaker> { speaker });

  // pass mock object as ISpeakerService
  _controller = new SpeakerController(_speakerServiceMock.Object);
}

确保修改接口,以便应用程序可以编译:

public interface ISpeakerService
{
  IEnumerable<Speaker> Search(string searchString);
}

现在,通过确保从控制器的Search动作结果中调用SpeakerServiceSearch方法来使测试通过。如果你还没有这样做,创建一个名为_speakerServicefield变量,并在构造函数中通过speakerService参数进行赋值:

private readonly ISpeakerService _speakerService;

public SpeakerController(ISpeakerService speakerService)
{
  _speakerService = speakerService;
}

[Route("search")]
public IActionResult Search(string searchString)
{
  var hardCodedSpeakers = new List<Speaker>
  {
    new Speaker{Name = "Josh"},
    new Speaker{Name = "Joshua"},
    new Speaker{Name = "Joseph"},
    new Speaker{Name = "Bill"},
  };

  _speakerService.Search("foo");

  var speakers = hardCodedSpeakers.Where(x =>   
                       x.Name.StartsWith(searchString,  
                       StringComparison.OrdinalIgnoreCase)).ToList();

  return Ok(speakers);
}

接下来,添加一个测试来验证控制器Search动作结果提供的searchString与传递给SpeakerServiceSearch方法的searchString是否相同:

[Fact]
public void GivenSearchStringThenSpeakerServiceSearchCalledWithString(){
  // Arrange
  var searchString = "jos";

  // Act
  _controller.Search(searchString);

  // Assert
  _speakerServiceMock.Verify(mock => mock.Search(searchString),   
      Times.Once());
}

通过向_speakerServiceSearch方法提供searchString来使测试通过:

  _speakerService.Search(searchString);

现在,确保SpeakerServiceSearch方法的结果是动作结果返回的内容:

[Fact]
public void GivenSpeakerServiceThenResultsReturned()
{
  // Arrange
  var searchString = "jos";

  // Act 
  var result = _controller.Search(searchString) as OkObjectResult;

  // Assert
  Assert.NotNull(result);
  var speakers = ((IEnumerable<Speaker>)result.Value).ToList();
  Assert.Equal(_speakers, speakers);
}

记住,SpeakerServiceSearch方法返回的结果是由Mock定义的。你需要提取一个field来测试返回给动作结果的结果与为我们的Mock定义的结果是否相同:

private readonly SpeakerController _controller;
private static Mock<ISpeakerService> _speakerServiceMock;
private readonly List<Speaker> _speakers;

public SpeakerControllerSearchTests()
{
  _speakers = new List<Speaker> { new Speaker
  {
    Name = "test"
  } };

  _speakerServiceMock = new Mock<ISpeakerService>();
  _speakerServiceMock.Setup(x => x.Search(It.IsAny<string>()))
                .Returns(() => _speakers);

  _controller = new SpeakerController(_speakerServiceMock.Object);
}

仍然存在硬编码数据的问题。在使测试通过的同时,别忘了删除不必要的代码。记住红、绿、重构。这同样适用于你的生产代码以及测试代码。

在你删除硬编码数据后,可能会遇到一些失败的测试。目前,跳过这些测试,因为我们将会将这个逻辑移动到应用程序的另一个部分。现在,是时候创建一个SpeakerService了:

xUnit
 [Fact(Skip="Reason for skipping")]
MSTest
 [Skip]

业务层

你可能需要开始考虑如何有效地组织你的测试。随着你的应用程序的增长,测试文件的数量增加,你可能会发现导航你的解决方案变得越来越繁琐。一个可能的解决方案是为每个待测试的类创建单独的文件夹,并在类文件夹中为每个公共方法创建一个单独的文件。这可能会看起来像这样:

SpeakerService -> Search

你现在不一定需要处理这个问题,但为未来制定一个计划不会有害。应用程序往往会迅速增长,在你意识到之前,你可能会在你的解决方案中拥有十三个项目。你现在可以选择创建一个Services项目,并在此同时创建一个ServicesTest项目,以将业务层及其相关测试与表示层及其测试分开。这将被留给读者作为练习。

现在,为SpeakerService创建一个新的测试类。这里是你将在SpeakerService中创建所有Search测试方法的地方:

[Fact]
public void ItExists()
{
  var speakerService = new SpeakerService();
}

一旦这个测试通过,创建一些新的测试来确认Search方法存在并且返回一个演讲者集合:

[Fact]
public void ItHasSearchMethod()
{
  var speakerService = new SpeakerService();

  speakerService.Search("test");
}

接下来,测试SpeakerService是否实现了ISpeakerService接口:

[Fact]
public void ItImplementsISpeakerService()
{
  var speakerService = new SpeakerService();

  Assert.True(speakerService is ISpeakerService);
}

你的SpeakerService现在可能看起来像这样:

public class SpeakerService : ISpeakerService
{
  public IEnumerable<Speaker> Search(string searchString)
  {
    return new List<Speaker>();
  }
}

记住,要慢而有序地进行。你不允许在不编写失败的测试的情况下编写任何生产代码,而且你不应该编写比使测试通过所必需的更多的生产代码。

现在,开始将s*kipped*测试从controller test文件移动到Speaker Service Search Test文件。从GivenExactMatchThenOneSpeakerInCollection开始:

[Fact]
public void GivenExactMatchThenOneSpeakerInCollection()
{
  // Arrange
  // Act
  var result = _speakerService.Search("Joshua");

  // Assert
  var speakers = result.ToList();
  Assert.Equal(1, speakers.Count);
  Assert.Equal("Joshua", speakers[0].Name);
}

让这个测试通过,然后继续到GivenCaseInsensitveMatchThenSpeakerInCollection

[Theory]
[InlineData("Joshua")]
[InlineData("joshua")]
[InlineData("JoShUa")]
public void GivenCaseInsensitveMatchThenSpeakerInCollection(string searchString)
{
  // Arrange
  // Act
  var result = _speakerService.Search(searchString);

  // Assert
  var speakers = result.ToList();
  Assert.Equal(1, speakers.Count);
  Assert.Equal("Joshua", speakers[0].Name);
}

最后,GivenNoMatchThenEmptyCollectionGiven3MatchThenCollectionWith3Speakers

[Fact]
public void GivenNoMatchThenEmptyCollection()
{
  // Arrange
  // Act
  var result = _speakerService.Search("ZZZ");

  // Assert
  var speakers = result.ToList();
  Assert.Equal(0, speakers.Count);
}

[Fact]
public void Given3MatchThenCollectionWith3Speakers()
{
  // Arrange
  // Act
  var result = _speakerService.Search("jos");

  // Assert
  var speakers = result.ToList();
  Assert.Equal(3, speakers.Count);
  Assert.True(speakers.Any(s => s.Name == "Josh"));
  Assert.True(speakers.Any(s => s.Name == "Joshua"));
  Assert.True(speakers.Any(s => s.Name == "Joseph"));
}

随着你对这个实践越来越熟悉,并且对 TDD 有更多的经验,你可能发现列出你想要实现的所有测试是有帮助的。这可以简单地是在一张纸上写下它们,或者在你的 IDE 中为跳过的或忽略的测试创建一些存根。

如果做得正确,你的代码应该看起来像这样:

public class SpeakerService : ISpeakerService
{
  public IEnumerable<Speaker> Search(string searchString)
  {
    var hardCodedSpeakers = new List<Speaker>
    {
      new Speaker{Name = "Josh"},
      new Speaker{Name = "Joshua"},
      new Speaker{Name = "Joseph"},
      new Speaker{Name = "Bill"},
    };

    var speakers = hardCodedSpeakers.Where(x => 
                        x.Name.StartsWith(searchString, 
                        StringComparison.OrdinalIgnoreCase)).ToList();

    return speakers;
  }
}

我们现在已经将硬编码的数据从我们的控制器移到了SpeakerService的业务层。你可能认为我们付出了很多努力,仅仅是为了将问题移到一个新的文件!虽然这在一定程度上是正确的,但这实际上使我们处于更好的位置,以便于未来的开发。所谓的“逻辑”已经被移动到一个可以被应用程序的其他部分以及潜在的新接口(例如原生和/或移动应用程序)重用的类中,而这些接口无法访问我们的原始控制器。

我们将在未来的章节中继续这个例子。我们最终将摆脱硬编码的数据,并使用 Entity 框架实现数据访问层。所有这些都可以通过测试驱动开发来完成。

摘要

在本章中,我们讨论了一些会阻碍 TDD 的陷阱,例如对第三方库的依赖、直接实例化类以及脆弱的测试。我们还讨论了避免或解决这些问题的方法。我们介绍了并讨论了每个 SOLID 原则。我们还讨论了不同类型的测试替身以及何时使用每种类型。最后,我们给出了一个 N 层应用的简短示例以及如何对其进行测试。

在第五章,“Tabula Rasa – Approaching an Application with TDD in Mind”,我们将探讨如何带着 TDD(测试驱动开发)的思路去接近一个应用程序,将理论转化为实践,以及如何通过测试更好地发展应用程序。

第五章:Tabula Rasa – 以 TDD 的心态处理应用程序

开发一个完整的应用程序似乎是一项艰巨的任务,特别是使用测试驱动开发TDD)。到目前为止,所有的例子都相对较小。函数和方法具有微小、有限的范围。TDD 在开发完整的应用程序时是如何转换的?实际上,相当不错。

本章讨论的主题包括:

  • Yak shaving

  • 早期大设计

  • YAGNI

  • 测试要小

  • 恶魔的辩护者

从哪里开始

最好的开始方式就是从开始的地方开始。在开发者开始编码之前,他们必须知道程序的目标是什么。应用程序的目的是什么?如果没有对试图解决的问题有清晰的理解,那么开始可能会很困难。至少,在没有某种计划的情况下开始是不明智的。

你开始编码越早,程序就会越晚完成。

– 罗伊·卡尔森,威斯康星大学

你有没有在没有目标的情况下开始一个手工艺项目?你是如何知道你要做什么的?项目结果如何?如果结果不错,你很可能在某个时候选择了一个方向,并开始实现目标。你可能甚至不得不从头开始或途中进行调整,以便完成项目。

现在,想象一下在事先定义好期望结果的情况下开始同一个手工艺项目。也许你想要画一幅画。也许你制定了一套计划。直到你开始实际开始项目的物理行动之前,你都不会有清晰的理解。在这个例子中,成功的可能性要大得多。在过程中遇到障碍的机会最小化。

这是否意味着所有问题都需要提前提出?在开始之前是否应该获得所有答案?当然不是。有时,对问题只有肤浅的了解就足以开始。但是,目标越明确,开发正确解决方案的可能性就越大。

Yak shaving

在前几章提供的示例中,你可能已经注意到有很多代码的移动,这些代码似乎没有立即的益处。在 TDD 中,尤其是在项目的初期,有些工作看起来似乎没有太多意义。编写的测试除了证明类或方法的存在之外,没有做更多的事情。代码重构的方式只是将硬编码的值推入另一个依赖项。这意味着会创建更多的文件,你可能会发现自己正在编写大量的辅助类。所有这些活动都被称为“剃羊毛”。

“剃羊毛”有两个与软件开发相关的含义。第一个也是需要避免的是,为了拖延而编写不需要的东西。第二个是指完成所有必须做的事情来准备代码的行为。两者之间的区别是细微的。你站在哪一边取决于你编写代码的意图。你是避免编写应该写的代码,还是使用 TDD 来为高效和有效的软件开发打下基础?

在我们的案例中,正如前面章节所讨论的,我们要么在为未来的测试打下基础,要么在测试中实施防止写作障碍的已知技术。有时,为测试准备应用程序的过程可能需要相当长的时间。

当在遗留应用程序中工作时,你可能需要花费一周的大部分时间来创建工厂、向现有类添加接口、编写测试替身或进行安全的重构技术。所有这些活动都有助于提高可测试性和确保测试体验的顺畅。然而,避免沉迷于这些活动是很重要的。我们只想通过这些活动来推动下一个测试的进行。

大规模设计

以前,拥有一个漫长且昂贵的软件开发生命周期SDLC)是一种常见的做法。组建了大型团队。会议被安排,讨论无休止地进行。收集需求并创建文档,这些文档消耗了大量的纸张,足以填满每个团队成员的档案柜。系统的设计通常是通过民主方式组装的,并为系统制定了一个计划。

一旦管理层和/或执行团队满意,开发就可以开始了。这个漫长而繁琐的过程通常意味着预算已经因为规划阶段的每个人时间成本而大幅减少。如果在开发周期中发现设计中的缺陷,通常会发出变更订单和一系列会议。

如果由于市场变化或其他外部条件的变化而导致需求发生变化,这可能会使整个项目偏离轨道。如果软件开发生命周期(SDLC)不允许快速适应变化和快速调整方向,那么这通常会给整个项目带来灾难。更糟糕的是,如果变化足够大,它可能会使应用程序的需求变得过时。

不幸的是,以这种方式开发软件成本很高,而且失败的可能性比成功要大。变化的成本太高,由此产生的干扰往往对过程有害。如今,软件项目更有可能以某种敏捷的方式进行开发。

一张干净的画布

那么,我们如何使用 TDD 开始一个新的应用?带着 TDD 的想法开始实际上与开始任何软件开发项目没有太大区别。开发者必须对应用的目标有一个大致的了解。基本需求应该被理解。就像我们通过测试来扩展应用一样,需求应该随着时间的推移而增长。

一口一口吃

你怎么吃大象?一口一口吃。

尝试一次性定义和开发一个单体应用是一项庞大的任务。如果你被分配去创建 Facebook,你可能不知道从哪里开始。但是,如果你将应用分解为逻辑部分,例如登录用户仪表板新闻源,它就会变得更容易管理。

最小可行产品

每个工作定义都应该分解成小的可交付成果。最小可行产品的概念可以应用到我们代码的各个方面。随着单体应用的需求被分解成可管理的块,可能开始编码。如果一个编程任务只需要几个小时就能完成,那么很难交付一个完全偏离目标的东西。然而,如果需要变更,应该提供反馈,并且可以快速进行调整。

不同的思维方式

当应用以 TDD 为导向进行开发时,你应该对小的可交付成果采取同样的方法。写一点测试,写足够的代码使其通过,然后重构。如果你一直在运行测试套件,或者更好的是,你正在使用像 NCrunch 这样的持续测试运行器,你的反馈周期确实应该非常快。

永远不要留下未关注的测试,或者一次不要忽略超过一个测试。

如果在开发周期中测试开始失败,应该很容易恢复。刚刚编写的代码就是问题所在。暂停当前的工作并评估。这个变更是否必要?失败的测试是否需要更改?如果需要,可以跳过(xUnit)或忽略(MSTest)当前的测试。修复代码,然后通过取消忽略测试来继续。永远不要留下未关注的测试,或者一次不要忽略超过一个测试。这样做只会增加测试(或者更糟,多个测试)永远不会完成、修复或恢复的风险。一个被忽略的测试没有任何价值。如果测试在以后被你或其他人取消忽略,并且现在(或仍然)失败,可能很难确定测试是否有效并指示真正的失败,或者无效并可能让你走上歧途。确保你的测试是有效、准确并提供价值的。

YAGNI - 你不需要它

有时,你可能会被迫编写一些代码,因为你认为你需要它。这只是一个简单的方法。如果你有一张满载数据的数据表,你可能需要一个GetAll方法和一个GetById方法。在这里提醒一下:在没有真正需求之前,不要编写任何代码。编写的代码越多,需要维护的代码就越多。如果你编写了你认为可能需要但从未实际使用的代码,你就浪费了努力。更糟糕的是,你引入了必须维护直到或除非它被删除的代码。

不要为了未来的需求而编写代码。这是浪费的,并且通常开发和维护成本高昂。

测试小规模

在进行 TDD 时,要考虑的最重要的事情之一是测试的大小和范围。TDD 是一项完全理解你试图解决的问题的练习,并且能够尽可能地将解决方案分解成尽可能多的微小部分。

作为例子,让我们考虑一些简单的事情:一个管理需要完成的项目列表的应用程序。我们如何将这个应用程序的使用案例分解?

首先,使用我们讨论的剃羊毛方法,我们可以验证应用程序是否存在。

public class ToDoApplicationTests
{
  [Fact] 
  public void TodoListExists()
  {
    var todo = new TodoList();

    Assert.NotNull(todo);
  }
}

internal class TodoList
{
  public TodoList()
  {
  }
}

接下来,验证你是否能够检索待办事项的列表。

[Fact]
public void CanGetTodos()
{
  // Arrange
  var todo = new TodoList();

  // Act
  var result = todo.Items;

  // Assert
  Assert.NotNull(result);
}

魔鬼的代言人

我们将继续展示小规模的测试,但已经触及了下一个例子。在许多情况下,充当魔鬼的代言人是一种有用的技巧。在 TDD 中,我们通过想象使测试通过的最简单、可能也是最错误的方法来充当魔鬼的代言人。我们希望迫使测试使代码正确,而不是编写我们认为正确的代码。例如,在这种情况下,我们的目标是使刚刚编写的测试通过添加一个Items列表。但在这个阶段,测试并不需要这一点。它只要求 Items 作为类的一个属性存在。测试中没有指定类型。因此,为了充当魔鬼的代言人,使用Object作为类型,并将Items对象设置为简单的非空值。

internal class TodoList
{
  public object Items { get; } = new object();

  public TodoList()
  {
  }
}

好的,现在所有测试都通过了,但这显然不是一个合适的解决方案。从小处着手,我们可以迫使实现有一个计数,这肯定需要它是一个Todos列表的集合。在最后一个测试中添加以下内容:

Assert.Empty();

为了使它通过,Items必须改变:

public IEnumerable<Object> Items { get; } = new List<Object>();

记住我们在第四章“开始之前要知道什么”中讨论的 SOLID 原则。我们希望使用接口隔离,并限制自己只使用所需的接口。我们不需要完整的IList接口功能,因此不需要使用它。所需的是能够遍历项目集合的能力。为此,最简单的接口是IEnumerable

然而,我们仍然有一个问题:我们正在使用Object作为我们的可枚举类型。我们只想使用一个特定的类。现在让我们解决这个问题。修改最后一个测试,包括一个类型断言。

[Fact]
public void CanGetTodos()
{
  // Arrange
  var todo = new TodoList();

  // Act
  var result = todo.Items;

  // Assert
  Assert.NotNull(result);
  Assert.IsAssignableFrom<IEnumerable<Todo>>(result);
  Assert.Empty();
}

现在,更新类,如下所示:

internal class TodoList
{
  public IEnumerable<Todo> Items { get; } = new List<Todo>();

  public TodoList()
  {
  }
}

public class Todo 
{
}

正如你所见,我们添加了一个看似相当小的测试,最终创建了一个属性,分配了一个默认值,并创建了一个类。你能想到任何方法可以使这个更小吗?

我们接下来的测试可能将验证待办事项项开始时是空的,但如果我们回顾 TDD(测试驱动开发)的法则,第一条法则是编写一个失败的测试。目前,如果我们编写一个验证Items为空的测试,我们会期望这个测试通过。那么,我们应该编写什么样的测试呢?

我们决定编写下一个测试,以验证添加待办事项项的方法。

[Fact]
public void AddTodoExists()
{
  // Arrange
  var todo = new TodoList();
  var item = new Todo();

  // Act
  todo.AddTodo(item);
}

internal class TodoList
{
  public IEnumerable<Todo> Items { get; } = new List<Todo>();

  public TodoList()
  {
  }

  internal void AddTodo(Todo item)
  {
  }
}

到目前为止,我们一直在采取一些可能与你正常开发中采取的步骤相似的步骤,将大量功能切割到代码中。这是第一个在我们真正实现有价值功能之前就停止的测试。这是采取那些小步骤的一部分。我们目前可以部署这个应用。它不会很有用,但我们确实有这个选项。如果我们已经到达了冲刺的终点,产品负责人可能会要求为了尽快部署,我们硬编码一些待办事项,以便在 UI 中提供一些内容。

我们接下来的测试看起来相当直接。我们将验证我们是否真的可以使用我们的新方法添加一个待办事项。但是有一个问题,因为这个测试是在测试功能而不是通用类结构。我们建议为这个方法创建一个专门的测试类。

public class TodoListAddTests
{
  [Fact]
  public void ItAddsATodoItemToTheTodoList()
  {
    // Arrange
    var todo = new TodoList();
    var item = new Todo();

    // Act
    todo.AddTodo(item);

    // Assert
    Assert.Single(todo.Items);
  }
}

internal class TodoList
{
  private List<Todo> _items = new List<Todo>();

  public IEnumerable<Todo> Items
  {
    get
    {
      return _items;
    }
  }

  public TodoList()
  {
  }

  public void AddTodo(Todo item)
  {
    _items.Add(item);
  }
}

现在,这真是一个从悬崖上飞扑下来的跳跃。那个测试几乎改变了我们应用的所有代码。我们完全改变了Items的实现,并在AddTodo方法中添加了代码。我们能否将这些分成两个或更多步骤?我们还有许多事情要做,我们将会覆盖其中的一些。但在我们继续之前,写下你认为你会编写的下一个几个测试。尽量别跳过这个练习,因为将功能分解成这样的小块是大多数开发者学习 TDD 时遇到的最大难题之一。

我们将暂时暂停这个示例应用的进度,因为我们已经陷入了一个困境。为了防止受阻,我们应该首先测试负案例。

首先测试负案例

首先要测试负案例意味着什么?在许多电脑游戏中,尤其是在角色扮演游戏中,游戏设计师通常会设计得非常困难,如果你直接去挑战 Boss,就很难赢得游戏。相反,你必须完成支线任务,走错路,在故事中迷失方向,才能与 Boss 战斗。测试也是如此。在问题得到解决之前,我们必须首先处理不良输入,防止异常,并解决业务需求中的冲突。

在 Todo 应用程序中,我们错误地快速通过了添加一个项目到 Todo 列表,而没有验证该项目是否有效。现在,冲刺已经结束,我们的用户界面开发者对我们很生气,因为他们不知道如何处理一个没有任何详细信息的 Todo 项目。我们应该做的首先是处理我们收到的不良数据的情况。让我们回放并暂时跳过我们刚刚制作的测试。

[Fact(Skip="Forgot to test negative cases first")]
public void ItAddsATodoItemToTheTodoList()

我们现在需要编写的测试应该放在刚刚忽略的测试之上,但仍在同一个文件中。记住我们需要有小的测试增量,我们可以编写一个测试来防止最简单的坏数据,即null

[Fact]
public void OnNullAnArgumentNullErrorOccurs()
{
  // Arrange
  var todo = new TodoList();
  Todo item = null;

  // Act
  var exception = Record.Exception(() => todo.AddTodo(item));

  // Assert
  Assert.NotNull(exception);
  Assert.IsType<ArgumentNullException>(exception);
}

public void AddTodo(Todo item)
{
  throw new ArgumentNullException();
}

注意,我们已经移除了AddTodo中现有的代码。我们本可以保留这些代码,但在这个阶段,它们只是杂乱无章,而且目前没有测试强制要求这些代码存在。有时,当你忽略一个测试时,删除该测试所验证的功能性代码比绕过它更容易。有时,杂乱无章可能会限制你的重构努力,并导致更糟糕的代码。不要害怕删除被跳过的测试代码,也不要害怕删除进入源代码控制中的被跳过的测试。

在进行这个更改时,我们遇到的一个其他问题是,之前在TodoApplicationTests类中定义的AddTodoExists方法现在失败了。这个测试最初是一个剃须测试,并没有给测试套件增加任何真正的价值,所以直接移除它。

既然我们已经用我们的方法覆盖了空值情况,接下来可能出错的是什么?思考一下,Todo 是否需要必填字段?在我们将其添加到列表之前,我们可能需要确保 Todo 至少有一个标题或描述。

首先,在我们能够验证字段已被填充之前,我们需要验证该字段存在于模型上。编写模型测试可能看起来有点过度,但我们发现这些测试有助于更好地定义应用程序,供其他人进入。它们还提供了一个良好的附加点,用于稍后当业务决定 Todo 的描述字段最大长度为 255 个字符时的字段验证测试。让我们为 Todo 模型测试创建一个新的类。

public class TodoModelTests
{
  [Fact] 
  public void ItHasDescription()
  {
    // Arrange
    var todo = new Todo();

    // Act
    todo.Description = "Test Description";
  }
}

public class Todo
{
  public string Description { get; set; }
}

正如你所见,这类测试没有真正的断言。只需验证我们能否设置描述值而不抛出错误就足够了。

现在我们有了描述字段,我们可以验证它是必需的。

[Fact]
public void OnNullADescriptionRequiredValidationErrorOccurs()
{
  // Arrange
  var todo = new TodoList();
  var item = new Todo()
  {
    Description = null
  };

  // Act
  var exception = Record.Exception(() => todo.AddTodo(item));

  // Assert
  Assert.NotNull(exception);
  Assert.IsType(typeof(DescriptionRequiredException), exception); 
}

internal class TodoList
{
  ...

  public void AddTodo(Todo item)
  {
    item = item ?? throw new ArgumentNullException();

    item.Description = item.Description ?? throw new                   
                                           DescriptionRequiredException();
  }
}

我们已经很久没有重构了,这是一个暂停我们的测试工作并进行重构的好地方。我们希望将模型验证移动到模型中。让我们为待办模型上的验证方法创建一个快速测试,然后将该逻辑移动到Todo类中。

public class TodoModelValidateTests
{
  [Fact]
  public void ItExists()
  {
    // Arrange
    var todo = new Todo();

    // Act
    todo.Validate();
  }
}

public class Todo
{
  public string Description { get; set; }

  internal void Validate()
  {
  }
}

现在,至少目前,我们想把我们的验证逻辑从待办事项列表移动到模型中。在创建验证测试和移动逻辑的过程中,我们导致我们的剃羊毛测试失败。测试失败是因为,尽管存在必需的方法,但它抛出了异常,因为我们没有填充我们的待办事项的描述。我们将不得不移除这个测试,因为它不再增加价值。

public class TodoModelValidateTests
{
  [Fact]
  public void OnNullADescriptionRequiredValidationErrorOccurs()
  {
    // Arrange
    var item = new Todo()
    {
      Description = null
    };

    // Act
    var exception = Record.Exception(() => item.Validate());

    // Assert
    Assert.NotNull(exception);
    Assert.IsType(typeof(DescriptionRequiredException), exception); 
  }
}

public class Todo
{
  public string Description { get; set; }

  internal void Validate()
  {
    Description = Description ?? throw new DescriptionRequiredException();
  }
}

最后,我们在进行我们想要的重构更改之前需要编写的测试已经完成。现在我们可以简单地用对模型上的Validate的调用替换TodoList类中处理模型验证的异常逻辑。

public void AddTodo(Todo item)
{
  item = item ?? throw new ArgumentNullException();

  item.Validate();
}

这个更改对我们的测试或我们的结果逻辑不应有任何影响。我们只是在重新定位验证代码。可能还有更多的验证可以发生。你能想到一些可能很有价值的验证吗?

现在是时候添加回我们跳过的测试,并进行一些小的修改以通过验证。

[Fact]
public void ItAddsATodoItemToTheTodoList()
{
  // Arrange
  var todo = new TodoList();
  var item = new Todo
  {
    Description = "Test Description"
  };

  // Act
  todo.AddTodo(item);

  // Assert
  Assert.Single(todo.Items);
}

public void AddTodo(Todo item)
{
  item = item ?? throw new ArgumentNullException();

  item.Validate();

  _items.Add(item);
}

当测试变得痛苦时

有可能你可能会遇到一些痛苦。也许你因为你的设计而把自己逼到了角落。也许你不确定下一个最有趣的测试会是什么。当然,你并不是故意的,但可能你在测试之间跳得太大了。无论情况如何,可能有一段时间,测试会变得痛苦。

一个尖峰

如果你发现自己陷入了困境,或者在你如何继续前进的选项之间犹豫不决,运行一个尖峰可能会有所帮助。尖峰是一种你可以用来调查想法的手段。给自己设定一个时间限制或其他限制性指标。一旦通过练习获得了足够的知识或洞察力,就丢弃结果。尖峰的目的不是带走可工作的代码。目标应该是获得理解,并为前进的道路提供一个更好的想法。

首先断言

有时,你可能知道你想要编写的下一个测试,但并不完全确定如何开始。如果发生这种情况,从断言开始,以确定预期的结果。一旦定义了期望,就着手使实际值与期望值相匹配。你可能更愿意经常采取这种方法,以确保你只编写足够的代码来使所需的测试通过。

保持组织

记住,测试是应用程序的第一个消费者。你可以提供的最好和最准确的文档是一套全面且维护良好的测试。在你的测试套件中,创建文件夹、嵌套类或利用测试框架的功能来使你的测试更易于阅读。记住,如果你在以后遇到测试失败,一个描述性的测试名称和适当的断言将有助于描述结果是如何产生的。

使用Describe来更好地组织你的 JavaScript 测试。通过在测试中使用多个Describe来嵌套多个层级。

解构 Speaker Meet

Speaker Meet 应用始于一个简单的目标:连接技术演讲者、社区和会议。这个想法很简单,但可能会演变成广泛的复杂性。在早期阶段,决定从小处着手,并在有需要时添加功能。新想法应该能够以最小的努力实现和测试。如果一个想法被证明是网站错误的方向,新的功能可以轻松删除并放弃。简单开始,快速发布小功能以获取反馈。

初始网站定义了三个主要部分:演讲者社区会议。每个部分都需要列出所有演讲者/社区/会议,提供查看选定项目详细信息的方式,并提供基于预定义标准搜索项目的方式。这将是初始发布的最小可行产品。

演讲者

在一开始,决定演讲者将是首要的关注点。演讲者将包含姓名、电子邮件地址、技术选择和位置。Gravatar将用于提供头像。未来不包括在最小可行产品中的增强功能包括演讲列表、旅行距离和评分。通过关注这一有限的功能,可以收集初始反馈,并将未来的努力适当引导。

社区

Speaker Meet 应用次要的关注点围绕在技术社区。Meetup 和用户组通常由专门的志愿者运营,他们总是在寻找新的和有趣的演讲者来参加他们的会议。网站社区部分的主要目标是定义成员社区的名字、位置、会议日期/时间以及技术选择。

会议

技术会议是 Speaker Meet 网站的第三和最后一个关注点。会议在要求上与社区相似,即它们需要名称、位置、日期和技术选择。它们主要在规模、范围和日期上有所不同。用户组通常每月举行一次会议,一位演讲者可能向一小群人发表演讲。会议通常每年举行一次,持续一到多天,有多个演讲者向更多的参与者发表演讲。

技术要求

这个项目的技术选择是在团队的知识和经验基础上早期决定的。前端网站将使用 JavaScript 和 ReactJS。后端将使用 C#和 WebAPI,以及.NET Core、Entity Framework 和 SQL Server。所有这些都将托管在 Azure 上。在编码开始之前就了解这些技术需求对于定义你的系统部分大有裨益。

摘要

现在,你应该对 Yak shaving(剃牛毛)及其如何帮助你开始有所了解。你已经被告诫要避免前期大设计和为了可能需要某个功能而提前创建可能不需要的东西(YAGNI)。确保测试小部分,扮演魔鬼的代言人,并测试负面情况。

在第六章“解决问题”中,将更详细地讨论演讲者见面网站的三个部分。将投入更多努力将这些初始声明分解成有意义的需求和可管理的作业单元。

第六章:接近问题

在第五章,“Tabula Rasa – 以 TDD 的心态接近应用程序”,讨论了演讲者遇到应用程序的细节。需求已经在非常高的层面上被定义。用非常、非常宽的笔触描绘了一幅画面。许多应用程序的概念通常就是这样开始的,有一个高级描述和一个重要的关键功能被定义。它可能始于一张吧台纸或白板草图,但无论如何,某个地方、某种方式形成了一个想法。

在本章中,我们将涵盖:

  • 定义演讲者遇到的应用程序

  • 架构选择

  • 测试方向

定义问题

要定义问题,首先必须定义愿景。应该描述并概述清晰的目标。演讲者遇到问题的出现是由于技术演讲者寻找一个单一、集中的地方来寻找演讲机会和场所。确定用户群体和会议组织者同样需要寻求和找到为他们的会议提供演讲者的解决方案。

因此,演讲者遇到的想法产生了。但是,应用程序将如何工作?它应该是一个移动应用程序还是网站?数据将如何收集和管理?用户是否允许创建自己的个人资料?用户能否提交演讲者、社区和会议信息?应用程序将驻留在何处以及如何托管?而且我们在世界的哪个角落开始?

消化问题

应用程序将被设计用来解决的问题已经被定义。演讲者遇到将把技术演讲者、社区和会议聚集在一起。现在目的已经定义,必须消化。

如前一章所建议的,从所有方向攻击新的应用程序是不明智的。试图通过一次性实现每个期望的功能来接近新的软件项目是一项相当艰巨的任务。定义系统的每个愿望和需求也可能是一项庞大的工作。

最好定义小的、可管理的应用程序块,以便可以快速交付以评估其正确性和有效性。问题是,一个人如何定义可以分离成小块的内容,并确定这个小块具有足够的价值?

历史故事、特性和故事;哦,我的天啊!

许多软件开发项目将维护所谓的产品待办事项列表。这是系统可能被要求执行的所有事情被汇编的地方。产品待办事项列表可能包含从最大的想法到最微小的细节。重要的是这些想法被记录下来。

应定期整理和维护待办事项列表。应根据其重要性评估项目,并适当排序。如果确定某个项目是下一个需要工作的最重要的事情,则应将其拆分成适合团队及时有效交付的适当大小的故事。

史诗

较大、较广泛的想法被定义为史诗。这些可能具有相当重要的范围和规模。演讲者可能被定义为史诗。演讲者史诗是应用程序中用于与技术演讲者相关的一切和任何事物的部分。

术语“史诗”用于表示史诗中包含的功能和故事都围绕一个单一、中心的思想。这些本质上最初是一个单一的大型用户故事,然后被拆分成更小的功能和故事。史诗可能需要几个迭代才能完成。

功能

功能通常比史诗小,并且包含在史诗中。一个功能通常包含许多与它负责的主题相关的用户故事。将功能视为与史诗相同的方式,它们只是更小的分组。

一个功能可能包括一个演讲者目录演讲者详情。演讲者目录可能包含与在系统中显示、排序、过滤和搜索演讲者相关的一切。演讲者详情功能可能定义有关单个、个别演讲者信息及其在应用程序中显示的详细信息和功能。

用户故事

根据团队偏好,一个故事可能小到在查看其详情时看到演讲者的名字。提醒一下:故事可能太小。最好是将其拆分成足够小,以便开始工作,而不是在细节上浪费时间。如果做得正确,细节将在开发周期中自然出现。

确定足够小的事情应该留给团队决定。一个很好的经验法则是,故事应该需要半天到三天的时间来完成。少于半天的工作量,故事很可能会被拆分成过小的部分。多于三天的工作量,可能有机会将故事拆分成两个或更多个故事。

故事可能太小。

不要陷入将故事拆分成过小部分的陷阱。试图编写越来越小的故事可能会浪费精力。如果你在实践 Scrum,记得在每个迭代的结束时可以并且应该提出小的改进建议。在回顾会议期间,应讨论故事大小及其有效性。如果决定大小不合适,无论是太大还是太小,应在下一个迭代开始之前进行调整。

维护您的待办事项列表

那么,为什么保持产品待办事项列表如此重要呢?一个维护良好的待办事项列表将定义团队应该做什么工作,并帮助他们规划已知和即将到来的任务。这也有助于团队制定预测,以便规划特定功能可能完成的时机。

如果捕获了适当的指标,一个纪律严明的团队可以在每个冲刺中交付可靠的速率。通过合理大小和估计的故事,可以预测待办事项中即将到来的项目的合理时间表。例如,如果一个团队在每个冲刺中可靠地交付了 20 个点,而下一个五个故事被估计为每个 8 个点,那么预计这五个故事将在大约两个冲刺内完成是合理的。当然,这并不是承诺,而只是一个估计。

演讲者会议问题

记住,应用程序的范围最初将保持小而有限。一些特性现在可能被识别为未来的项目,但最小可行产品的有限范围仍需要更好地定义。如果确定这些特性对首次发布不是必需的,将继续向产品待办事项中添加更多特性,并将它们的优先级设定得相当低。然而,考虑到最小可行产品仍需要提供一些价值。一个什么也不做的软件应用对任何人都没有多少价值。

花时间优先考虑特性和故事的潜在价值,将有助于决定应该包含在初始发布中的内容,以及可以等待的内容。通过确定提供特定功能所需的工作量,并将这些信息与提议的价值相结合,可以做出关于哪些特性将首先交付的明智决定。

有意义的分离

对应用程序提出的特性的头脑风暴有助于描述系统。找到有意义的、逻辑上的分离将有助于定义软件解决方案特定部分的范围。逻辑边界可能包括产品待办事项中定义的史诗和特性。它们也可能由技术划分决定。

演讲者

演讲史诗将由围绕应用程序演讲部分的全部特性和故事组成。这包括演讲者目录和演讲者详细信息。本节还将包含未来可能添加的任何增强功能和特性。未来的功能可能包括演讲者评分和评论、幻灯片、演示文稿、YouTube 或 Vimeo 视频,等等。这些尚未确定,可以在稍后评估,届时可以权衡提议的价值。

并非所有功能都需要一开始就决定。记住,朝着最小可行产品努力,并在需要时构建功能。

以下是演讲史诗的基本特性故事:

As user group organizer
 I want to see a listing of all speakers
 So that I can find speakers for my user group.

As conference organizer
 I want to see details of a particular speaker
 So that I might view more information about them.

我们使用故事格式来描述应用的所有级别的细节。也就是说,史诗以故事的形式呈现,主题或特性以故事的形式呈现,具体需求也以故事的形式呈现。在层次结构中,只有具体需求被称为用户故事。给它们所有故事格式的原因很简单。我们希望能够编写一个需求,并能够以最小的麻烦将其从用户故事转换为特性或甚至史诗。因此,我们使用相同的格式来编写需求,无论该需求抽象级别如何。

这些特性故事是一个良好的开端。这将给企业提供机会在确定首先应该做什么之前对特性进行评分和优先排序。当向团队展示时,这些特性故事很可能会被分解成更小、更详细的用户故事,并带有验收标准。

良好的验收标准将帮助团队确定何时可以标记故事为完成。如果所有条件都已满足,则故事完成并可交付。如果在某个时候,决定需要更多的工作才能交付所需的功能,则应包括额外的标准或向待办事项列表中添加新的故事。

As user group organizer
 I want to see a listing of all speakers
 So that I can find speakers for my user group.
Given system contains speakers
 When viewing speaker catalog
 Then a listing of all speaker summaries is returned.
As conference organizer
 I want to see details of a particular speaker
 So that I might view more information about them.
Given specified speaker exists
 When speaker selected
 Then speaker details are returned.
Given specified speaker does not exists
 When speaker selected
 Then a friendly error message should be returned.

社区

用户组和聚会构成了应用中的社区部分。此应用部分的主要目的是为演讲者、潜在成员和参会者提供一个地方,以便他们可以找到和发现他们所在地区的技术社区。任何前往特定城市的旅行者也可能对了解哪些用户组或聚会可供他们参加感兴趣,无论是为了演讲还是一般参加。应用中的社区部分将包括社区目录和用户组详情。如果将来提出任何增强建议,它们可以作为新的特性或用户故事添加到社区史诗中。

在不久的将来,系统中的社区部分将增加位置搜索功能。这将使用户能够根据距离搜索社区,也许允许演讲者确定他们可能感兴趣的、半径为 200 英里的社区。这个功能被确定为 Speaker Meet 应用程序初始版本中不必要的功能。

一份社区特性故事的简短列表可以在这里找到:

As a speaker
 I want to see a listing of all communities
 So that I can find potential user groups at which to speak.
As a speaker
 I want to see details of a particular community
 So that I can learn more about the user group.

与演讲者特性故事类似,社区特性故事将帮助产品所有者优先考虑要开发的功能。这些特性故事也可能会被分解成更小、更详细的用户故事,并带有验收标准。请查看这里的故事:

As a speaker
 I want to see a listing of all communities
 So that I can find potential user groups at which to speak.
Given system contains communities
 When viewing community catalog
 Then a listing of all user groups is returned.
As a speaker
 I want to see details of a particular community
 So that I can learn more about the user group.
Given community selected
 When specified community exists
 Then community detail returned.
Given community selected
 When specified community does not exists
 Then a friendly error message should be returned.

会议

应用中关于会议的细节和功能在会议史诗中定义和描述。这包括会议目录和会议详情。未来在稍后日期提出的增强功能和特性可能被添加到会议史诗中。

会议也可能利用位置搜索。有各种各样的第三方服务可用,并且它们可以被评估以包含在未来的版本中。像所有第三方代码一样,这些将被从主应用程序中抽象出来,以便系统免受潜在变化的影响。

As a speaker
 I want to see a listing of all conferences
 So that I can find conferences at which to speak.
As a speaker
 I want to see details of a particular conference
 So that I can learn more about the conference.

会议与社区不同,因为它们每年只举行一次,通常有众多演讲者和会议。会议功能故事将帮助产品负责人优先考虑要开发的功能。这些也可能需要被分解成更小、更详细的用户故事,并带有验收标准。请查看这里的故事:

As a speaker
 I want to see a listing of all conferences
 So that I can find conferences at which to speak.
Given system contains conferences
 When viewing conference catalog
 Then a listing of all conferences is returned.
As a speaker
 I want to see details of a particular community
 So that I can learn more about the user group.
Given specified conference exists
 When conference selected
 Then conference detail returned.
Given specified conference does not exists
 When conference selected
 Then a friendly error message should be returned.

按团队功能分离

许多自我组织的团队会根据专业知识进行划分。这可能意味着成员将自己划分为前端开发者、后端开发者、QA 等。同样,故事和任务可以根据功能进行分离。

最好由团队自己决定如何有效地组织自己和他们的工作。例如,萨莉可能是.NET 框架方面最知识渊博的开发者,而史蒂夫可能在 React 方面有更多的专业知识。可能更好的是让萨莉承担大部分后端故事,让史蒂夫专注于前端功能。

注意,以这种方式优先考虑故事,让每个团队成员都有最适合他们的工作,这是一个容易陷入的陷阱。这将是高效的,但不是有效的。相反,优先级应该集中在交付的价值上,并在之后进行优化。当需要例如大型 UI 设计变更时,让某人(例如,萨莉)与史蒂夫一起工作在 UI 功能上,例如,并没有什么不好。

技术分离

可能会有这样的情况,你必须执行一些之前定义史诗中无法整齐划入的工作。非功能性需求可能包括与系统部分选择的技术相关的项目。故事可能仅包括纯 Web 或前端功能,例如打包 JavaScript 文件。或者,后端或服务器端功能可能需要定义在之前的史诗之外。

很可能还会有一系列非功能性或系统规范需要评估。这些要求的例子可能包括响应时间、吞吐量或内存消耗。这些通常被添加到“完成定义”清单中,以便每个故事都应该确认非功能性要求。

许多现代的 Web 启用应用程序都是使用 JavaScript 构建的单页应用程序(SPA)。这些应用程序由 Web 服务器托管,并在请求时发送到 Web 浏览器。整个应用程序,或者更确切地说,应用程序的大块内容,一次全部发送。当客户端浏览器发起请求时,SPA 将更新屏幕上的数据或模拟页面转换。在 SPA 中不使用全页回发和页面重新加载。这为最终用户提供了可感知的性能提升和响应速度增加。同时,这也允许将应用程序的一些处理工作分配到客户端机器上。

通过对 SPA 的这种划分,大部分功能可以被分为Web非 Web的标识。一个团队可能会选择以这种方式编写他们的故事。同样,一个团队可能会选择指定 Web 专家主要工作在 Web 相关功能上。这种划分的一个问题是,只有前端或后端的一个单一故事并不是可能发布的软件。它们没有单独提供价值。相反,故事可以通过去除特殊案例处理、只提供单一目的、保持 UI 更简单等方式进行拆分。

与 Web 标识类似,一个团队可能会决定将故事分为服务器端或后端功能。这可能涵盖从 API 到数据库以及所有中间的功能。Speaker Meet 应用程序的后端是用.NET、C#和 Entity Framework Core 以及 SQL Server 数据库编写的。这些技术为创建技术分离提供了极好的机会。

定义一致的 API,例如,是一个很好的起点。后端可能如何进一步细分将在本章后面讨论。

技术要求

Speaker Meet 应用程序有一系列的技术要求。语言选择和平台决策可以对应用程序产生巨大影响。这些决策将决定应用程序如何交付给客户端以及预期应用程序的哪些部分应该表现如何。

技术规范可以对应用程序产生重大影响。无论是LAMP(Linux, Apache, MySQL, PHP/Perl/Python),MEAN(MongoDB, Express, Angular, Node),还是 Speaker Meet 中的.NET 和 React,编程语言和框架在软件系统中可以发挥重要作用。

React Web 用户界面

Speaker Meet 应用程序的第一个用户界面定义为使用 React 的 SPA,React 是一个 JavaScript 库。React 是由 Facebook 团队编写的,用于开发现代 Web 应用程序。这相当于传统模型-视图-控制器模板中的视图。通过使用单向数据流模型以及虚拟 DOM,React 是一个功能强大、性能出色且可扩展性良好的库。

将使用 JavaScript 包管理器 NPM 包含许多额外的库。额外的库包括 webpack,一个用于 JavaScript、CSS 和其他类似文件的打包器。更多内容将在后续章节中介绍。

.NET Core

服务器端应用程序的主要语言将是.NET Core 中的 C#。随着最新版本的.NET Framework 的重构发布,开发者可以选择将框架的哪些部分包含在他们的应用程序中,并将核心级库保持到最小。

.NET Web API

将内部信息和行为暴露给外部系统的方式,SPA 被视为外部系统,是通过提供一个应用程序编程接口API)。API 层向外界暴露数据功能。应用程序的主要入口是一个由.NET Web API 组成的 API 集合。

实体框架

对于演讲者见面应用,使用了对象关系映射器ORM)将数据库对象转换为 C#对象。有众多这样的 ORM 适用于各种不同的语言和平台。仅.NET 就有 NHibernate、LLBLGen、Dapper 等许多 ORM。对于演讲者见面应用,选择了Entity FrameworkEF)Core。

选择一个如 EF Core 这样的 ORM 映射器本身就是一个要求,它将影响应用程序的架构选择。团队可能需要确定他们可用的 ORM 选项的优缺点,以及是否需要使用 ORM。

Azure

演讲者见面应用使用 Microsoft Azure 进行托管。选择 Azure 允许团队根据需求增加或减少应用程序的部分。当然,必须做出一些架构决策,以有效地利用 Azure 提供的可用功能。

了解即将到来或期望的未来功能,可以使团队在开发应用程序的部分时做出明智的决定。

计划未来的增强功能以利用 Azure Search 的力量。核心搜索功能是以一种方式编写的,切换到 Azure Search 将对系统其他部分的影响最小。当然,实现 Azure Search 将使用 TDD(测试驱动开发)。

数据库

使用 Microsoft SQL Azure 来持久化演讲者信息、用户群体和社区细节、会议信息以及用户登录详情。SQL Azure 与在本地使用 SQL Server 非常相似,但有几点需要注意。例如,SQL Azure 要求每个表都有聚集索引。了解可用数据库选项的要求和差异,使团队能够就他们的数据存储选择做出明智的决定。

N 层六边形架构

在前一章中,我们讨论了 N 层架构,其中软件应用程序被划分为多个层次。N 层应用程序通常像蛋糕的层次一样,从A层到B层再到C层,依此类推。以这种方式定义应用程序存在风险,因为有时功能组件并不能干净利落地归入某一层。只要层次之间保持松散耦合,功能不跨越边界,您的应用程序应该保持良好的结构和组织。

六边形架构

六边形架构最早由 Alistair Cockburn 在 2000 年代提出。六边形架构也被称为端口和适配器,其中端口是抽象,适配器是实现。这种设计应用程序的方法改变了层级的概念,将其转变为应用程序的内部和外部组件。

有些人可能会争论,六边形架构和 N 层架构是相同的。虽然可以使用 N 层线性分层方法实现六边形架构,但主要区别在于层与层之间的交互方式——线性或通过特定的端口和适配器:两个不同的区域,内部和外部部分。用最简单的话说,六边形方法可以在某些东西无法整齐地放入一系列连续层次时为您提供帮助,并有助于防止层之间的紧密耦合。

需要记住的主要是,有一些东西需要分离——数据源、用户界面、第三方库、框架——基本上是任何不是由您的团队编写的组件,甚至可能包括层次本身。通过使用在前一章中讨论的依赖倒置原则仓储模式,可以将耦合度降至最低。这提供了更大的灵活性、可维护性和可测试性。

通过最小化组件之间的耦合,可以提供更大的灵活性。新功能可以插入到现有应用程序中。现有应用程序的部分可以被替换为其他完全不同的东西。如果现有应用程序的部分与其他部分紧密耦合,这是无法做到的。

如果应用程序被正确分割,维护起来会容易得多。通过严格遵循在前几章中概述的 SOLID 原则,这几乎变得轻而易举。通过严格遵循六边形设计和保持内部逻辑不受外部依赖的影响,可以轻松地进行修改,而不会影响系统的其他部分。

测试松散耦合的系统比其他方法要容易得多。通过限制依赖关系,测试可以限制在测试的方法、函数或系统功能上。

基本而有效的 N 层划分

三层应用程序中的典型层包括表示层、业务逻辑和数据访问。这些可以进一步细分,但这对于许多应用程序来说是一个基本的起点。

通过以这种方式划分应用程序,产生了第一个关注点的分离。业务逻辑不应出现在表示层。数据访问代码不应出现在业务逻辑层。

一切都有其位,一切都在其位。

-玛丽·波平斯

服务层

业务层,或称为服务层,是应用程序业务逻辑所在的地方。无论你选择使用单个服务、管理器还是领域对象的概念,其本质上是相同的。应用程序的逻辑应该位于与表示信息和数据访问代码分开的地方。

微服务

在你的开发生涯中,你可能在某个时刻听说过微服务这个术语。这些通常是极小、独立的应用程序,为系统中的其他部分服务一个且仅有一个目的。无论是作为独立 API 还是部署到 Azure Service Fabric 的可执行文件,它们都可以独立于应用程序的其他部分进行开发和部署。微服务通常是小型、可重用的函数,通常被多个不同的应用程序或部署的用户界面所消费。

数据访问层

为了避免在应用程序的其他部分散布数据持久化代码,许多应用程序依赖于某种类型的数据访问层。这允许所有数据检索和存储过程集中在一个地方。

由于 Speaker Meet 应用程序依赖于 EF Core,数据访问层将是这些信息存储的主要地方。

仓库模式

仓库模式允许在领域层和数据访问层之间进行抽象。这允许应用程序的其他部分对数据持久化或检索的方式保持无知。这提高了测试性,并在仓库本身中实现了代码重用。

通用仓库

由于大部分数据访问功能在数据库模型之间是相同的,因此使用通用仓库来最小化代码重复。许多标准的CRUD创建、读取、更新、删除)操作被用于所有数据库对象。这为创建一个可用于所有模型的通用仓库提供了机会,这将在第七章,“测试驱动 C#应用程序”中介绍。

就像生活中一样,往往不是所有情况都适用单一模式。虽然通用仓库适用于大多数情况,但可能有时你需要创建一个特定的仓库或扩展通用仓库。这些情况应该仔细评估,并为他们制定适当的解决方案。

用户界面适配层

用户界面适配层是用户界面可以“插入”到应用程序其余部分的地方。演讲者见面应用程序提供了一系列网络 API,以向外部系统提供数据和功能。第一个这样的外部系统是 React SPA。利用用户界面适配层允许替换或添加新的 UI 应用程序。这可能是一种原生移动应用程序、Facebook 应用程序,或者与另一个外部网站(如 Meetup)的集成。

用户界面层

现代应用程序在前后端都有双 N 层架构。这意味着在服务器端所做的尽可能多的规划和分离,同样多的努力也可以用于架构 UI 应用程序。

由于整个系统的许多功能都交付给了客户端,在演讲者见面系统中,SPA 可以被视为一个完全独立的应用程序。它也必须有自己的应用程序架构规范。

前端业务层

使用 Redux 动作创建者可以将前端业务逻辑包含在单个层或位置中。在动作创建者中,行为可以被封装,关注点可以被分离。可重用函数可以被公开,从而最小化代码重复。

前端用户界面层

React 组件和容器为最终用户提供展示。应该创建可重用组件并保持它们小巧,且没有外部依赖。

前端数据源层

使用 React 和 Redux,数据将通过 reducer 存储在客户端机器的状态中。数据存储的形状应该仔细规划和评估。如果某个东西不是由多个组件共享的,那么它很可能不应该放在状态中。如果你需要相同的数据以多种形状出现,可以考虑使用像 React Reselect 这样的工具,它提供了一种在整个应用程序中使用转换或计算派生数据的方法。

测试方向

现在你已经为你的架构制定了一个基本的计划,你必须考虑你应该从哪里开始测试。有几个选择来确定开始的地方:

  • 你可以选择从数据访问层或数据源层开始测试,然后逐步向上到用户界面层。这种方法是测试的从后向前的方法。

  • 你可以从用户界面层开始,然后逐步到数据访问层。以这种方式进行测试是一种从前往后的测试方法。

  • 最后,你可以从业务层开始测试,然后逐步扩展到系统的六边形边界。这种方法是内部到外部的测试方法。

作为要检查的三种测试方向的演示,将使用相同的用户登录场景。

从后向前

大多数后端开发者都接受过以数据库优先的方式进行思考的教育。这种思考方式会让他们发现,从后往前的方式进行测试更有意义。如前所述,在后往前测试中,你从数据访问层开始。在心理上,你实际上是通过想象数据源内的数据结构来开始的。一旦定义了数据源,你就可以向上移动一层,开始考虑业务层的设计。最后,你可以将创建的模型和功能应用到用户界面。

定义数据源

通过从数据层开始,你可以尽早定义你的数据模型。对于这个应用程序和收到的需求,我们建议你使用 SQL 数据库,并使用实体框架进行数据连接。由于你在一个关系型数据库中工作,你需要某种类型的主键。这些键是针对关系型数据库的关注点,通常在系统需求中不会提及。在这种情况下,你可能会得到一个看起来像这样的表。

用户资料
ID Integer 主键,自增
用户名 Varchar(255) 唯一,非空
密码散列 Binary(64) 非空
首名 Varchar(255) 非空

现在你已经定义了一个表,你可以看到,由于安全原因,你不仅需要一个简单的密码字段,还必须使用密码散列。下一步是创建与该表交互的数据访问层代码。

从测试开始,正确地定义模型。这些测试将提供一些在需求中定义的验证,并为你定义实体框架模型构建器关系提供一个良好的起点。

public class UserProfileDtoTests
{
  [Fact]  
  public void ItExists()
  {
    var dto = new UserProfileDto();
  }

  [Fact]
  public void ItHasAnId()
  {
    // Arrange
    var dto = new UserProfileDto();
    dto.Id = 1;

    // Act
    // Assert
    Assert.Equal(1, dto.Id);
  }
}

这些测试将帮助你开始测试模型,其余的作为练习由你自己来完成。在完成之后,你应该有一个看起来与这个类似的模型。

public class UserProfileDto
{ 
  public int Id { get; set; }
  public string Username { get; set; }
  public string FirstName {get; set;}
  public byte[] PasswordHash { get; set; }
}

如你所见,这个模型并不复杂,但如果需要扩展的数据库字段数量增加,它可能会很快变得复杂。这只是一个用户资料可能看起来的部分示例。在继续之前,考虑一下还需要哪些其他字段,以及它们可能需要如何进行测试。

现在你已经有一个数据传输对象,你需要能够从数据库中将该模型读入应用程序。如第三章中所述,“在开始之前需要了解的内容”部分,我们更倾向于使用仓库模式。作为一个快速回顾,仓库模式是一个简单的模式,帮助我们处理数据源上的创建、读取、更新和删除操作。

我们只会使用FakeRepository中所需的部分。目前这意味着我们只会实现GetGetAll

public class FakeRepository<T> : IRepository<T> where T : class
{
    public IList<T> DataSet { get; set; } = new List<T>(); 
    public T Get(Func<T, bool> predicate)
    { 
        return GetAll().Where(predicate).FirstOrDefault(); 
    }

    public IQueryable<T> GetAll() 
    { 
        return DataSet.AsQueryable(); 
    } 
} 

现在我们正在使用FakeRepository,我们可以继续到业务层集成。

创建业务层

使用之前定义的 UserProfileDto,你现在可以专注于登录所需的服务。由于你将处理 UserProfileDto 和存储库,将其称为 UserProfileService。它将包含应用中与用户资料对象的所有交互。

目前,你只需要关注系统的登录功能。你将创建一个 GetUser 方法,它将接受一个用户名并返回一个 UserProfile。然后你将使用 UserProfile 和密码进行认证。

首先,这是创建 UserProfileService 的起始测试。

public class UserProfileServiceTests
{
  [Fact]
  public void ItExists()
  {
    var service = new UserProfileService();
  }
}
public class UserProfileService
{
  public UserProfileService()
  {
  }
}

我们通常在这个阶段创建一个新的类和文件夹结构来支持与 UserProfileService 相关的测试。我们的下一个测试类将是用于测试 GetUserProfile 方法的,因此我们将创建文件夹结构并添加该测试类。

文件夹结构:

图片

现在编写 GetUserProfile 方法的测试。

public class GetUserProfileTests
{
  [Fact]
  public void ItReturnsNullForNonExistentUsers()
  {
    // Arrange
    var repository = new FakeRepository<UserProfileDto>();
    var service = new UserProfileService(repository);

    // Act
    var profile = service.GetUserProfile("NonExistantUser@email.com");

    // Assert
    Assert.Null(profile);
  }

  [Fact]
  public void ItReturnsUserProfileForUsersThatExist()
  {
    // Arrange
    var repository = new FakeRepository<UserProfileDto>();
    var service = new UserProfileService(repository);

    repository.DataSet.Add(new UserProfileDto
    {
      Username = "ExistingUser@email.com"
    });

    // Act
    var profile = service.GetUserProfile("ExistingUser@email.com");

    // Assert
    Assert.NotNull(profile);
    Assert.IsAssignableFrom<UserProfileDto>(profile);
  }
}

在这种情况下,我们将让你实现一个类方法,该方法将通过这些测试。记住,我们只想编写足够少的代码来通过测试。你还将想要创建测试来验证不区分大小写,如果你认为系统需要的话。

现在你已经有一个用户资料,你需要验证用户提供的密码是否正确。在这本书的部分内容中,我们不会过多涉及安全问题,但你应该知道密码应该是一个单向散列。现在,在你继续创建登录用户界面之前,先编写测试来检查密码。

public class IsUserPasswordValid
{
  private readonly UserProfileService _service;
  private readonly UserProfileDto _profile;

  public IsUserPasswordValid()
  {
    // Arrange
    var repository = new FakeRepository<UserProfileDto>();
    _service = new UserProfileService(repository);
    _profile = new UserProfileDto
    {
      Username = "ValidUser@email.com",
      // This should be an encryption helper utility. Try to write and 
         test a utility to replace this code.
      PasswordHash = SHA512.Create().ComputeHash(Encoding.ASCII.GetBytes("ValidPassword"))
    };

    repository.DataSet.Add(_profile);
  }

  [Fact]
  public void ItReturnsFalseForInvalidPasswords()
  {
    // Act
    var result = _service.IsUserPasswordValid(_profile, "InvalidPassword");

    // Assert
    Assert.False(result);
  }

  [Fact]
  public void ItReturnsTrueForValidPasswords()
  {
    // Act
    var result = _service.IsUserPasswordValid(_profile, "ValidPassword");

    // Assert
    Assert.True(result);
  }
}
public class UserProfileService
{
  private readonly IRepository<UserProfileDto> _repository;

  public UserProfileService(IRepository<UserProfileDto> repository)
  {
    _repository = repository;
  }

  public object GetUserProfile(string username)
  {
    return _repository.GetAll().FirstOrDefault(u => u.Username == username);
  }

  public bool IsUserPasswordValid(UserProfileDto profile, string password)
  {
    // Now we have the same code in production code as we do in our tests.
    var hash = SHA512.Create().ComputeHash(Encoding.ASCII.GetBytes(password));

    return profile.PasswordHash.SequenceEqual(hash);
  }
}

构建用户界面

现在已经有了足够的功能,你可以开始着手用户界面了。在 C# Web API 中,用户界面是一个 API 控制器。API 控制器所需的基本测试是它存在并且正确地从控制器类继承。

public class UserProfileControllerTests
{
  [Fact]
  public void ItExists()
  {
    var controller = new UserProfileController();
  }

  [Fact]
  public void ItIsAController()
  {
    var controller = new UserProfileController();

    Assert.IsAssignableFrom<Controller>(controller);
  }
}
public class UserProfileController : Controller
{
}

接下来,你需要确保它有一个接受用户名和密码的登录方法。该方法必须根据用户信息的有效性返回 200 OK 或 401 NOT AUTHORIZED:

public class UserLogon
{
  private readonly UserProfileController _controller;

  public UserLogon()
  {
    // Arrange
    var repository = new FakeRepository<UserProfileDto>();
    var service = new UserProfileService(repository);
    _controller = new UserProfileController(service);

    repository.DataSet.Add(new UserProfileDto
    {
      Username = "TestUser@email.com",
      PasswordHash = SHA512.Create().ComputeHash(Encoding.UTF8.GetBytes("ValidPassword"))
    });
  }

  [Fact]
  public void ItExists()
  {
    // Act
    var response = _controller.LogonUser("TestUser@email.com", "Password");
  }

  [Fact]
  public void ItReturnsAnActionResult()
  {
    // Act
    var response = _controller.LogonUser("TestUser@email.com", "Password");

    // Assert
    Assert.IsAssignableFrom<IActionResult>(response);
  }

  [Fact]
  public void ItReturnsNotAuthorizedForBadUsername()
  {
    // Act
    var response = (StatusCodeResult) _controller.LogonUser("BadUser@email.com", "ValidPassword");

    // Assert
    Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)response.StatusCode);
  }

  [Fact]
  public void ItReturnsOkForValidUsernameAndPassword()
  {
    // Act
    var response = (StatusCodeResult)_controller.LogonUser("TestUser@email.com", "ValidPassword");

    // Assert
    Assert.Equal(HttpStatusCode.OK, (HttpStatusCode)response.StatusCode);
  }

  [Fact]
  public void ItReturnsUnauthorizedForInvlalidPassword()
  {
    // Act
    var response = (StatusCodeResult)_controller.LogonUser("TestUser@email.com", "InvalidPassword");

    // Assert
    Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)response.StatusCode);
  }
}
public class UserProfileController : Controller
{
  private readonly UserProfileService _service;

  public UserProfileController(UserProfileService service)
  {
    _service = service;
  }

  public IActionResult LogonUser(string username, string password)
  {
    var user = _service.GetUserProfile(username);

    if (user != null && _service.IsUserPasswordValid(user, password))
    {
      return Ok();
    }

    return Unauthorized();
  }
}

以这种方式处理应用程序的一个缺点是,现在我们几乎所有的层都关注于几乎与数据库完全相同的对象。通常,这并不是真正的问题。但是数据库表确实会发生变化,那么如果我们的用户资料表在未来需要一些调整怎么办?此时,我们的整个应用程序都需要更新。你是否注意到了以反向方式思考应用程序的一些副作用?如果没有,那没关系,但在你探索其他两种方向方法时,请保持警觉。

前后端

一些开发者选择从用户体验的角度来处理应用程序设计和实现。首先,思考用户如何希望与系统交互,然后围绕这个概念设计系统。

定义用户界面

以这种方式攻击应用程序,首先你必须确定你认为最好的用户体验是什么。如果用户不仅得到登录是否被接受的通知,而且还收到解释当前状态的消息,那可能最好。

从这个方向测试时,你会称我们的控制器叫什么?用户想要登录,所以,你应该称它为登录控制器。

和之前一样,你需要测试你的控制器是否存在。然后测试它是否正确地继承自控制器。

public class LogonControllerTests
{
  [Fact]
  public void ItExists()
  {
    var controller = new LogonController();
  }

  [Fact]
  public void ItIsAnIActionResult()
  {
    // Act
    var controller = new LogonController();

    // Assert
    Assert.IsAssignableFrom<Controller>(controller);
  }
}
public class LogonController : Controller
{
}

现在,你可以测试你的 API 方法。它应该叫什么?再想想用户。他们正在尝试登录,所以,我们可能应该坚持与登录相关的简单名称。这个控制器的默认 POST 操作可能应该是激活登录的方法。

public class Post
{
  [Fact]
  public void ItExists()
  {
    // Arrange
    var controller = new LogonController();

    // Act
    var response = controller.Post(null);
  }

  [Fact]
  public void ItReturnsAnIActionResult()
  {
    // Arrange
    var controller = new LogonController();

    // Act
    var response = controller.Post(null);

    // Assert
    Assert.IsAssignableFrom<IActionResult>(response);
  }

  [Fact]
  public void ItReturnsUnauthorizedForInvalidUser()
  {
    // Arrange
    var controller = new LogonController();
    var attempt = new LoginAttempt
    {
      Username = "InvalidUser@email.com",
      Password = "BadPassword"
    };

    // Act
    var response = (ObjectResult)controller.Post(attempt);

    // Assert
    Assert.NotNull(response.StatusCode);
    Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)response.StatusCode);
  }

  [Fact]
  public void ItReturnsOkForValidUser()
  {
    // Arrange
    var controller = new LogonController();
    var attempt = new LoginAttempt
    {
      Username = "ValidUser@email.com",
      Password = "ValidPassword"
    };

    // Act
    var response = (ObjectResult)controller.Post(attempt);

    // Assert
    Assert.NotNull(response.StatusCode);
    Assert.Equal(HttpStatusCode.OK, (HttpStatusCode)response.StatusCode);
  }

  [Fact]
  public void ItReturnsUnauthorizedForInvalidPassword()
  {
    // Arrange
    var controller = new LogonController();
    var attempt = new LoginAttempt
    {
      Username = "ValidUser@email.com",
      Password = "InvalidPassword"
    };

    // Act
    var response = (ObjectResult)controller.Post(attempt);

    // Assert
    Assert.NotNull(response.StatusCode);
    Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)response.StatusCode);
  }

  [Fact]
  public void ItReturnsSuccessfulLogonMessageWhenSuccessful()
  {
    // Arrange
    var controller = new LogonController();
    var attempt = new LoginAttempt
    {
      Username = "ValidUser@email.com",
      Password = "ValidPassword"
    };

    // Act
    var response = (ObjectResult)controller.Post(attempt);

    // Assert
    Assert.Equal("Logon Successful", response.Value);
  }

  [Fact]
  public void ItReturnsUnauthorizedLogonMessageWhenUnauthorized()
  {
    // Arrange
    var controller = new LogonController();
    var attempt = new LoginAttempt
    {
      Username = "InvalidUser@email.com",
      Password = "Password"
    };

    // Act
    var response = (ObjectResult)controller.Post(attempt);

    // Assert
    Assert.Equal("Username or Password invalid", response.Value);
  }
}
public class LoginAttempt
{
  public string Username { get; set; }
  public string Password { get; set; }
}
public class LogonController : Controller
{
  [HttpPost]
  public IActionResult Post(LoginAttempt attempt)
  {
    if (attempt != null && attempt.Username == "ValidUser@email.com" && attempt.Password == "ValidPassword")
    {
      return Ok("Logon Successful");
    }

    return new ObjectResult("Username or Password invalid") {
      StatusCode = (int?)HttpStatusCode.Unauthorized
    };
  }
}

使用从前往后的方向性方法,你还没有定义任何依赖项,所以你别无选择,只能硬编码决策。尽管如此,你可以稍微将这些决策推后。

创建业务层

创建一个接口,并将有效的用户登录移动到该接口的模拟登录服务中。

public class LogonController : Controller
{
  private readonly ILogonService _service;

  public LogonController(ILogonService service)
  {
    _service = service;
  }

  public IActionResult Post(LoginAttempt attempt)
  {
    return _service.IsLogonValid(attempt) ? 
      Ok("Logon Successful") :
      new ObjectResult("Username or Password invalid") {
        StatusCode = (int?)HttpStatusCode.Unauthorized
      };
  }
}
public interface ILogonService
{
  bool IsLogonValid(LoginAttempt attempt);
}
class FakeLogonService : ILogonService
{
  public bool IsLogonValid(LoginAttempt attempt)
  {
    return attempt != null &&
      attempt.Username == "ValidUser@email.com" &&
      attempt.Password == "ValidPassword";
  }
}

如果你正在跟随,你需要在测试中更新所有控制器引用以使用这个新的模拟登录服务。

既然你已经定义了接口,你可以编写测试来创建服务层。

public class IsValidLogon
{
  private readonly LogonService _service;

  public IsValidLogon()
  {
    var repository = new FakeRepository<UserLogonDto>();
    _service = new LogonService(repository);
    var userLogon = new UserLogonDto
    {
      Username = "ValidUser@email.com",
      PasswordHash =  SHA512.Create().ComputeHash(Encoding.ASCII.GetBytes("ValidPassword"))
    };

    repository.DataSet.Add(userLogon);
  }

  [Fact]
  public void ItExists()
  {
    var repository = new FakeRepository<UserLogonDto>();
    var service = new LogonService(repository);
    var attempt = new LoginAttempt();

    service.IsLogonValid(attempt);
  }

  [Fact]
  public void ItReturnsTrueForValidAttempt()
  {
    // Arrange
    var attempt = new LoginAttempt
    {
      Username = "ValidUser@email.com",
      Password = "ValidPassword"
    };

    // Act
    var result = _service.IsLogonValid(attempt);

    // Assert
    Assert.True(result);
  }

  [Fact]
  public void ItReturnsFalseForInvalidUsername()
  {
    // Arrange
    var attempt = new LoginAttempt
    {
      Username = "InvalidUser@email.com",
      Password = "ValidPassword"
    };

    // Act
    var result = _service.IsLogonValid(attempt);

    // Assert
    Assert.False(result);
  }

  [Fact]
  public void ItReturnsFalseForInvalidPassword()
  {
    // Arrange
    var attempt = new LoginAttempt
    {
      Username = "ValidUser@email.com",
      Password = "InvalidPassword"
    };

    // Act
    var result = _service.IsLogonValid(attempt);

    // Assert
    Assert.False(result);
  }
}
public class LogonService : ILogonService
{
  private readonly IRepository<UserLogonDto> _repository;

  public LogonService(IRepository<UserLogonDto> repository)
  {
    _repository = repository;
  }

  public bool IsLogonValid(LoginAttempt attempt)
  {
    attempt = attempt ?? new LoginAttempt();

    var user = _repository.GetAll().FirstOrDefault(u => u.Username == attempt.Username);

    var hash = SHA512.Create().ComputeHash(Encoding.ASCII.GetBytes(attempt.Password ?? ""));

    return user != null && user.PasswordHash.SequenceEqual(hash);
  }
}
public class UserLogonDto : IIdentity
{
  public int Id { get; set; }
  public string Username { get; set; }
  public byte[] PasswordHash { get; set; }
}

构建数据源

现在你有了服务,你可以专注于数据层。信不信由你,这部分实际上与我们之前在从后到前的方法中做的没有太大区别。

我们已经成功地做了一件事不同。我们为我们的数据交互创建了一个合约。如果我们使用关系型数据存储,表的其余部分可以是任何东西,我们不在乎。我们只关心用户名和密码散列。我们只有 ID,因为FakeRepository需要它。

有一些编程存储库的方法不需要这个功能。我们不会从上一个示例中重新创建表。它是一样的表。

内外结合

在本章中,我们将要介绍的最后一个方向性方法是“内外结合”方法。使用内外结合方法,你开始时不是从 UI 或数据源开始,而是从需求中定义的业务规则开始。

定义业务层

回顾我们的需求,我们可以构建与我们的需求一对一匹配的测试和逻辑,例如:

  • 给定一个已注册的演讲者

  • 给定一个无效的用户名

  • 尝试登录时

  • 然后发生 INVALID_USERNAME_OR_PASSWORD 错误

public class LoginTests
{
  [Fact]
  public void GivenAnInvalidUsername()
  {
    // Arrange/Given
    var username = "InvalidUser@email.com";

    // Act/When
    var exception = Record.Exception(() => Account.Logon(username));

    // Assert/Then
    Assert.IsAssignableFrom<InvalidUsernameOrPasswordException>(exception);
    Assert.Equal("Invalid Username or Password", exception.Message);
  }
}
public class InvalidUsernameOrPasswordException: Exception
{
  public InvalidUsernameOrPasswordException() : base("Invalid Username or Password")
  {
  }
}
public class Account
{
  public object Logon(string username)
  {
    throw new InvalidUsernameOrPasswordException();
  }
}

下一个需求为了提供一些前进的空间,做了一些重大改变。

  • 给定一个已注册的演讲者

  • 给定一个有效的用户名

  • 给定一个有效的密码

  • 尝试登录时

  • 然后用户被授予访问应用程序的权限

public class LoginTests
{
  private readonly string _accessKey;
  private readonly Account _account;

  public LoginTests()
  {
    _accessKey = "GrantedAccessKey";
    var repository = new FakeRepository<UserCredentials>();
    _account = new AccountTestDouble(repository);

    repository.DataSet.Add(new UserCredentials {
      Username = "ValidUser@email.com"
    });
  }

  [Fact]
  public void GivenAnInvalidUsername()
  {
    // Arrange/Given
    var username = "InvalidUser@email.com";
    var password = "UnimportantPassword";

    // Act/When
    var exception = Record.Exception(() => _account.Logon(username, password));

    // Assert/Then
    Assert.IsAssignableFrom<InvalidUsernameOrPasswordException>(exception);
    Assert.Equal("Invalid Username or Password", exception.Message);
  }

  [Fact]
  public void GivenAValidUsernameAndPassword()
  {
    // Arrange/Given
    var username = "ValidUser@email.com";
    var password = "ValidPassword";

    // Act/When
    var result = _account.Logon(username, password);

    // Assert/Then
    Assert.IsAssignableFrom<string>(result);
    Assert.Equal(_accessKey, result);
  }
}
public class InvalidUsernameOrPasswordException : Exception
{
  public InvalidUsernameOrPasswordException() : base("Invalid Username or Password")
  {
  }
}
public class Account
{
  private readonly IRepository<UserCredentials> _repository;

  public Account(IRepository<UserCredentials> repository)
  {
    _repository = repository;
  }

  public string Logon(string username, string password)
  {
    var uc =_repository.GetAll().FirstOrDefault(u => u.Username == username);

    if (uc == null)
    {
      throw new InvalidUsernameOrPasswordException();
    }

    return GenerateAccessKey(uc);
  }

  protected virtual string GenerateAccessKey(UserCredentials userCredentials)
  {
    // Here we would need to actually generate an access token
    return "DefaultKey";
  }
}
public class AccountTestDouble : Account
{
  public AccountTestDouble(IRepository<UserCredentials> repository) : base(repository) { }

  protected override string GenerateAccessKey(UserCredentials userCredentials)
  {
    return "GrantedAccessKey";
  }
}
public class UserCredentials : IIdentity
{
  public int Id { get; set; }
  public string Username { get; set; }
}

现在是最后一个我们提供的需求:

  • 给定一个已注册的演讲者

  • 给定一个有效的用户名

  • 给定一个无效的密码

  • 尝试登录时

  • 然后发生了一个 INVALID_USERNAME_OR_PASSWORD 错误

[Fact]
public void GivenAnInvalidPassword()
{
  // Arrange/Given
  var username = "ValidUser@email.com";
  var password = "InvalidPassword";

  // Act/When
  var exception = Record.Exception(() => _account.Logon(username, password));

  // Assert/Then
  Assert.IsAssignableFrom<InvalidUsernameOrPasswordException>(exception);
  Assert.Equal("Invalid Username or Password", exception.Message);
}

最后这个测试相当简单,与我们在内部开发中编写的第一个测试非常相似。有一点需要注意,但在这里我们没有展示,那就是我们必须扩展我们的UserCredentials类以包含密码哈希属性。

从这个点开始创建用户界面和数据层几乎与我们在早期示例中展示的完全一样,所以这里我们不会展示它们。

对于这个示例,剩余的任务是将业务层抽象化到接口后面,在 UI 中使用业务对象,并为数据层创建适当的数据配置。

摘要

在本章中,我们更详细地定义了演讲者见面应用。讨论了架构选择,并设定了路径。史诗、功能和用户故事已经足够详细,我们现在可以准备与演讲者见面应用迈出下一步。

在第七章,“测试驱动 C#应用程序”,我们将专注于测试驱动 C# API。将介绍诸如模拟存根模拟对象等主题,以帮助您在测试世界中导航。

第七章:测试驱动 C#应用程序

对于 Speaker Meet 应用来说,最重要的两个功能被确定为演讲者列表和查看单个演讲者详情的能力。演讲者列表和演讲者详情将为我们的最小可行产品带来最大的价值。

会议组织者、用户组管理员以及公众最可能关心的是找到关于演讲者的信息。考虑到这一点,演讲者史诗是演讲者见面应用开发的起点。

在本章中,我们涵盖了以下内容:

  • Speaker Meet 需求

  • API、服务和存储库测试

  • 演讲者详情和演讲者列表 API

审查需求

为了开始,通过定义初始需求集,为 Speaker Meet 应用中的演讲者部分打下基础。这些需求将有助于消除歧义,并发展对需求的共同理解,以及定义在整个项目中使用的共同词汇。

摘要是展示项目目的和价值的地方。任何项目在获得批准进行工作之前,都必须证明它能为公司提供的价值。无论你是为财富 500 强公司工作还是为只有两个人的初创公司工作,这都是正确的。

数据字典很重要,因为它为项目提供了一个通用、无处不在的语言。这个术语“无处不在的语言”来自领域驱动设计,表示一种共享或通用的语言。这个想法是,业务和开发团队的共享术语被固定在一个法典中,可以被所有人查看和使用。

最后,但同样重要的是,需求必须以商定的格式呈现。具体的格式不如格式协议重要。无论格式如何,良好的需求都提供了交互的背景、正在进行的交互以及根据背景和特定操作预期的结果。

演讲者列表

Speaker Meet 网站上的演讲者部分包含系统中所有演讲者的列表。演讲者列表将为多个群体带来价值,包括会议和用户组组织者以及会议和用户组参与者。从用户交互的角度来看,演讲者列表允许进入演讲者详情。演讲者详情是真正提供价值的部分,以可用性、即将到来的活动和特定演讲者的联系信息的形式呈现。

初始时,演讲者列表将帮助组织者快速访问演讲者发现。组织者将能够找到他们所知道的演讲者,并发现他们不知道的演讲者。一旦找到或发现,组织者将能够查看特定演讲者的详细信息,最终,组织者将能够使用可用的联系信息联系演讲者。

参与者将从演讲者列表中受益,类似于组织者。然而,参与者有一个重要区别:他们正在寻找演讲者已经作为演讲者附加的事件。类似地,这种信息,类似于联系信息,将在演讲者详情中可用。

API

API 是进入演讲者 Meet 应用程序核心系统的主要入口。演讲者列表 API 应返回演讲者摘要 ViewModel 的列表。这些 ViewModel 只包含应用程序这部分所需的信息。ViewModel 代表演讲者,但不一定是直接复制到数据库中的演讲者对象。

SpeakerSummary ViewModel 将根据系统的需求进行定义。这个 ViewModel 将逐渐增长,只包含其有限用途所需的属性。

要开始,需要向 API 添加一个新方法。对于要添加的第一个新功能,需要在 SpeakerController 中创建一个新的 GetAll 方法。但首先,必须创建一个测试。

API 测试

回顾一下,SpeakerController 中的代码在没有失败的单元测试的情况下可能不会被编写。首先,应该创建一个名为 GetAll 的新测试文件。这里将包含与 SpeakerControllerGetAll 方法相关联的所有测试。

在设置 SpeakerController 测试时存在重复。尝试想出可以最小化这种重复的方法。

这样的第一个测试应该是标准的 ItExists 测试。基于前几章的示例,SpeakerController 在构造函数中接受一个 ISpeakerService。同样可以应用提供 Moq 对象的方法。

[Fact]
public void ItExists()
{
  // Arrange
  var speakerServiceMock = new Mock<ISpeakerService>();
  var controller = new SpeakerController(speakerServiceMock.Object);

  // Act
  controller.GetAll();
}

将这次测试与为 SpeakerController 中的 Search 方法编写的第一次测试进行比较,你可能已经注意到已经发生了一些重复。记住,应该避免重复。不要忘记这个缩写,DRY不要重复自己)。

为了使这次测试通过,需要在 SpeakerController 中添加一个空的 GetAll 方法。这将允许应用程序编译,从而通过这次测试。记住,编译失败即测试失败。

public void GetAll()
{           
}

接下来,通过创建一个新的测试来确保 SpeakerControllerGetAll 方法返回一个 OkObjectResult。不要担心结果本身的类型。这将在下一个测试中解决。

[Fact]
public void ItReturnsOkObjectResult()
{
  // Arrange
  var speakerServiceMock = new Mock<ISpeakerService>();
  var controller = new SpeakerController(speakerServiceMock.Object);

  // Act
  var result = controller.GetAll();

  // Assert
  Assert.NotNull(result);
  Assert.IsType<OkObjectResult>(result);
}

为了使这个测试通过,方法应该返回 IActionResult 而不是 void。方法还应更改为返回 Ok() 以使测试通过。为了使测试按编写的方式通过,方法不需要返回任何其他内容。不要编写比使测试通过所需的代码更多的代码。

public IActionResult GetAll()
{           
  return Ok();
}

现在,确定该方法返回一个 SpeakerSummary 集合。

[Fact]
public void ItReturnsCollectionOfSpeakerSummary()
{
  // Arrange
  var speakerServiceMock = new Mock<ISpeakerService>();
  var controller = new SpeakerController(speakerServiceMock.Object);

  // Act
  var result = controller.GetAll() as OkObjectResult;

  // Assert
  Assert.NotNull(result);
  Assert.NotNull(result.Value);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(result.Value);
}

创建一个SpeakerSummary类来满足这个测试定义的要求。考虑一下新的SpeakerSummary应该放在哪里。这是一个 ViewModel,它将需要被测试访问,但不应该对应用程序的其他层可用。关于适当分离的更多内容将在未来的章节中介绍。

修改SpeakerControllerGetAll方法,使其返回一组SpeakerSummary对象作为返回值。

public IActionResult GetAll()
{
  return Ok(new List<SpeakerSummary>());
}

Moq

在前面的章节中,Moq 被用来为被测试的项目提供一组替代功能。对于模拟实例提供的结果是必需的,但实现对于测试的内容并不重要。

如前几章中的示例,GetAll的逻辑不应该在控制器本身中找到。相反,逻辑将包含在业务层中,特别是ISpeakerServiceSpeakerService实现。当在SpeakerController中调用GetAll方法时,预期将调用SpeakerServiceGetAll方法。

SpeakerService中不存在GetAll方法,因此下面的测试应该会失败。

[Fact]
public void ItCallsGetAllServiceOnce()
{
  // Arrange
  var speakerServiceMock = new Mock<ISpeakerService>();
  var controller = new SpeakerController(_speakerServiceMock.Object);

  // Act
  controller.GetAll();

  // Assert
  speakerServiceMock.Verify(mock => mock.GetAll(), Times.Once());
}

创建之前的测试强制在ISpeakerService接口中创建一个新的方法签名。以下方法签名应添加到ISpeakerService接口中。

IEnumerable<SpeakerSummary> GetAll();

为了使应用程序能够编译,GetAll也需要添加到SpeakerService类中。目前,这应该抛出一个异常。

public IEnumerable<SpeakerSummary> GetAll()
{
  throw new NotImplementedException();
}

为了使ItCallsGetAllServiceOnce测试通过,确保调用SpeakerServiceGetAll方法。对于测试通过,调用方法本身就足够了,不需要返回值。

public IActionResult GetAll()
{
  _speakerService.GetAll();

  return Ok(new List<SpeakerSummary>());
}

注意,这将使测试通过,但这还不是完全正确的解决方案。需要一个新的测试来强制代码对服务返回值进行操作。继续前进,现在是时候对SpeakerService.GetAll调用的结果进行处理了。

[Fact]
public void GivenSpeakerServiceThenResultsReturned()
{
  // Arrange
  var speakers = new List<SpeakerSummary> { new SpeakerSummary
  {
    Name = "Speaker"
  } };

  var speakerServiceMock = new Mock<ISpeakerService>();
  speakerServiceMock.Setup(x => x.GetAll()).Returns(() => _speakers);

  var controller = new SpeakerController(speakerServiceMock.Object);

  // Act
  var result = controller.GetAll() as OkObjectResult;
  var speakers = ((IEnumerable<SpeakerSummary>)result.Value).ToList();

  // Assert
  Assert.Equal(_speakers, speakers);
}

不要忘记重构测试和代码。为了提高可读性,Arrange方法已被包含在之前的示例中。很可能,这些方法将被提取并定义为字段,并在构造函数中分配。

private readonly SpeakerController _controller;
private static Mock<ISpeakerService> _speakerServiceMock;
private readonly List<SpeakerSummary> _speakers;

public GetAll()
{
  _speakers = new List<SpeakerSummary> { new SpeakerSummary
  {
    Name = "test"
  } };

  _speakerServiceMock = new Mock<ISpeakerService>();
  _speakerServiceMock.Setup(x => x.GetAll()).Returns(() => _speakers);

  _controller = new SpeakerController(_speakerServiceMock.Object);
}

测试异常情况

如果请求的演讲者不存在,最好向 API 的消费者返回一个友好的错误信息。

[Fact]
public void GivenSpeakerNotFoundExceptionThenNotFoundObjectResult()
{
  // Arrange
  // Act
  var result = _controller.Get(-1);

  // Assert
  Assert.IsAssignableFrom<NotFoundObjectResult>(result);
}

创建一个新的异常类,命名为SpeakerNotFoundException。这将是由下面的Moq调用返回的特定异常。像之前的SpeakerSummary类文件一样,考虑一下SpeakerNotFoundException类文件应该保存的位置。

public class SpeakerNotFoundException : Exception
{
}

当提供一个特定 ID 时,在Moq中“抛出”一个新的异常需要一点设置。这类似于之前由x.Get(It.IsAny<int>)定义的。

_speakerServiceMock.Setup(x => x.Get(-1)).Returns(() => throw new SpeakerNotFoundException());

确保在之前的设置之后添加此操作,因为Moq将首先处理最后一个值。通过理解Moq将如何评估其上下文中设置的内容,避免出现假阳性。

接下来,修改控制器中的Get方法以捕获异常并返回适当的响应代码。

public IActionResult Get(int id)
{
  try
  {
    var speaker = _speakerService.Get(id);
    return Ok(speaker);
  }
  catch (SpeakerNotFoundException)
  {
    return NotFound();
  }
}

初始要求指出,应向客户端返回一个友好的错误消息。创建一个测试以确保在找不到具有提供的 ID 的演讲者时,向消费者返回一个友好的消息。

[Fact]
public void GivenSpeakerNotFoundExceptionThenMessageReturned()
{
  // Arrange
  // Act
  var result = _controller.Get(-1) as NotFoundObjectResult;

  // Assert
  Assert.NotNull(result);
  Assert.Equal("Speaker Not Found", result.Value);
}

为了使这个测试通过,必须修改SpeakerNotFoundException类以返回一个友好的错误信息。

public class SpeakerNotFoundException : Exception
{
  public SpeakerNotFoundException() : base("Speaker Not Found")
  {
  }
}

最后,修改控制器中的Get方法以返回消息。

public IActionResult Get(int id)
{
  try
  {
    var speaker = _speakerService.Get(id);
    return Ok(speaker);
  }
  catch (SpeakerNotFoundException ex)
  {
    return NotFound(ex.Message);
  }
}

服务

GetAll方法的业务逻辑应放在SpeakerService中。和之前一样,为了编写一行代码,必须首先编写一个测试。

服务测试

在上一个示例的基础上,从ItExists测试开始。

[Fact]
public void ItHasGetAllMethod()
{
  var speakerService = new SpeakerService();
  speakerService.GetAll();
}

由于此方法之前已添加到SpeakerService中,尽管使用了NotImplementedException,但最好看到这个测试因为正确的原因而失败。从SpeakerService中删除GetAll方法,以便应用程序无法编译。现在,将方法添加回来,以查看应用程序再次编译,因此这个测试通过。这次,让该方法返回null而不是抛出一个新的NotImplementedException

public IEnumerable<SpeakerSummary> GetAll()
{
  return null;
}

现在,确保GetAll方法通过创建一个新的测试来返回一个SpeakerSummary对象的集合。

[Fact]
public void ItReturnsCollectionOfSpeakerSummary()
{
  // Arrange
  // Act
  var speakers = _speakerService.GetAll();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);
}

修改SpeakerService中的GetAll方法以使这个测试通过。使这个测试通过所需的最小代码量是返回一个SpeakerSummary对象的新List。不要添加超过使这个测试通过所需的代码。

public IEnumerable<SpeakerSummary> GetAll()
{
  return new List<SpeakerSummary>();
}

在前一章的示例的基础上,使用之前硬编码的数据。将hardCodedSpeakers提取到一个字段中,以便在Search方法和GetAll方法中使用这些数据:

public readonly List<Speaker> HardCodedSpeakers = new List<Speaker>
{
  new Speaker {Name = "Josh"},
  new Speaker {Name = "Joshua"},
  new Speaker {Name = "Joseph"},
  new Speaker {Name = "Bill"}
};

注意,该字段已被公开。这将允许测试使用这些数据来比较断言。不用担心,这个字段以及其中包含的硬编码数据将不会长期存在。一旦这些不再需要,它们可以安全地被删除。

现在,创建一个测试以确保SpeakerService中的GetAll方法返回了HardCodedSpeakers中包含的所有数据。首先,验证方法返回的硬编码数据中的演讲者数量是否相同。

[Fact]
public void ItReturnsAllSpeakers()
{
  // Arrange
  // Act
  var speakers = _speakerService.GetAll();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);
  Assert.Equal(_speakerService.HardCodedSpeakers.Count, speakers.Count());
}

为了使这个测试通过,只需遍历硬编码的值,并为每个条目返回一个新的SpeakerSummary。由于测试尚未检查返回的演讲者的值,所以只需要返回正确的SpeakerSummary对象数量。

public IEnumerable<SpeakerSummary> GetAll()
{
  return HardCodedSpeakers.Select(speaker => new SpeakerSummary());
}

现在,确保演讲者被正确地转换为SpeakerSummary对象。首先,检查Name属性是否相同。

[Fact]
public void ItReturnsAllSpeakersWithName()
{
  // Arrange
  // Act
  var speakers = _speakerService.GetAll().ToList();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);

  for (var i = 0; i < speakers.Count; i++)
  {
    Assert.NotNull(_speakerService.HardCodedSpeakers[i].Name);
    Assert.Equal(_speakerService.HardCodedSpeakers[i].Name, speakers[i].Name);
  }
}

现在,通过在 GetAll 方法中分配 Name 来使这个测试通过。

public IEnumerable<SpeakerSummary> GetAll()
{
  return HardCodedSpeakers.Select(speaker => new SpeakerSummary
  {
    Name = speaker.Name     
  });
}

继续构建具有所需属性的 SpeakerSummary 对象。已添加 Name 属性。现在,添加一个 ID 并确保它被正确分配和返回。

[Fact]
public void ItReturnsAllSpeakersWithId()
{
  // Arrange
  // Act
  var speakers = _speakerService.GetAll().ToList();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);

  for (var i = 0; i < speakers.Count; i++)
  {
    Assert.NotNull(_speakerService.HardCodedSpeakers[i].Id);
    Assert.Equal(_speakerService.HardCodedSpeakers[i].Id, speakers[i].Id);
  }
}

为了使这个通过,需要在 SpeakerServiceGetAll 方法中映射一个 ID,并添加一个 ID 属性到 SpeakerSpeakerSummary 对象中。

public IEnumerable<SpeakerSummary> GetAll()
{
  return HardCodedSpeakers.Select(speaker => new SpeakerSummary
  {
    Id = speaker.Id,
    Name = speaker.Name     
  });
}

接下来,添加一个 LocationGetAll 方法返回。这也需要修改 SpeakerSpeakerSummary 对象。在 HardCodedSpeakers 集合中为新位置属性提供独特的值,以确保值被正确返回。

[Fact]
public void ItReturnsAllSpeakersWithLocation()
{
  // Arrange
  // Act
  var speakers = _speakerService.GetAll().ToList();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);

  for (var i = 0; i < speakers.Count; i++)
  {
    Assert.NotNull(_speakerService.HardCodedSpeakers[i].Location);
    Assert.Equal(_speakerService.HardCodedSpeakers[i].Location, speakers[i].Location);
  }
}

将一些位置添加到硬编码的数据中。

public readonly List<Speaker> HardCodedSpeakers = new List<Speaker>
{
  new Speaker {Id = 1, Name = "Josh", Location = “Tampa, FL”},
  new Speaker {Id = 2, Name = "Joshua", Location = “Louisville, KY”},
  new Speaker {Id = 3, Name = "Joseph", Location = “Las Vegas, NV”},
  new Speaker {Id = 4, Name = "Bill", Location = “New York, NY”},
};

最后,将位置映射到 SpeakerSummary 视图模型。

public IEnumerable<SpeakerSummary> GetAll()
{
  return HardCodedSpeakers.Select(speaker => new SpeakerSummary
  {
    Id = speaker.Id,
    Name = speaker.Name,  
    Location = speaker.Location,
  });
}

如前所述,测试应该有一个单一的操作。这并不排除它们可以有多个断言。为了最小化重复,应该合并属性测试。

清洁测试

测试套件应该得到良好的维护。这是应用程序的第一个消费者,并为系统的功能提供了最全面的文档。为了清理刚刚创建的测试,是时候进行一些重构了。

SpeakerSummary 属性合并为单个动作,包含多个断言。这将有助于使测试套件更小、更易于阅读和维护,并且可能会更快地执行。一个执行快速的测试套件更有可能被开发者频繁运行。

ItReturnsAllSpeakersWithName 重命名为 ItReturnsAllSpeakersWithProperties 并将 IDLocation 测试合并到这个测试中。

[Fact]
public void ItReturnsAllSpeakersWithProperties()
{
  // Arrange
  // Act
  var speakers = _speakerService.GetAll().ToList();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);

  for (var i = 0; i < speakers.Count; i++)
  {
    Assert.NotNull(_speakerService.HardCodedSpeakers[i].Name);
    Assert.Equal(_speakerService.HardCodedSpeakers[i].Name, speakers[i].Name);
    Assert.NotNull(_speakerService.HardCodedSpeakers[i].Id);
    Assert.Equal(_speakerService.HardCodedSpeakers[i].Id, speakers[i].Id);
    Assert.NotNull(_speakerService.HardCodedSpeakers[i].Location);
    Assert.Equal(_speakerService.HardCodedSpeakers[i].Location, speakers[i].Location);
  }
}

存储

在前一章中,数据是在 SpeakerController 类中硬编码的。数据随后被移动到 SpeakerService 中的硬编码集合中。最终,数据将持久化到数据库中。目前,将数据从 SpeakerService 中移除就足够了。

将使用存储库层来将数据访问层与其他应用程序部分分离。为了实现这一点,必须引入一个存储库。为了创建存储库,必须建立需求。通过要求 SpeakerService 接受一个 IRepository 来缓慢开始。

[Fact]
public void ItAcceptsIRepository()
{
  // Arrange
  IRepository fakeRepository = new FakeRepository();

  // Act
  var service = new SpeakerService(fakeRepository);

  // Assert
  Assert.NotNull(service);
}

当然,这将导致应用程序无法编译。创建一个 IRepository 接口,一个 FakeRepository 类,并修改 SpeakerService 以接受一个 IRepository

public SpeakerService(IRepository repository)
{
}

IRepository 接口

IRepository 接口将定义与数据访问层交互的方法签名。这个接口将缓慢增长,由测试指导。在 第八章 的 抽象问题 中,将提供更多细节并引入更多概念。目前,这个接口将仅仅是用于 SpeakerService 测试的 FakeRepository 的一个合同。

FakeRepository

现在,FakeRepository已经创建,可以将HardCodedSpeakers移动到FakeRepository中。首先,需要创建几个迭代测试。

与你自己创建的FakeRepository交互允许你替换值并为测试目的创建额外的功能。

[Fact]
public void ItCallsRepository()
{
  // Arrange
  FakeRepository fakeRepository = new FakeRepository();
  var service = new SpeakerService(fakeRepository);

  // Act
  var speakers = service.GetAll();

  // Assert
  Assert.True(fakeRepository.GetAllCalled);
}

通过引入一个公共字段,可以在FakeRepository中应用与Moq相同的功能。

public bool GetAllCalled { get; private set; }

public void GetAll()
{
  GetAllCalled = true;
}

现在,通过修改现有的ItReturnsAllSpeakersItReturnsAllSpeakersWithProperties测试,确保当调用GetAllFakeRepository返回HardCodedSpeakers

[Fact]
public void ItReturnsAllSpeakers()
{
  // Arrange
  // Act
  var speakers = _speakerService.GetAll();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);
  Assert.Equal(_fakeRepository.HardCodedSpeakers.Count, speakers.Count());
}
[Fact]
public void ItReturnsAllSpeakersWithProperties()
{
  // Arrange
  // Act
  var speakers = _speakerService.GetAll().ToList();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);

  for (var i = 0; i < speakers.Count; i++)
  {
    Assert.NotNull(_fakeRepository.HardCodedSpeakers[i].Name);
    Assert.Equal(_fakeRepository.HardCodedSpeakers[i].Name, speakers[i].Name);
    Assert.NotNull(_fakeRepository.HardCodedSpeakers[i].Id);
    Assert.Equal(_fakeRepository.HardCodedSpeakers[i].Id, speakers[i].Id);
    Assert.NotNull(_fakeRepository.HardCodedSpeakers[i].Location);
    Assert.Equal(_fakeRepository.HardCodedSpeakers[i].Location, speakers[i].Location);
  }
}    

可能看起来已经付出了很多努力,只是为了把问题推到一边。所有这些都是为了成功地向一个真正功能性和可维护的应用程序迈进所必需的努力。然而,还有更多的工作要做。

使用工厂与FakeRepository交互

到目前为止,这已经是一个相对直接的练习。Speaker类代表了将被持久化到数据库的对象的形状。HardCodedSpeakers集合代表了数据库中所有的演讲者

无论是在测试文件中还是在其他地方,拥有或维护一组硬编码的数据都不是完全理想的。为测试编写者提供一种定义测试数据的方式将更加灵活。

使用工厂创建演讲者并将其添加到FakeRepository提供了一种更干净、更易于维护的方式来管理需要特定数据场景的测试的状态。

public static class SpeakerFactory
{
  public static Speaker Create(FakeRepository fakeRepository, int id = 1, string name = "Joshua", string location = "Springfield, IL")
  {
    var speaker = new Speaker
    {
      Id = id,
      Name = name,
      Location = location
    };

    fakeRepository.Speakers.Add(speaker);

    return speaker;
  }
}

注意,已为 id、name 和 location 定义了默认值。这允许用户在需要时提供特定值,或者在没有提供它们的情况下继续操作。

FakeRepository还必须进行修改,以删除HardCodedSpeakers并公开一个演讲者集合。

public class FakeRepository : IRepository
{
  public List<Speaker> Speakers = new List<Speaker>();
  public bool GetAllCalled { get; private set; }

  public IEnumerable<Speaker> GetAll()
  {
    GetAllCalled = true;

    return Speakers;
  }
}

现在,对于每个测试,可以提供一组特定的数据来进行测试。所需的所有操作只是调用工厂来创建一个或多个演讲者并将其添加到FakeRepository中。

public GetAll()
{
  _fakeRepository = new FakeRepository();
  SpeakerFactory.Create(_fakeRepository);
  _speakerService = new SpeakerService(_fakeRepository);
}

如果你一直在跟随前几章中的相同解决方案,你可能需要修改搜索测试。

public Search()
{
  var fakeRepository = new FakeRepository();
  SpeakerFactory.Create(fakeRepository);
  SpeakerFactory.Create(fakeRepository, name:"Josh");
  SpeakerFactory.Create(fakeRepository, name:"Joseph");
  SpeakerFactory.Create(fakeRepository, name:"Bill");
  _speakerService = new SpeakerService(fakeRepository);
}

软删除

决定能够从系统中“软删除”一个演讲者将是有用的。一个“软删除”允许记录被标记为已删除,而不需要物理删除记录。这将有助于维护引用完整性,同时实现预期的结果。

首先,向SpeakerFactory添加一个扩展方法IsDeleted,该方法将设置演讲者以供删除。

public static Speaker IsDeleted(this Speaker speaker)
{
  speaker.IsDeleted = true;
  return speaker;
}

现在,创建一个测试以确保当调用GetAll时不会返回这个演讲者。

[Fact]
public void GivenSpeakerIsDeletedSpeakerIsNotReturned()
{
  // Arrange
  var fakeRepository = new FakeRepository();
  SpeakerFactory.Create(fakeRepository).IsDeleted();
  var speakerService = new SpeakerService(fakeRepository);

  // Act
  var speakers = speakerService.GetAll().ToList();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);
  Assert.Equal(0, speakers.Count);           
}

最后,修改代码以确保不会返回“已删除”的演讲者。

public IEnumerable<SpeakerSummary> GetAll()
{
  return _repository.GetAll()
    .Where(x => !x.IsDeleted)
    .Select(speaker => new SpeakerSummary
      {
        Id = speaker.Id,
        Name = speaker.Name,
        Location = speaker.Location
      });
}

演讲者详细信息

接下来,我们将讨论演讲者详细信息。我们选择在后端应用程序中继续,因为我们将在接下来的章节中将整个程序结合起来。

如前所述,这是第一组需求真正价值所在的地方。用户组和会议组织者将能够使用详细信息视图中的信息联系演讲者。

API

为了返回单个演讲者的详细信息,需要一个新端点。需要一个名为Get的新方法,它将接受一个整数 ID 并返回一个SpeakerDetail ViewModel。

API 测试

为了开始,添加一个名为Get的新测试类。现在,添加一个测试来检查Get方法是否存在。

[Fact]
public void ItExists()
{
  // Arrange
  var speakerServiceMock = new Mock<ISpeakerService>();
  var controller = new SpeakerController(speakerServiceMock.Object);

  // Act
  var result = controller.Get();
}

通过向SpeakerController添加Get方法来使这个测试通过。注意,在以下示例中,Arrange测试设置已经被移动到测试类的构造函数中。

[Fact]
public void ItExists()
{
  // Arrange
  // Act
  _controller.Get();
}

接下来,确保Get方法接受一个整数。

[Fact]
public void ItAcceptsInteger()
{
  // Arrange
  // Act
  _controller.Get(1);
}

为了使这个测试通过,需要在Get方法中添加一个整数参数。此时,可以安全地删除ItExists方法。这个测试需要修改以适应这个变化,并且其存在性将通过新的测试来验证。

public void Get(int id)
{
} 

现在测试已经确认Get方法接受一个整数,现在确认它返回一个Ok结果。

[Fact]
public void ItReturnsOkObjectResult()
{
  // Arrange
  // Act
  var result = _controller.Get(1);

  // Assert
  Assert.IsType<OkObjectResult>(result);
}

现在,确保结果是SpeakerDetail类型。

[Fact]       
public void ItReturnsSpeakerDetail()
{
  // Arrange
  // Act
  var result = _controller.Get(1) as OkObjectResult;

  // Assert
  Assert.NotNull(result);
  Assert.NotNull(result.Value);
  Assert.IsType<SpeakerDetail>(result.Value);
}

为了使这个测试通过,需要一个SpeakerDetail对象。创建一个没有任何属性的空对象,因为测试目前还没有要求任何属性。

public IActionResult Get(int id)
{
  return Ok(new SpeakerDetail());
} 

就像GetAll方法一样,这个操作的逻辑应该位于Service中。创建一个测试来检查SpeakerService中的Get方法是否使用Moq被调用。

[Fact]
public void ItCallsGetServiceOnce()
{
  // Arrange
  // Act
  _controller.Get(1);

  // Assert
  _speakerServiceMock.Verify(mock => mock.Get(), Times.Once());
}

为了使应用程序能够编译,需要在IService接口中添加一个Get方法的签名。

void Get();

为了使应用程序能够编译,需要修改SpeakerService

public void Get()
{
  throw new NotImplementedException();
}

为了使这个测试通过,只需调用SpeakerServiceGet方法。

public IActionResult Get(int id)
{
  _speakerService.Get();

  return Ok(new SpeakerDetail());
}

ISpeakerServiceGet方法的签名需要修改为返回SpeakerDetail而不是void

SpeakerDetail Get();

现在确保传递给SpeakerController中的Get方法的 ID 与传递给SpeakerService中的Get方法的 ID 相同。

[Fact]
public void ItCallsGetServiceWithProvidedId()
{
  // Arrange
  const int id = 1;

  // Act
  _controller.Get(id);

  // Assert
  _speakerServiceMock.Verify(mock => mock.Get(id),Times.Once());
}

这将需要对ISpeakerService接口以及SpeakerService类进行修改。

SpeakerDetail Get(int id);

...

public SpeakerDetail Get(int id)
{
  throw new NotImplementedException();
}

现在返回SpeakerServiceGet方法的返回结果。

[Fact]
public void GivenSpeakerServiceThenResultIsReturned()
{
  // Arrange
  // Act
  var result = _controller.Get(1) as OkObjectResult;

  // Assert
  Assert.NotNull(result);
  var speaker = ((SpeakerDetail)result.Value);
  Assert.Equal(_speaker, speaker);
}

为了使这个测试通过,只需返回Get方法的返回结果。

public IActionResult Get(int id)
{
  var speaker = _speakerService.Get();

  return Ok(speaker);
}

这是SpeakerController当前最终结果的样子:

using Microsoft.AspNetCore.Mvc;
using SpeakerMeet.Api.Services;

namespace SpeakerMeet.Api.Controllers
{
  [Route("api/[controller]")]
  public class SpeakerController : Controller
  {
    private readonly ISpeakerService _speakerService;

    public SpeakerController(ISpeakerService speakerService)
    {
      _speakerService = speakerService;
    }

    [Route("search")]
    public IActionResult Search(string searchString)
    {
      var speakers = _speakerService.Search(searchString);

      return Ok(speakers);
    }

    public IActionResult GetAll()
    {
      var speakers = _speakerService.GetAll();

      return Ok(speakers);
    }

    public IActionResult Get(int id)
    {
      var speaker = _speakerService.Get(id);

      return Ok(speaker);
    }
  }
}

服务

现在控制器正在调用Moq服务的Get方法,现在是时候在SpeakerService中实现这个方法了。

服务测试

Get方法是由于之前的测试声明的。创建一个新的ItExists测试并删除实现以查看它失败。

[Fact]
public void ItHasGetMethod()
{
  // Act
  // Arrange
  _speakerService.Get();
}

通过实现Get方法来使这个测试通过。

public void Get()
{
}

现在确保Get方法接受一个整数。

[Fact]
public void ItAcceptsAnInteger()
{
  // Act
  // Arrange
  _speakerService.Get(1);
}

修改Get方法以接受一个整数。

public SpeakerDetail Get(int id)
{
}

测试Get方法返回一个SpeakerDetail对象。

[Fact]
public void ItReturnsSpeakerDetail()
{
  // Arrange
  // Act
  var speaker = _speakerService.Get(1);

  // Assert
  Assert.NotNull(speaker);
  Assert.IsType<SpeakerDetail>(speaker);
}

为了使这个测试通过,只需返回一个新的SpeakerDetail对象。

public SpeakerDetail Get(int id)
{
  return new SpeakerDetail();
}

验证返回的SpeakerDetail包含一个 ID。

[Fact]
public void GivenSpeakerReturnsId()
{
  // Arrange
  // Act
  var speaker = _speakerService.Get(1);

  // Assert
  Assert.Equal(1, speaker.Id);
}

现在使测试通过。

public SpeakerDetail Get(int id)
{
  return new SpeakerDetail
  {
    Id = 1,
    Name = "Joshua"
  };
}

确认SpeakerDetail包含一个名字。

[Fact]
public void GivenSpeakerReturnsName()
{
  // Arrange
  // Act
  var speaker = _speakerService.Get(1);

  // Assert
  Assert.Equal("Joshua", speaker.Name);
}

并使测试通过。

public SpeakerDetail Get(int id)
{
  return new SpeakerDetail
  {
    Id = 1,
    Name = "Joshua"
  };
}

最后,确保返回Location

[Fact]
public void GivenSpeakerReturnsLocation()
{
  // Arrange
  // Act
  var speaker = _speakerService.Get(1);

  // Assert
  Assert.Equal("Tampa, FL", speaker.Location);
}

通过返回位置来使测试通过。

public SpeakerDetail Get(int id)
{
  return new SpeakerDetail
  {
    Id = 1,
    Name = "Joshua",
    Location = "Tampa, FL"
  };
}

清理测试

不要忘记清理和重构测试。折叠属性测试。

[Fact]
public void GivenSpeakerReturnsSpeakerWithProperties()
{
  // Arrange
  // Act
  var speaker = _speakerService.Get(1);

  // Assert
  Assert.Equal(1, speaker.Id);
  Assert.Equal("Joshua", speaker.Name);
}

更多来自存储库的信息

现在,验证存储库是否被调用。

[Fact]
public void ItCallsRepository()
{
  // Arrange
  var fakeRepository = new FakeRepository();
  var service = new SpeakerService(fakeRepository);

  // Act
  service.Get(-1);

  // Assert
  Assert.True(fakeRepository.GetCalled);
}

现在,通过实现必要的修改来确保测试通过。

public SpeakerDetail Get(int id)
{
  _repository.Get();

  return new SpeakerDetail
  {
    Id = 1,
    Name = "Joshua"
  };
}

额外的工厂工作

如前所述,如果值不是硬编码的,那就很理想了。使用工厂创建一个说话者,并让存储库返回指定的说话者。

[Fact]
public void ItReturnsSpeakerFromRepository()
{
  // Arrange
  var fakeRepository = new FakeRepository();
  var expectedSpeaker = SpeakerFactory.Create(fakeRepository, 2, "Bill");
  var service = new SpeakerService(fakeRepository);

  // Act
  var actualSpeaker = service.Get(expectedSpeaker.Id);

  // Assert
  Assert.True(fakeRepository.GetCalled);
  Assert.Equal(expectedSpeaker.Id, actualSpeaker.Id);
  Assert.Equal(expectedSpeaker.Name, actualSpeaker.Name);
}

要使这个测试通过,需要对IRepositoryFakeRepositoryService进行修改。

IRepository:

        Speaker Get(int id);

FakeRepository:

public Speaker Get(int id)
{
  GetCalled = true;

  return Speakers.Find(x => x.Id == id);
}

Service:

public SpeakerDetail Get(int id)
{
  var speaker = _repository.Get(id);

  return new SpeakerDetail
  {
    Id = speaker.Id,
    Name = speaker.Name
  };
}

所有之前的ItReturnsSpeakerFromRepository测试现在都可以删除。这些都是为了达到这个目的而进行的繁琐工作。

现在,为了确保这可以与多个值一起工作,将最后一个测试转换为理论集。

[Theory]
[InlineData(1, "Joshua")]
[InlineData(2, "Bill")]
[InlineData(3, "Suzie")]
public void ItReturnsSpeakerFromRepository(int id, string name)
{
  // Arrange
  var expectedSpeaker = SpeakerFactory.Create(_fakeRepository, id, name);
  var service = new SpeakerService(_fakeRepository);

  // Act
  var actualSpeaker = service.Get(expectedSpeaker.Id);

  // Assert
  Assert.True(_fakeRepository.GetCalled);
  Assert.Equal(expectedSpeaker.Id, actualSpeaker.Id);
  Assert.Equal(expectedSpeaker.Name, actualSpeaker.Name);
}

所有测试都应该通过。如果由于某种原因遇到失败的测试,不要继续,直到失败的测试得到解决。

测试异常情况

测试异常情况是一个非常重要的步骤。在这种情况下,业务已经定义了一个情况,即如果说话者不存在,我们将返回 SPEAKER NOT FOUND 错误。对于开发者来说,考虑业务可能遗漏的任何重大边缘情况也很重要。如果可能的话,与业务讨论它们,并将它们添加到规范中。

现在测试说话者必须存在。

[Fact]
public void GivenSpeakerNotFoundThenSpeakerNotFoundException()
{
  // Arrange
  var service = new SpeakerService(_fakeRepository);

  // Act
  var exception = Record.Exception(() => service.Get(-1));

  // Assert
  Assert.IsAssignableFrom<SpeakerNotFoundException>(exception);
}

并使其通过。

public SpeakerDetail Get(int id)
{
  var speaker = _repository.Get(id);

  if (speaker == null)
  {
    throw new SpeakerNotFoundException();
  }

  return new SpeakerDetail
  {
    Id = speaker.Id,
    Name = speaker.Name
  };
}

现在,验证说话者没有被删除。如果被删除,则抛出相同的SpeakerNotFoundException.

[Fact]
public void GivenSpeakerIsDeletedThenSpeakerNotException()
{
  // Arrange
  var expectedSpeaker = SpeakerFactory.Create(_fakeRepository).IsDeleted();
  var service = new SpeakerService(_fakeRepository);

  // Act
  var exception = Record.Exception(() => service.Get(expectedSpeaker.Id));

  // Assert
  Assert.IsAssignableFrom<SpeakerNotFoundException>(exception);
}

使这个测试通过的最简单、最有效的方法是在找到的说话者已被删除时抛出异常。对Get方法进行必要的更改。

public SpeakerDetail Get(int id)
{
  var speaker = _repository.Get(id);

  if (speaker == null || speaker.IsDeleted)
  {
    throw new SpeakerNotFoundException();
  }

  return new SpeakerDetail
  {
    Id = speaker.Id,
    Name = speaker.Name
   };
}

摘要

现在,你应该对围绕“说话者见面”应用的要求感到相当舒适,并且已经对后端应用说话者部分的 API、Service 和 Repository 层有了良好的介绍。Mocks 和 Fakes 在程序的测试驱动中继续发挥作用。

在第八章“抽象问题”中,将讨论更多关于抽象的内容。SpeakerSummarySpeakerDetail的模型将扩展以包含更多属性。将提供更多关于如何最佳地增加应用程序的功能和复杂性的详细信息。

第八章:抽象问题

现在,很容易在网上找到资源集成到你的应用程序中。许多提供的功能非常适合任何数量的应用程序。毕竟,为什么要在别人已经做了大部分工作的基础上浪费时间呢?

在本章中,我们将了解:

  • 抽象 Gravatar 服务

  • 扩展存储库模式

  • 使用通用存储库和 Entity Framework

抽象问题

现在有很多工具和库可以帮助创建一个功能齐全的应用程序。在应用程序中集成这些第三方系统可能相当容易。然而,有时你可能需要用另一个第三方库替换一个。或者,你可能会发现自己依赖于第三方系统提供的实现,结果发现该实现随着后续更新而发生了变化。你该如何避免这些潜在的问题?

依赖外部控制之外的代码可能会在未来给你带来问题。如果依赖的库中引入了变更,可能会破坏你的系统。或者,如果你的需求发生变化,系统不再满足你的特定需求,你可能需要重写应用程序的大量部分。

不要直接依赖任何第三方系统。抽象出细节,使你的应用程序只依赖于你定义的接口。如果你定义了接口并且只暴露你需要的功能,那么在需要时进行更改将变得非常简单。更改可能包括小的更新或替换整个库。你希望这些更改对应用程序的其他部分影响最小。

不要依赖第三方实现;专注于驱动测试你的代码。

在考虑测试驱动开发的应用程序开发过程中,往往会诱使人们测试第三方软件。虽然确保任何第三方库或工具集成到你的系统中表现良好很重要,但最好专注于你系统中的行为。确保你的系统能够良好地处理你希望公开的功能。

这意味着你应该处理正常路径以及可能抛出的任何异常。优雅地恢复错误将允许你的应用程序在第三方服务未按预期运行时继续运行。

Gravatar

Speaker Meet 应用程序使用 Gravatar 来显示演讲者、社区和会议头像图像。Gravatar 是一个在线服务,将电子邮件地址与图像关联起来。用户可以创建一个账户并添加他们希望任何请求其图像的服务显示的图像。通过创建用户的电子邮件地址的 MD5 哈希并使用哈希值从 Gravatar 请求图像来检索图像。通过依赖哈希值,用户的电子邮件地址不会被暴露。

Gravatar 服务允许消费者向 HTTP 调用提供可选参数,以请求特定大小、评级或默认图像(如果未找到任何图像)。这些选项包括:

  • s:请求的图像大小;默认情况下,这是 80 x 80 像素

  • d:如果未找到任何图像,则返回的默认图像;选项包括 404、mm神秘人)、identicon 等

  • f:强制默认;即使找到图像也总是返回默认图标

  • r:评级;用户可以将他们的图像标记为 G、PG、R 和 X

通过提供这些值,你可以控制你希望在应用程序中显示的图像的大小和类型。Speaker Meet 应用程序依赖于 Gravatar 的默认提供项。

从接口开始

看起来 Gravatar 网站上有许多可用的选项。为了保护应用程序的其他部分,Gravatar 的功能将通过 Speaker Meet 应用程序中的一个类来公开。这个功能首先由一个接口定义。

所需的接口可能看起来像这样:

  public interface IGravatarService
  {
    string GetGravatar(string emailAddress);
    string GetGravatar(string emailAddress, int size);
    string GetGravatar(string emailAddress, int size, string rating);
    string GetGravatar(string emailAddress, int size, string rating, 
    string imageType);
  }

要开始,你必须首先编写一些测试。记住,你不应该在没有失败的单元测试的情况下编写任何生产代码。

实现接口的测试版本

为了创建一个名为 IGravatarService 的接口,首先必须在应用程序中有一个需求。在 SpeakerServiceTestsGet 类中创建一个名为 ItTakesGravatarService 的测试:

[Fact]
public void ItTakesGravatarService()
{
  // Arrange
  var fakeGravatarService = new FakeGravatarService();
  var service = new SpeakerService(_fakeRepository, fakeGravatarService);           
}

这将导致编译错误。创建一个 IGravatarService 并修改 SpeakerService 的构造函数,使其成为一个参数。

接口:

public interface IGravatarService
{
}

SpeakerService 方法:


public SpeakerService(IRepository repository, IGravatarService gravatarService)
{
  _repository = repository;
}

为了让测试编译,创建一个 FakeGravatarService,它可以提供给正在测试的 SpeakerService。记住,你并不是在测试 FakeGravatarService,而是在测试 SpeakerService 是否接受一个 IGravatarService 实例。

现在,确保当请求一个单独的 Speaker 时调用 FakeGravatarServiceGetGravatar 方法。

[Fact]
public void ItCallsGravatarService()
{
  // Arrange
  var expectedSpeaker = SpeakerFactory.Create(_fakeRepository);
  var service = new SpeakerService(_fakeRepository, _fakeGravatarService);

  // Act
  service.Get(expectedSpeaker.Id);

  // Assert
  Assert.True(_fakeGravatarService.GetGravatarCalled);
}

修改接口以添加一个 GetGravatar 方法:

public interface IGravatarService
{
  void GetGravatar();
}

FakeGravatarService 中实现此方法。这与 第七章,测试驱动 C# 应用程序中的 FakeRepositoryGetCalled 检查类似:

public class FakeGravatarService : IGravatarService
{
  public bool GetGravatarCalled { get; set; }

  public void GetGravatar()
  {
    GetGravatarCalled = true;
  } 
}

接下来,确保当调用 SpeakerServiceGet(id) 时执行 GetGravatar 函数:

private readonly IRepository _repository;
private readonly IGravatarService _gravatarService;

public SpeakerService(IRepository repository, IGravatarService gravatarService)
{
  _repository = repository;
  _gravatarService = gravatarService;
}

public Models.SpeakerDetail Get(int id)
{
  var speaker = _repository.Get(id);

  if (speaker == null || speaker.IsDeleted)
  {
    throw new SpeakerNotFoundException();
  }

  var gravatar = _gravatarService.GetGravatar();

  return new Models.SpeakerDetail
  {
    Id = speaker.Id,
    Name = speaker.Name,
    Location = speaker.Location
  };
}

测试现在应该通过。然而,FakeGravatarService 目前并没有提供任何实际价值。GetGravatar 方法应该使用提供的电子邮件地址执行:

[Fact]
public void ItCallsGravatarServiceWithEmail()
{
  // Arrange
  var expectedSpeaker = SpeakerFactory.Create(_fakeRepository, emailAddress: "example@test.com");
  var service = new SpeakerService(_fakeRepository, _fakeGravatarService);

  // Act
  service.Get(expectedSpeaker.Id);

  // Assert
  Assert.True(_fakeGravatarService.WithEmailCalled);
  Assert.Equal(expectedSpeaker.EmailAddress, _fakeGravatarService.CalledWith);
}

你需要修改 SpeakerFactory 以接受电子邮件地址,并将 Speaker 模型类修改为包含电子邮件地址属性。

修改 FakeGravatarServiceIGravatarService 接口中的 GetGravatar 方法以接受一个字符串 emailAddress。确保在执行 GetGravatar 时设置 CalledWith 属性:

public string CalledWith { get; set; }

public void GetGravatar(string emailAddress)
{
  GetGravatarCalled = true;
  CalledWith = emailAddress;
}

确保使用演讲者的电子邮件地址调用 GetGravatar 方法:

public Models.SpeakerDetail Get(int id)
{
  var speaker = _repository.Get(id);

  if (speaker == null || speaker.IsDeleted)
  {
    throw new SpeakerNotFoundException();
  }

  var gravatar = _gravatarService.GetGravatar(speaker.EmailAddress);

  return new Models.SpeakerDetail
  {
    Id = speaker.Id,
    Name = speaker.Name,
    Location = speaker.Location,
  };
}

最后,将 GetGravatar 方法的返回值设置到 SpeakerDetail 对象的新属性 Gravatar 上:

[Fact]
public void GivenGravatarServiceThenItSetsGravatar()
{
  // Arrange
  var expectedSpeaker = SpeakerFactory.Create(_fakeRepository);
  var service = new SpeakerService(_fakeRepository, 
   _fakeGravatarService);

  // Act
  var actualSpeaker = service.Get(expectedSpeaker.Id);
  var expectedGravatar = 
   _fakeGravatarService.GetGravatar(expectedSpeaker.EmailAddress);

  // Assert
  Assert.True(_fakeGravatarService.WithEmailCalled);
  Assert.Equal(expectedSpeaker.Id, actualSpeaker.Id);
  Assert.Equal(expectedSpeaker.Name, actualSpeaker.Name);
  Assert.Equal(expectedGravatar, actualSpeaker.Gravatar);
}

你需要修改 FakeGravatarService 及其接口,以及 SpeakerServiceGet 方法以返回一个字符串,并在 SpeakerDetail 类中添加一个 Gravatar 属性:

public string GetGravatar(string emailAddress)
{
  WithEmailCalled = true;
  CalledWith = emailAddress;

  return System.Reflection.MethodBase.GetCurrentMethod().Name;
}

GetGravatar 方法的返回值不重要,只要它是一个已知的值。记住,你并不是在测试 FakeGravatarService 是否返回一个有效的 Gravatar 图像 URL,只是测试该方法返回了某个值,并且返回值被设置到 SpeakerDetail 对象的 Gravatar 属性上。

实现接口的生产版本

到目前为止,已经创建了一个包含一个方法 GetGravatarIGravatarService 接口。有几种选项可以用来与 Gravatar 交互。你可以选择编写自己的方法来直接与其公共 API 通信。Speaker Meet 应用程序使用可用的 NuGet 包之一,GravatarHelper.NetStandard

通过 NuGet 安装 GravatarHelper.NetStandard 的最新版本以便跟随操作。

在审查 Gravatar 网站时,看起来他们提供了各种可选参数。为了扩展 IGravatarService 接口及其实现,创建一个新的测试类 GetGravatar

public class GetGravatar
{
}

现在测试 GravatarService 是否存在:

[Fact]
public void ItExists()
{
  var gravatarService = new GravatarService();
}

通过在 SpeakerService 相同的位置创建一个 GravatarService 类来使这个测试通过:

namespace SpeakerMeet.Api.Services
{
  public class GravatarService
  {}
}

现在,确保 GravatarService 实现了 IGravatarInterface

[Fact]
public void ItImplementsIGravatarInterface()
{
  // Arrange
  // Act
  var gravatarService = new GravatarService();

  // Assert
  Assert.IsAssignableFrom<IGravatarService>(gravatarService);
}

从之前的一组测试中,已经在接口中定义了一个 GetGravatar 方法。通过实现接口来使测试通过:

public class GravatarService : IGravatarService
{
  public string GetGravatar(string emailAddress)
  {
    throw new System.NotImplementedException();
  }
}

通过一个新的测试来验证 GetGravatar 方法是否存在:

[Fact]
public void ItHasGetGravatarMethod()
{
  // Arrange
  IGravatarService gravatarService = new GravatarService();

  // Act
  gravatarService.GetGravatar("example@test.com");
}

通过返回一个空字符串来允许这个测试通过:

public string GetGravatar(string emailAddress)
{
  return string.Empty;
}

以下测试被分类为 集成测试,因为它们正在测试 Speaker Meet 应用程序如何与第三方系统交互。将类装饰为这样的形式:

[Trait("Category", "Integration")]
public class GetGravatar

许多测试运行器允许你根据特征类别有条件地运行或排除这些测试。一旦定义了集成测试并且已知它们可以成功运行,你可以在更改时忽略或禁用它们,或者只在提交前运行它们。

现在,测试当提供电子邮件地址时,Gravatar 服务返回一个已知值。如果你有一个 Gravatar 账户,请随意提供你自己的电子邮件地址并测试你的 Gravatar URL:

[Fact]
public void GivenEmailAddressThenGravatarReturned()
{
  // Arrange
  IGravatarService gravatarService = new GravatarService();

  // Act
  var actual = gravatarService.GetGravatar("example@test.com");

  // Assert
  Assert.Equal("http://www.gravatar.com/avatar/29e3f53ee49fae541ee0f48fb712c231", actual);
}

现在,通过调用GravatarHelper提供的静态方法来使这个测试通过:

public string GetGravatar(string emailAddress)
{
  return Gravatar.GetGravatarImageUrl(emailAddress);
}

测试现在应该通过了。你可以看到实现是如何从应用程序的其他部分隐藏起来的。界面是通过在SpeakerService中进行一系列测试而出于必要性设计的。

那么,为什么不直接从SpeakerService和其他地方调用GravatarHelper方法呢?记住,你不应该依赖于第三方实现。如果GravatarHelper被更改或被完全替换,那么任何直接调用它的类可能都需要更改。通过使用接口和外观,唯一可能需要更改的类是GravatarService

未来的规划

未来的规划可能不好。如果你现在编写代码是为了预期未来的问题,你可能会浪费努力。不要编写你不需要的代码。这可能会增加复杂性,从而减慢开发速度。

记住术语YAGNI你不需要它),这适用于任何没有立即需求的代码。之前添加的GravatarService方法可以用作那个例子的说明。到目前为止提供的示例中,没有一个需要刚刚创建的额外方法。如果由于某种原因GravatarHelper的实现发生变化,那么已经编写的代码可能需要更改。如果它目前没有被使用,这将是徒劳的努力。

那么,未来的规划从哪里开始,好的抽象在哪里结束?抽象第三方系统。仅暴露立即需要的方法和功能。通过屏蔽应用程序的其他部分不受任何第三方系统细节的影响来最小化更改的痛苦。这包括.NET 框架和诸如 Entity Framework 之类的 ORM。

抽象数据层

数据层抽象已经通过实现存储库模式开始了。在本节中,我们将努力创建一个有效的抽象,以便连接到 Entity Framework。在我们能够与 Entity Framework 通信之后,我们将专注于使存储库更加通用,能够与多个数据模型一起工作。

扩展存储库模式

创建有效数据层抽象的第一步是确保 CRUD(创建读取更新删除)已经被处理。CRUD是可以在任何数据集上执行的基本操作。IRepository尚未提供对所有这些功能的访问,因此我们将从扩展它开始。

首先创建一个文件夹来包含SpeakerRepository的测试。这个文件夹应该与包含SpeakerService测试和SpeakerController测试的文件夹命名一致。像往常一样,我们从失败的测试开始。在这种情况下,测试失败是因为无法编译:

[Trait("Category", "SpeakerRepository")]
public class Class
{
  [Fact]
  public void ItExists()
  {
    var repo = new SpeakerRepository();
  }
}

创建SpeakerRepository,测试应该通过:

public class SpeakerRepository
{
}

SpeakerRepository需要继承自IRepository,因此要么将存在性测试转换为类型测试,要么创建一个新的测试:

[Fact]
public void ItIsARepository()
{
  // Arrange / Act
  var repo = new SpeakerRepository();

  // Assert
  Assert.IsAssignableFrom<IRepository<Speaker>>(repo);
}

现在,通过继承自IRepository使测试通过。目前我们没有针对功能性的测试,所以将仓库方法留为未实现:

public class SpeakerRepository : IRepository<Speaker>
{
  public Speaker Get(int id)
  {
    throw new System.NotImplementedException();
  }

  public IQueryable<Speaker> GetAll()
  {
    throw new System.NotImplementedException();
  }
}

获取方法

现在SpeakerRepository正确地继承自IRepository,目前由IRepository定义的两个方法需要实现。就像我们对SpeakerService所做的那样,我们需要为Get方法创建一个新的测试类。再次强调,虽然在这个阶段可能看起来有些过度,但为每个方法创建一个文件将有助于随着测试套件的扩展进行组织:

[Trait("Category", "SpeakerRepository")]
public class Get
{
}

现在类已经存在,可以编写的第一个测试方法是简单的存在方法:

[Fact]
public void ItHasGetMethod()
{
  // Arrange
  var repo = new SpeakerRepository();

  // Act
  var result = repo.Get(0);
}

初始时,这个测试将失败,因为 Visual Studio 提供的占位符实现只是抛出NotImplementedException。为了修复这个问题,我们必须返回一些东西;那么应该返回什么呢?没有测试来解释期望的结果,所以我们必须选择一个可以编译但几乎肯定是不正确的值。在这种情况下,正确的、不正确的返回值可能是null

public Speaker Get(int id)
{
  return null;
}

获取所有方法

测试现在通过了。让我们在这里暂停一下,并围绕接口强制执行的GetAll方法进行相同数量的测试。像之前一样,为GetAll创建一个新的类:

[Trait("Category", "SpeakerRepository")]
public class GetAll
{
}

创建一个存在方法,它将确保当被调用时该方法不会抛出异常:

[Fact]
public void ItHasGetAllMethod()
{
  // Arrange  
  var repo = new SpeakerRepository();

  // Act
  var result = repo.GetAll();
}

创建方法

如果假设所有仓库方法都存在,测试仓库模式可能会更容易。不幸的是,仓库呈现了一个鸡生蛋的问题。如果没有在仓库中创建条目的方法,我们如何测试GetGetAll?同时,如果没有从仓库检索条目的方法,我们如何测试CreateDelete

接下来创建一个新的类用于Create

[Trait("Category", "SpeakerRepository")]
public class Create
{
}

如前所述,编写一个检查存在性的方法。在这个测试中,我们需要确保测试的是仓库,而不是Create类的实现。这将迫使我们添加到接口中:

[Fact]
public void ItHasCreateMethod()
{
  // Arrange
  IRepository<Speaker> repo = new SpeakerRepository();

  // Act
  var result = repo.Create(new Speaker());
}

你可能已经注意到,在这个测试中,我们从Create方法中得到了一个结果。这可能不是很明显,因为我们从GetGetAll方法中得到了值,但我们选择打破CQRS命令查询责任分离)以支持更 RESTful 的方法。在REST表征状态转移)中,因为它必须保持无状态并且不能提供关于已经完成的操作的信息,所以服务通常返回创建的对象或将来检索该对象的方式。

在这种情况下,你可能会认为我们现在提供了一个有漏洞的抽象的 Web。它可以这样解释。我更喜欢将这个选择视为打开选项而不是限制它们。在隐藏 CQRS 实现背后比反过来工作要容易得多。

现在,为了通过当前失败的测试,需要在IRepository接口中添加一个方法定义,在SpeakerRepository中添加一个方法实现,并且需要修改SpeakerRepository的实现以避免抛出异常:

public interface IRepository<T>
{
  T Get(int id);
  IQueryable<T> GetAll();
  T Create(T item);
}

Speaker Save方法:

public Speaker Save(Speaker speaker)
{
 return null;
}

还需要在定义在第七章,测试驱动 C#应用程序中的FakeRepository中添加一个存根实现。

删除方法

接下来,我们将添加Delete方法。就像之前一样,创建一个新的测试类:

[Trait("Category","SpeakerRepository")]
public class Delete
{
}

就像对于Create方法一样,我们需要将SpeakerRepository视为IRepository。创建一个Delete存在的方法:

[Fact]
public void ItHasDeleteMethod()
{
  // Arrange
  IRepository<Speaker> repo = new SpeakerRepository();
  var speaker = new Speaker();

  // Act
  repo.Delete(speaker);
}

实现这个方法将会稍微容易一些,因为它是一个无返回值的方法,我们并不期望得到结果。关于当方法传递给一个不存在的演讲者时应该如何表现,我们将在稍后做出一些决定。现在,我们可以假设什么也不发生,方法执行成功。

就像之前一样,修改IRepository以包含一个Delete方法:

public interface IRepository<T>
{
  T Get(int id);
  IQueryable<T> GetAll();
  T Create(T item);
  void Delete(T item);
}

现在在SpeakerRepositoryFakeRepository中创建一个存根方法:

public void Delete(Speaker speaker)
{
}

更新方法

对于一个有效的仓储模式和任何有用的系统来说,还需要一个最后的方法。我们需要能够更新我们正在工作的模型。与最后两个方法一样,这个方法在仓储中还不存在,所以让我们添加它。

就像之前一样,首先为它创建一个测试类:

[Trait("Category", "SpeakerRepository")]
public class Update
{
}

就像其他方法一样,创建一个引用IRepositoryItExists测试:

[Fact]
public void ItHasUpdateMethod()
{
  // Arrange
  IRepository<Speaker> repo = new SpeakerRepository();
  var speaker = new Speaker();

  // Act
  var result = repo.Update(speaker);
}

就像之前一样,我们在这里只是存根功能,所以我们还没有实际的演讲者去更新。这个测试,以及其他测试,几乎肯定会在我们开始测试实际功能时发生变化。现在,它们足以确保接口和类有适当的方法。

就像之前一样,将方法添加到接口中,然后添加到SpeakerRepositoryFakeRepository中:

public interface IRepository
{
  TGet(int id);
  IQueryable<T> GetAll();
  T Create(T item);
  T Update(T item);
  void Delete(T item);
}

Speaker Update方法:

public Speaker Update(Speaker speaker)
{
  return null;
}

确保功能

现在所有的方法都已经定义好了,我们可以开始编写功能测试了。我们将从Create开始,然后逐步进行到Delete

创建一个演讲者

之前提到的“鸡生蛋”场景让我们陷入了困境。如果没有创建演讲者,我们就无法从存储库中读取演讲者。除非我们能从存储库中检索到一个演讲者,否则我们也无法验证一个演讲者实际上已经被创建。

解决这个问题的方法之一是使用一种特殊的测试双胞胎,它暴露了类的内部功能,以便对相关信息进行断言。对于Create,我们将使用这种方法。在Create.cs文件中,让我们添加一个假设可测试类已经存在的测试:

[Fact]
public void ItAddsASpeakerToTheRepository()
{
  // Arrange
  var repo = new TestableSpeakerRepository();

  // Act
  var result = repo.Create(new Speaker());

  // Assert
  Assert.Equal(1, repo.SpeakersCollection.Count);
}

为了解决编译错误,必须创建可测试的类。现在,为了更有效地与之一起工作,在同一文件中创建这个类:

public class TestableSpeakerRepository : SpeakerRepository
{
}

现在类已经创建,初始的编译错误已经解决,但一个新的错误出现了:

// Assert
Assert.Equal(1, repo.SpeakersCollection.Count);

这个错误稍微有点难以解决。实际上,我们希望在真实存储库中存在一个演讲者集合。然而,我们没有理由暴露这个集合。实际上并没有创建任何集合。现在,在这个测试中,我们正在询问是否存在一个集合。测试要求TestableSpeakerRepository存在一个集合,但我们知道我们还需要一个用于真实的SpeakerRepository。让我们扮演魔鬼的代言人,实际上做我们知道并不完全正确的事情:

public class TestableSpeakerRepository : SpeakerRepository
{
  public IQueryable<Speaker> SpeakersCollection { get; set; }
}

这个更改并没有完全使测试通过;在编写测试时,我们匆忙访问了Count属性。Count属性只在列表上,但为了限制接口的暴露,直到我们实际上可以用测试来要求它,我们实际上应该使用一个IQueryable。我们可以快速更新测试以反映这个选择:

// Assert
Assert.Equal(1, repo.SpeakersCollection.Count);

现在,执行测试,它最终以实际消息失败。解决这个失败的方法是在调用Create时向演讲者集合添加一个条目。问题是Speakers类与Create类不同。因此,我们必须在SpeakerRepository中也有一个集合:

protected readonly IList<Speaker> Speakers = new List<Speaker>();

public Speaker Create(Speaker speaker)
{
  Speakers.Add(speaker);

  return speaker;
}

需要注意的是_speakers的范围和类型。它是一个IList,因为我们需要向其中添加一个项目;一个IQueryable是不够的。它也是受保护的;_speakers必须对外部世界隐藏,但同时也必须可以从可测试的类中访问。提供这种功能的范围操作符是受保护的。

我们还必须在可测试的类中进行更改,以便使这个测试通过:

internal class TestableSpeakerRepository : SpeakerRepository
{
  public IList<Speaker> SpeakersCollection => Speakers;
}

继续使用Create,我们现在需要验证,当创建一个新的演讲者时,它将收到一个唯一的 ID:

[Fact]
public void ItAssignsUniqueIdsToEachSpeaker()
{
  // Arrange
  var repo = new TestableSpeakerRepository();

  // Act
  var speaker1 = repo.Create(new Speaker());
  var speaker2 = repo.Create(new Speaker());

  // Assert
  Assert.NotEqual(speaker1.Id, speaker2.Id);
}

为了使这个测试通过,我们必须想出某种 ID 生成系统。有很多选择,但其中最简单的一个是在每次调用Create时创建一个私有字段并递增其值:

private int _currentId = 0;

public Speaker Create(Speaker speaker)
{
  speaker.Id = ++_currentId;

  Speakers.Add(speaker);

  return speaker;
}

那个测试通过后,我们现在可以将注意力转向抽象中的漏洞。我们只是简单地将传入的对象放入数据集中。这可能会在需要修复的应用程序中引起问题。

仓库应该通过传递和存储克隆对象来将其对象与应用程序的其他部分隔离,而不是直接访问和提供对象:

[Fact]
public void ItReturnsANewSpeaker()
{
  // Arrange
  var repo = new TestableSpeakerRepository();
  var speaker = new Speaker { Id = 0 };

  // Act
  var result = repo.Create(speaker);

  // Assert
  Assert.Equal(0, speaker.Id);
}

要使这个测试通过,我们需要一些克隆机制。为了尽快使这个测试通过,我们可以简单地使用一个新的对象和对象初始化器:

public Speaker Create(Speaker speaker)
{
  var newSpeaker = new Speaker
  {
    Id = ++_currentId,
    Name = speaker.Name,
    Location = speaker.Location,
    IsDeleted = speaker.IsDeleted
  };

  Speakers.Add(newSpeaker);

  return newSpeaker;
}

现在,我们必须处理引用传递的另一个方向。存储在演讲者集合中的值不应该直接从 Create 方法返回给我们:

[Fact]
public void ItProtectsAgainstObjectChangesAfterCreation()
{
  // Arrange
  var repo = new TestableSpeakerRepository();
  var speaker = repo.Create(new Speaker());

  // Act
  speaker.Name = "test name";

  // Audit
  var result = repo.SpeakersCollection.First();

  // Assert
  Assert.NotEqual("test name", result.Name);
}

注意额外的审计步骤。有时,你可能需要采取一个行动,然后断言一个深层嵌套的值或一个遥远的值。在这种情况下,你可以通过添加审计步骤来保持一个干净的单一步骤行动。

要使这个测试通过,我们必须采取与我们在 Create 方法顶部已经做过的类似行动:

public Speaker Create(Speaker speaker)
{
  var newSpeaker = new Speaker
  {
    Id = ++_currentId,
    Name = speaker.Name,
    Location = speaker.Location,
    IsDeleted = speaker.IsDeleted
  };

  Speakers.Add(newSpeaker);

  var returnableSpeaker = new Speaker
  {
    Id = newSpeaker.Id,
    Name = newSpeaker.Name,
    Location = newSpeaker.Location,
    IsDeleted = newSpeaker.IsDeleted
  };

  return returnableSpeaker;
}

这就完成了 Create 方法所需的功能。现在我们真的应该做一些长期未完成的重构。首先,让我们专注于测试,并减少创建新仓库的重复调用。

创建一个构造函数和一个私有的 repo 字段:

private readonly TestableSpeakerRepository _repo;

public Create()
{
  _repo = new TestableSpeakerRepository();
}

然后在测试中将所有仓库引用替换为 _repo。在减少测试中创建的仓库数量后,测试看起来相当不错。现在我们可以专注于 SpeakerRepository 类。

SpeakerRepository 中,一个立即引人注目的是我们用来克隆演讲者的代码。相同的代码在同一个方法中基本上被输入了两次。让我们现在将其抽象为仓库内部的私有函数。我们可能最终会做出更复杂的解决方案,但就目前而言,这应该足够了。

在类的底部,在所有公共方法之后,我们可以创建一个 CloneSpeaker 方法:

private Speaker CloneSpeaker(Speaker speaker)
{
  return new Speaker
  {
    Id = speaker.Id,
    Name = speaker.Name,
    Location = speaker.Location,
    IsDeleted = speaker.IsDeleted
  };
}

然后我们在 Create 中使用 CloneSpeaker 方法:

public Speaker Create(Speaker speaker)
{
  var newSpeaker = CloneSpeaker(speaker);

  newSpeaker.Id = ++_currentId;

  Speakers.Add(newSpeaker);

  return CloneSpeaker(newSpeaker);
}

获取单个演讲者

随着 Create 的存在,我们现在可以非常容易地断言检索现有或不存在演讲者。根据鲍勃叔叔的转换优先级前提,测试单个项目比测试复数项目更容易、更简单,所以虽然这并不完全符合前提的意图,但我们将测试检索单个演讲者。

我们已经有了存在性测试,那么下一个测试会是什么?最简单的测试是检索单个演讲者,但如果我们试图避免黄金标准,最合适的测试将是检查演讲者不存在时会发生什么。

对于不存在的演讲者,我们有一些立即显而易见的选择。我们可以抛出一个错误,声明请求的演讲者不在系统中。另一个选项是返回一个 null 对象。最后一个选项是简单地返回 null

抛出错误可能是最直接的选择,所以让我们首先考察这个选项:

[Fact]
public void ItThrowsWhenSpeakerIsNotFound()
{
  // Arrange
  var repo = new SpeakerRepository();

  // Act
  var result = Record.Exception(() => repo.Get(-1));

  // Assert
  Assert.IsType<SpeakerNotFoundException>(result.GetBaseException());
}

要使这个测试通过,首先我们必须让它编译。在这个情况下,SpeakerNotFoundException与我们正在使用的SpeakerService中的不同。因此,需要创建它:

public class SpeakerNotFoundException : Exception
{
  public SpeakerNotFoundException()
  {
  }
}

现在测试已经正确编译并失败,我们可以向仓库添加适当的代码以使测试通过:

public Speaker Get(int id)
{
  if (id == -1)
  {
    throw new SpeakerNotFoundException();
  }

  return null;
}

另一个测试的指导原则,帮助你了解你是否走在正确的道路上,而不是挖坑,再次来自 Uncle Bob,A随着测试变得更加具体,代码变得更加通用。如果我们看看我们刚刚编写的代码,它似乎更具体而不是更通用。让我们重构它,以保持向通用性的趋势:

public Speaker Get(int id)
{
  if (id > -1)
  {
    return null;
  }

  throw new SpeakerNotFoundException();
}

这里的变化很微妙,但很重要。考虑这个方法在生产环境中的操作,默认情况确实是抛出异常。在包含所有整数的集合中,我们实际上有一个演讲者的情况只是一个很小的子集,所以通用情况是抛出异常。具体情况实际上是找到一个演讲者。

当找不到演讲者时抛出错误的问题在于,它给应用程序流程带来了可能意外和突然的结束。应用程序到达这个方法的整个逻辑路径现在被破坏,并且必须处理异常。即使我们处理了异常,C#在第一次异常错误处理过程中也会使用额外的 CPU 周期。有时抛出确实是正确的决定;然而,异常应该保留给真正异常的事件。如前所述,Get被一个无效的 ID 调用比被一个有效的 ID 调用的可能性要大得多。所以,在这种情况下,请求一个无效的演讲者并不一定是一个适当的异常事件。

让我们探索空对象模式的替代方案。首先,我们需要将我们的代码回滚到我们开始工作在Get方法时的状态。我们使用源代码控制,因此我们可以非常简单地回滚我们的代码。如果你没有使用源代码控制,我建议你开始使用。以下是我们在再次开始之前Get方法应该处于的状态:

public Speaker Get(int id)
{
  return null;
}

我们可以直接删除断言抛出异常的测试。

空对象模式是一个简单的模式,但其实现稍微复杂一些。基本上,你创建一个从所需类继承的对象,但以完全正确的方式什么都不做。

从我们最终网站使用的角度来看,一个空对象演讲者可能有一个像“Mr. Unknown”这样的名字,并在像“Mid-Nowhere Tech Fest”这样的会议上发表演讲。我们可以创建一个有趣的个人资料图片,并填写无害的用户信息,让用户知道他们请求了一个不存在的演讲者。所有这些信息都可以在之后确定并填写。空对象的重要部分是它代表一个完全有能力的对象,但以正确的方式什么都不做,不会对你的应用程序造成伤害:

[Fact]
public void ItReturnsANullSpeakerWhenNotFound()
{
  // Arrange
  var repo = new SpeakerRepository();

  // Act
  var result = repo.Get(-1);

  // Assert
  Assert.IsType<NullSpeaker>(result);
}

为了修复编译错误,创建一个NullSpeaker类,并让它从Speaker类继承:

public class NullSpeaker : Speaker
{
}

使测试通过相当简单。在这种情况下,我们不必担心现有测试可能会因为返回空对象而中断:

public Speaker Get(int id)
{
  return new NullSpeaker();
}

表面上看,空对象模式似乎是一个相当好的解决方案。实际上,这并不总是如此。在系统中正确地什么都不做是非常困难的。在获取扬声器的情况下,空对象模式可能会工作得很好。但我们还有一个选项要探索。

最后一个选项是简单地返回null。好吧,简单可能不是最好的词。Null可能会在系统中引起很多麻烦。如果你没有处理接收到的null,而系统的遥远部分试图将其作为非null使用,它将造成混乱。早些时候,当我们设计服务时,我们决定期望从调用存储库返回null作为可能的结果;因此,在这种情况下,影响范围应该是有限的,null不应该在系统中引起重大的负面影响。让我们再次回退代码,并探索简单地返回null

[Fact]
public void ItReturnsNullWhenNotFound()
{
  // Arrange
  var repo = new SpeakerRepository();

  // Act
  var result = repo.Get(-1);

  // Assert
  Assert.Null(result);
}

SpeakerRepository中没有事情要做,因为我们已经返回了null。通常,这个测试不会在这里编写。我们会等到有一个返回有效扬声器的测试时再写这个测试。我们想要推迟这个测试的原因是我们不能让它失败。你不会看到这个测试变红,这通常是一个问题。要么这个测试是不必要的,因为它是多余的,在其他地方已经覆盖了,要么这个测试是有缺陷的,因为它不能失败。

在这个测试之前,我们应该写一个应该检索有效值的测试。目前,忽略这个测试,我们将在下一个测试之后回来观察它失败:

[Fact(Skip = "Can't fail")]
public void ItReturnsNullWhenNotFound()
{
  // Arrange
  var repo = new SpeakerRepository();

  // Act
  var result = repo.Get(-1);

  // Assert
  Assert.Null(result);
}

那么,如果我们即将测试一个现有扬声器的检索,之前我们称之为“黄金标准”,为什么我们最初要避免它呢?我们现在要首先测试黄金标准的原因纯粹是因为实现选择。如果我们打算将其用作异常或空对象,我们早就已经那样做了。现在的问题是我们的代码已经返回了null,所以测试空对象对我们没有任何好处。

要测试一个现有的扬声器,首先必须存在一个扬声器。在我们的测试安排部分,我们需要确保我们创建了一个扬声器:

[Fact]
public void ItReturnsASpeakerWhenFound()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = repo.Create(new Speaker {Name = "Test Speaker"});

  // Act
  var result = repo.Get(speaker.Id);

  // Assert
  Assert.NotNull(result);
}

第一步是简单地断言结果不是null。当然,这失败了,因为我们现在所做的只是返回null。让我们来修复它:

public Speaker Get(int id)
{
  return new Speaker();
}

尽管我们现在被迫走黄金标准路线,但我们仍然可以尽可能地避免它。一种方法是通过扮演魔鬼的代言人,我们可以通过返回一个扬声器,但不是正确的扬声器来实现这一点。

我们现在必须修改测试,以确保我们关心的值是正确的:

// Assert
Assert.NotNull(result);
Assert.Equal("Test Speaker", result.Name);

现在来修改代码。我们可以继续扮演魔鬼的代言人,但在这个阶段,那只会导致测试中更多不必要的劳动:

public Speaker Get(int id)
{
  return Speakers.SingleOrDefault(s => s.Id == id);
}

到目前为止,我们面临着一个难题。我们不能调用SingleFirst,因为如果找不到请求的演讲者,它们会抛出异常。尽管如此,我们确实有一个系统规则,当找不到演讲者时返回null。系统默认行为是正确的。我们可以简单地重新启用我们的测试,并接受空测试无法正确验证。另一个选择是有意编写代码,在演讲者集合缺少请求的演讲者时返回一个新的演讲者。选择第二个选项将允许我们看到空测试失败,但我们只是移除它以使测试通过。

在这个情况下,第二个选项可能是最合适的。你们中很多人可能认为没有必要添加强制非空的代码;还有很多人可能认为不需要测试,因为它似乎没有提供价值。这两组人同时是正确的和错误的。他们是正确的:只是为了在两秒后删除而放入代码是愚蠢的。添加一个无法真正失败的测试也是愚蠢的。

然而,我们需要这个测试,因为在未来的某个时刻,可能会出现一个要求,导致空值意外地变得不可能,我们需要记录业务需求,说明该值应该是空的。我们还需要看到每个测试都失败。在这个特定的情况下,我们可能可以避开这部分,但如果我们养成了跳过测试失败的习惯,很快就会让你付出代价。

因此,让我们做出适当的更改,强制空测试失败,然后修复我们的“错误:”

public Speaker Get(int id)
{
  return Speakers.SingleOrDefault(s => s.Id == id) ?? new Speaker();
}

现在,我们可以移除空测试上的跳过属性,然后回到这里移除空合并运算符。

我们还需要一个针对Get的测试。我们必须确保返回的演讲者指针与存储库中的演讲者不同:

[Fact]
public void ItProtectsAgainstObjectChanges()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = repo.Create(new Speaker { Name = "Test Speaker" });
  var retrievedSpeaker = repo.Get(speaker.Id);
  retrievedSpeaker.Name = "New Speaker Name";

  // Act
  var result = repo.Get(speaker.Id);

  // Assert
  Assert.NotEqual(retrievedSpeaker.Name, result.Name);
}

这个测试很难理解,但却是必要的。我们首先必须创建一个演讲者。然后检索我们创建的演讲者,更新检索到的演讲者的名字,然后再检索演讲者。最后,我们验证修改后的演讲者与检索到的演讲者不同。它们不应该相同,因为我们没有保存数据,所以不应该发生任何更新。

这个问题的修复方案已经创建好了,只需要实施:

public Speaker Get(int id)
{
  var speaker = Speakers.SingleOrDefault(s => s.Id == id);

  if (speaker != null)
  {
    speaker = CloneSpeaker(speaker);
  }

  return speaker;
}

最后,我们应该重构。遵循你为创建和提取存储库创建所采取的相同步骤。这个测试类的简单性意味着它不需要任何进一步的重构。

获取多个演讲者

获取多个说话者比获取单个说话者容易得多。我们不必担心找不到说话者。我们也不必处理任何错误条件。我们只需测试的是检索正确的说话者数量,并确保数据安全性与我们迄今为止对其他方法所做的一样。

让我们从在没有说话者时检索所有说话者开始:

[Fact]
public void ItReturnsNoSpeakersWhenThereAreNoSpeakers()
{
  // Arrange
  var repo = new SpeakerRepository();

  // Act
  var result = repo.GetAll();

  // Assert
  Assert.NotNull(result);
}

这在仓库中是一个简单的修复:

public IQueryable<Speaker> GetAll()
{
  return new List<Speaker>().AsQueryable();
}

记住,我们是在扮演魔鬼的辩护者;这是其目的的正确回应。

现在,我们需要断言方法响应的类型和大小:

// Assert
Assert.NotNull(result);
Assert.IsAssignableFrom<IQueryable<Speaker>>(result);
Assert.Equal(0, result.Count());

我知道我们已经提到了单条断言规则以及它并不像听起来那样意味着什么。这个测试仍然遵循规则,因为我们正在断言GetAll返回一个非空空集合的说话者。

接下来,我们测试仓库中存在单个说话者的情况:

[Fact]
public void ItReturnsASingleSpeakerWhenOnlyOneSpeakerExists()
{
  // Arrange
  var repo = new SpeakerRepository();
  repo.Create(new Speaker { Name = "Test Speaker"});

  // Act
  var result = repo.GetAll();

  // Assert
  Assert.Equal(1, result.Count());
}

再次,在仓库中进行适当的调整相当简单:

public IQueryable<Speaker> GetAll()
{
  return Speakers.AsQueryable();
}

接下来,我们想要通过确保返回的说话者是我们创建的说话者来结束这一系列的测试。我们预计这会立即通过,因为我们已经知道这是正确的。这个断言对于确保仓库未来的完整性非常重要。

// Act
var result = repo.GetAll().ToList();

// Assert
Assert.Single(result);
Assert.Equal("Test Speaker", result.First().Name);

注意动作的变化。执行计数和检索集合中的第一个元素都会导致枚举。枚举等于 CPU 周期。为了减少测试时间和生产执行时间,我们想要减少枚举的数量。将IQueryable转换为列表将强制进行单个枚举。

让我们进行最后一次测试,以确保返回值正确,并检查多个说话者是否能够正确返回:

[Fact]
public void ItReturnsManySpeakersWhenManySpeakersExists()
{
  // Arrange
  var repo = new SpeakerRepository();
  repo.Create(new Speaker());
  repo.Create(new Speaker());
  repo.Create(new Speaker());

  // Act
  var result = repo.GetAll().ToList();

  // Assert
  Assert.Equal(3, result.Count);           
}

这个测试只是一个理智的检查,会立即通过。我们的下一个测试将检查数据完整性:

[Fact]
public void ItProtectsAgainstObjectChanges()
{
  // Arrange
  var repo = new SpeakerRepository();
  repo.Create(new Speaker {Name = "Test Name"});
  var speakers = repo.GetAll().ToList();
  speakers.First().Name = "New Name";

  // Act
  var result = repo.GetAll();

  // Assert
  Assert.NotEqual(speakers.First().Name, result.First().Name);
}

仓库中的更新与我们为其他方法所做的是相似的,只有一个例外。我们正在操作整个集合,而不仅仅是单个项目:

public IQueryable<Speaker> GetAll()
{
  return Speakers.Select(CloneSpeaker).AsQueryable();
}

一些同学可能之前见过这种语法。我们所做的是将CloneSpeaker方法的函数指针作为 linq 中Select方法所需的 Lambda 表达式传递。前面的行在功能上与下面的行完全相同:

public IQueryable<Speaker> GetAll()
{
  return Speakers.Select(CloneSpeaker).AsQueryable();
}

是时候清理了。我们与之前相同的清理步骤。唯一真正需要重构的是提取仓库创建。

更新说话者

我们现在可以创建和检索说话者。我们还确保了不能意外地更新它们。所以,让我们确保我们可以有意地更新它们:

[Fact]
public void ItUpdatesASpeaker()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = repo.Create(new Speaker {Name = "Test Name"});
  speaker.Name = "New Name";

  // Act
  var result = repo.Update(speaker);

  // Assert
  Assert.Equal(speaker.Name, result.Name);
}

再次扮演魔鬼的辩护者,这是对仓库的一个简单更新:

public Speaker Update(Speaker speaker)
{
  return speaker;
}

这显然是错误的解决方案;让我们写另一个更具体的测试:

[Fact]
public void ItUpdatesASpeakerInTheRepository()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = repo.Create(new Speaker {Name = "Test Name"});
  speaker.Name = "New Name";

  // Act
  var updatedSpeaker = repo.Update(speaker);

  // Audit
  var result = repo.Get(speaker.Id);

  // Assert
  Assert.Equal("New Name", result.Name);
}

使这个测试通过有点棘手,但不算太坏:

public Speaker Update(Speaker speaker)
{
  var oldSpeaker = Speakers.FirstOrDefault(s => s.Id == speaker.Id);
  var index = Speakers.IndexOf(oldSpeaker);
  Speakers[index] = speaker;           

  return speaker;
}

然而,这个更改导致存在测试失败。查看那个测试,它失败是有好理由的,并且根据我们对Update当前理解的工作方式,实际上不应该通过。让我们对那个测试进行一个小但重要的更新:

[Fact]
public void ItHasUpdateMethod()
{
  // Arrange
  IRepository<Speaker> repo = new SpeakerRepository();
  var speaker = repo.Create(new Speaker());

  // Act
  var result = repo.Update(speaker);
}

在我们的测试时间表上接下来的是处理由存在测试失败所突出显示的错误。如果有人请求更新一个不存在的演讲者,存储库会崩溃。这可能是一种所需的行为,但它应该通过一个信息性异常崩溃,而不是索引越界异常:

[Fact]
public void ItThrowsNotFoundExceptionWhenSpeakerDoesNotExist()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = new Speaker {Id = 5, Name = "Test Name"};

  // Act
  var result = Record.Exception(() => repo.Update(speaker));

  // Assert
  Assert.IsAssignableFrom<SpeakerNotFoundException>(result.GetBaseException());
}

系统中已经存在一个SpeakerNotFoundException,但它来自不同的层,因此我们需要创建一个新的异常:

public class SpeakerNotFoundException : Exception
{
  public SpeakerNotFoundException(int id) : base($"Speaker {id} not found.")
  {
  }
}

作为一项练习,看看你是否可以用测试来覆盖这个异常。能够在后续章节中讨论的实际情况之后反向工作并编写测试,是一项非常有价值的技能。

现在,让我们继续并使这个测试通过:

public Speaker Update(Speaker speaker)
{
  var oldSpeaker = Speakers.FirstOrDefault(s => s.Id == speaker.Id);
  var index = Speakers.IndexOf(oldSpeaker);

  if (index == -1)
  {
    throw new SpeakerNotFoundException(speaker.Id);
  }

  Speakers[index] = speaker;

  return speaker;
}

就像其他测试一样,我们现在必须确保数据完整性:

[Fact]
public void ItProtectsAgainstObjectChanges()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = repo.Create(new Speaker {Name = "Test Name"});
  speaker.Name = "New Name";
  var updatedSpeaker = repo.Update(speaker);

  // Act
  updatedSpeaker.Name = "Updated Name";

  // Audit
  var result = repo.Get(updatedSpeaker.Id);

  // Assert
  Assert.NotEqual("Updated Name", result.Name);
}

我们正接近测试复杂度过高的边缘。如果这些测试变得更加复杂,我们将想要考虑重新思考我们的方法。这个测试证实了我们的想法:返回的演讲者没有受到更改的保护。让我们修复这个问题:

public Speaker Update(Speaker speaker)
{
  var oldSpeaker = Speakers.FirstOrDefault(s => s.Id == speaker.Id);
  var index = Speakers.IndexOf(oldSpeaker);

  if (index == -1)
  {
    throw new SpeakerNotFoundException(speaker.Id);
  }

  Speakers[index] = speaker;

  return CloneSpeaker(speaker);
}

一个相当简单的解决方案:只需将返回值用CloneSpeaker调用包裹起来。你是否看到了另一个潜在的数据完整性问题?我们正在做些什么来保护传递给演讲者的演讲者免受进一步更改?让我们编写一个测试来确保对传递给演讲者的演讲者所做的任何事后更改都不会影响存储库:

[Fact]
public void ItProtectsAgainstOriginalObjectChanges()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = repo.Create(new Speaker { Name = "Test Name" });
  speaker.Name = "New Name";
  var updatedSpeaker = repo.Update(speaker);

  // Act
  speaker.Name = "Updated Name";

  // Audit
  var result = repo.Get(updatedSpeaker.Id);

  // Assert
  Assert.NotEqual("Updated Name", result.Name);
}

这个测试看起来几乎与上一个测试相同。唯一的重大变化是操作。我们不是在updatedSpeaker上更改值,而是在原始演讲者上更改值。为了使这个测试通过,对存储库的更改与之前的修复类似:

public Speaker Update(Speaker speaker)
{
  var oldSpeaker = Speakers.FirstOrDefault(s => s.Id == speaker.Id);
  var index = Speakers.IndexOf(oldSpeaker);

  if (index == -1)
  {
    throw new SpeakerNotFoundException(speaker.Id);
  }

  Speakers[index] = CloneSpeaker(speaker);

  return CloneSpeaker(speaker);
}

剩下要重构和清理的只有测试和存储库。

删除演讲者

我们终于到达了这个存储库中的最后一个方法。对于演讲者会议,我们并不真的想删除演讲者;我们可能会将演讲者标记为已删除,但我们并不真的想从数据集中删除他们。因此,我们的Delete方法将更像是一个更新。

对于Delete,我们没有任何奇怪的行为或约束,因此我们应该从失败情况开始。如果我们请求删除一个不存在的用户,会发生什么?我们应该抛出异常吗?在这种情况下,我们实际上可以假设工作已经完成。如果没有具有给定 ID 的演讲者,那么该演讲者的删除可以被认为是成功的。

好吧,现在我们已经考虑了失败情况,我们看到它确实有特殊的行为,这会导致失败情况直接通过。让我们看看成功情况,看看它是否会导致测试失败:

[Fact]
public void ItMarksTheGivenSpeakerAsDeleted()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = repo.Create(new Speaker {Name = "Test Name"});

  // Act
  repo.Delete(speaker);

  // Audit
  var result = repo.Get(speaker.Id);

  // Assert
  Assert.True(result.IsDeleted);
}

使这个测试通过应该相当简单:我们只需在将isDeleted标志更改为true后调用本地更新方法:

public void Delete(Speaker speaker)
{
  speaker.IsDeleted = true;

  Update(speaker);
}

不要忘记修复由于添加此代码而失败的任何其他测试,在确认失败是有效原因之后。如果我们编写更多测试时测试失败,我们需要检查需求并确保它们之间没有冲突。我们绝不想无意中破坏现有的需求。

现在,我们必须处理如果演讲者不存在于上下文中的情况。如之前决定的那样,我们对此请求的失败可以忽略,因为删除一个不存在的演讲者会导致与成功删除一个现有演讲者相同的情况:

[Fact]
public void ItDoesNothingWhenDeletingANonexistingSpeaker()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = new Speaker();

  // Act
  var result = Record.Exception(() => repo.Delete(speaker));

  // Assert
  Assert.Null(result);
}

为了通过这个测试,我们必须做一些通常不推荐或不首选的事情。我们必须吞下异常。在采取这样的步骤并忽略异常之前,确保你只忽略特定的异常,并确保你已经彻底思考过:

public void Delete(Speaker speaker)
{
  speaker.IsDeleted = true;

  try
  {
    Update(speaker);
  }
  catch (SpeakerNotFoundException ex)
  {
    // We can assume non-existing speakers are deleted
  }
}

对于这个方法的最后一个测试,我们必须确保我们没有意外地污染传入的对象:

[Fact]
public void ItProtectsAgainstPassedObjectChanges()
{
  // Arrange
  var repo = new SpeakerRepository();
  var speaker = repo.Create(new Speaker {Name = "Test Name"});

  // Act
  repo.Delete(speaker);

  // Assert
  Assert.False(speaker.IsDeleted);
}

我们可以使用我们一直在使用的相同方法来确保数据完整性:

speaker = CloneSpeaker(speaker);
speaker.IsDeleted = true;

泛型化仓库

这个仓库很棒,但我们真的希望为每个需要从数据源检索的数据模型重复这个逻辑和所有这些测试吗?答案是:不;如果你发现自己作为开发者反复做同样的事情,你就是在做错事。

那么,我们如何保护自己免受这种繁琐的困扰?

一种方法是用泛型。让我们重构 SpeakerRepository 以使用泛型,这还将涉及重构许多测试,使它们适用于 GenericRepository 而不是具体的 SpeakerRepository

第一步 – 抽象接口

IRepository 中,我们每处使用 Speaker 都需要用 C# 泛型来替换:

public interface IRepository<T>
{
  T Create(T item);
  T Get(int id);
  IQueryable<T> GetAll();
  T Update(T item);
  void Delete(T item);
}

这个更改将导致 SpeakerRepository 中断,我们需要修复。目前,我们正在追逐编译器,依赖它告诉我们我们打破了什么。一旦测试再次通过,我们就知道我们又回到了正轨:

public class SpeakerRepository : IRepository<Speaker>

现在,我们必须在 SpeakerService 中做一些更改,因为它不再能够引用简单的 IRepository,而是需要一个 IRepository<Speaker>

public class SpeakerService : ISpeakerService
{
  private readonly IRepository<Speaker> _repository;

  public SpeakerService(IRepository<Speaker> repository)
  {
    _repository = repository;
  }
 …

我们必须更新模拟仓库以使用正确的泛型仓库类型。

public class FakeRepository : IRepository<Speaker>

最后,我们有一些需要更新的测试。没有秘密代码;只需将 IRepository 更改为 IRepository<Speaker>

第二步 – 抽象具体类

现在接口已经是泛型了,我们可以开始处理 SpeakerRepository。首先,让我们将其重命名为 InMemorySpeakerRepository。现在,我们想要开始使用泛型。创建一个新的类,InMemoryRepository<T>,并让演讲者仓库继承自它:

public abstract class InMemoryRepository<T> : IRepository<T>
{
  public abstract T Create(T speaker);
  public abstract T Get(int id);
  public abstract IQueryable<T> GetAll();
  public abstract T Update(T speaker);
  public abstract void Delete(T speaker);
}

为了缓慢移动并尽可能多地通过测试,我们使用抽象的,并将不得不让演讲者仓库中的每个方法覆盖基类方法。这使我们能够将每个方法单独移动到抽象中,而不是一次尝试解决整个问题。

从内存仓库继承,并为每个继承的方法添加覆盖:

public class InMemorySpeakerRepository : InMemoryRepository<Speaker>
public overrideSpeaker Create(Speaker speaker)
public override Speaker Get(int id)
public override IQueryable<Speaker> GetAll()
public override Speaker Update(Speaker speaker)
public override void Delete(Speaker speaker)

Create转换为通用方法

我们将开始我们的通用方法之旅,从Create开始。第一步是将Create方法的内容从演讲者仓库复制到通用仓库。为了做到这一点,我们必须将抽象的Create方法更改为虚拟的Create方法:

public virtual T Create(T speaker)
{
  var newSpeaker = CloneSpeaker(speaker);
  newSpeaker.Id = ++CurrentId;
  Speakers.Add(newSpeaker);

  return CloneSpeaker(newSpeaker);
}

立刻,我们遇到了麻烦。我们没有通用的克隆方法。现在,我们先伪造它,直到我们找到真正的解决方案。

在通用仓库中添加以下受保护的抽象方法:

protected abstract T CloneEntity(T entity);

现在,我们可以在从演讲者仓库的Create方法复制的代码中做出类似的变化:

protected readonly IList<T> Entities = new List<T>();
protected int CurrentId;

public virtual T Create(T entity)
{
  var newSpeaker = CloneEntity(entity);
  newSpeaker.Id = ++CurrentId;
  Entities.Add(newSpeaker);

  return CloneEntity(newSpeaker);
}

现在看起来好多了,但我们仍然有一个问题。我们不能期望Id在任意对象上存在。编译器现在非常生气。有几个解决方案,但我们将创建一个简单的数据模型接口,并在T上放置一个约束,它必须继承该接口。接口中唯一的东西将是一个整型Id属性:

public interface IIdentity
{
  int Id { get; set; }
}

public abstract class InMemoryRepository<T> : IRepository<T> where T: IIdentity

这导致演讲者仓库中断。我们还必须让Speaker继承自IIdentity。现在,我们已经将Create方法中的所有逻辑移动到了通用仓库。删除演讲者仓库中的Create方法。

由于我们需要将演讲者仓库中的其他方法重新指向通用仓库中的支持对象,因此许多测试失败了。相反,遍历演讲者仓库并更新所有对Speakers的引用到Entities

我们还需要调整TestableSpeakerRepository类:

internal class TestableSpeakerRepository : InMemorySpeakerRepository
{
  public IQueryable<Speaker> SpeakersCollection => Entities;
}

Get转换为通用方法

接下来是列表中的Get方法。就像Create方法一样,将所有内容复制到通用仓库,并修复出现的任何错误:

public virtual T Get(int id)
{
  var entity = Entities.SingleOrDefault(e => e.Id == id);

  if (entity != null)
  {
    entity = CloneEntity(entity);
  }

  return entity;
}

这个方法证明是很容易复制的。现在,删除演讲者仓库中现有的方法。这次没有测试失败,所以我们可以继续到下一个方法。

GetAll转换为通用方法

GetAll是转换起来最简单的方法。它甚至没有以文本形式引用演讲者:

public virtual IQueryable<T> GetAll()
{
  return Entities.Select(CloneEntity).AsQueryable();
}

删除演讲者仓库中现有的方法,继续到下一个方法,更新。

Update转换为通用方法

更新过程与其他方法相同。复制现有代码的主体,将任何演讲者引用重命名为实体引用,然后删除现有方法:

public virtual T Update(T entity)
{
  var oldEntity = Entities.FirstOrDefault(s => s.Id == entity.Id);
  var index = Entities.IndexOf(oldEntity);

  if (index == -1)
  {
    throw new EntityNotFoundException(entity.Id);
  }

  Entities[index] = CloneEntity(entity);

  return CloneEntity(entity);
}

Delete转换为通用方法

Delete 是一个不同的情况:每个对象在删除时可能都有不同的要求。一些实际上会被删除,而其他,如 Speaker,则仅仅会被标记为已删除。出于这个原因以及许多其他原因,我们选择将 Delete 的实现留给具体的仓储,而通用仓储将抛出一个未实现异常。

让我们为这个功能编写一个 Delete 测试类。它应该是简短且快速的:

public class Delete
{
  [Fact]
  public void ItThrowsNotImplementException()
  {
    // Arrange
    var repo = new InMemoryRepository<TestEntity>();

    // Act
    var result = Record.Exception(() => repo.Delete(new TestEntity()));

    // Assert
    Assert.IsAssignableFrom<NotImplementedException> 
    (result.GetBaseException());
     Assert.Equal("Delete is not avaliable for TestEntity", 
     result.Message);
  } 
}
public class TestEntity : IIdentity
{
  public int Id { get; set; }
}

编写这个测试也让我们将通用仓储从抽象类更改为普通类。更改类类型使我们不得不将 CloneEntity 方法从抽象更改为虚拟:

protected virtual T CloneEntity(T entity)
{
  return entity;
}

现在,我们可以写出通过测试的方法:

public virtual void Delete(T speaker)
{
  throw new NotImplementedException($"Delete is not avaliable for {typeof(T).Name}");
}

第三步 – 将测试重新定向以使用通用仓储

我们在处理 Delete 方法时开始了这个过程。但让我们继续处理其他方法。所有移动到通用仓储的方法都可以将大部分测试一起移动。

我们将放弃数据完整性测试,因为这些测试直接与演讲者仓储中的功能相关。出于相同的原因,我们将保留所有删除测试。

InMemoryRepository 创建测试

为了实现 InMemoryRepository 的其余功能,我们将从 Create 方法的测试开始:

public class Create
{
  private readonly TestableEntityRepository _repo;

  public Create()
  {
    _repo = new TestableEntityRepository();
  }

  [Fact]
  public void ItExists()
  {
    // Act
    var result = _repo.Create(new TestEntity());
  }

  [Fact]
  public void ItAddsAEntityToTheRepository()
  {
    // Act
    var result = _repo.Create(new TestEntity());

    // Assert
    Assert.Equal(1, _repo.EntityCollection.Count());
  }

  [Fact]
  public void ItAssignsUniqueIdsToEachEntity()
  {
    // Act
    var entity1 = _repo.Create(new TestEntity());
    var entity2 = _repo.Create(new TestEntity());

    // Assert
    Assert.NotEqual(entity1.Id, entity2.Id);
  }
}
internal class TestableEntityRepository : InMemoryRepository<TestEntity>
{
  public IQueryable<TestEntity> EntityCollection => Entities;
}

InMemoryRepository 获取测试

现在我们可以使用 InMemoryRepository 创建,我们应该能够准确测试 Get 方法:

public class Get
{
  private readonly InMemoryRepository<TestEntity> _repo;

  public Get()
  {
    _repo = new InMemoryRepository<TestEntity>();
  }

  [Fact]
  public void ItExists()
  {
    // Act
    var result = _repo.Get(0);
  }

  [Fact]
  public void ItReturnsAnEntityWhenFound()
  {
    // Arrange
    var entity = _repo.Create(new TestEntity() { Name = "Test Entity" });

    // Act
    var result = _repo.Get(entity.Id);

    // Assert
    Assert.NotNull(result);
    Assert.Equal("Test Entity", result.Name);
  }

  [Fact]
  public void ItReturnsNullWhenNotFound()
  {
    // Act
    var result = _repo.Get(-1);

    // Assert
    Assert.Null(result);
  }
}

InMemoryRepository 获取所有测试

在单个对象获取工作后,让我们测试获取所有对象:

public class GetAll
{
  private readonly InMemoryRepository<TestEntity> _repo;

  public GetAll()
  {
    _repo = new InMemoryRepository<TestEntity>();
  }

  [Fact]
  public void ItExists()
  {
    // Act
    var result = _repo.GetAll();
  }

  [Fact]
  public void ItReturnsNoEntitiesWhenThereAreNoEntities()
  {
    // Act
    var result = _repo.GetAll();

    // Assert
    Assert.NotNull(result);
    Assert.IsAssignableFrom<IQueryable<TestEntity>>(result);
    Assert.Equal(0, result.Count());
  }

  [Fact]
  public void ItReturnsASingleEntityWhenOnlyOneEntityExists()
  {
    // Arrange
    _repo.Create(new TestEntity { Name = "Test Entity" });

    // Act
    var result = _repo.GetAll().ToList();

    // Assert
    Assert.Equal(1, result.Count);
    Assert.Equal("Test Entity", result.First().Name);
  }

  [Fact]
  public void ItReturnsManyEntitiesWhenManyEntitiesExist()
  {
    // Arrange
    _repo.Create(new TestEntity());
    _repo.Create(new TestEntity());
    _repo.Create(new TestEntity());

    // Act
    var result = _repo.GetAll().ToList();

    // Assert
    Assert.Equal(3, result.Count);
  }
}

InMemoryRepository 更新测试

最后但同样重要的是,我们将添加使用 InMemoryRepository 更新记录的能力:

public class Update
{
  private readonly InMemoryRepository<TestEntity> _repo;

  public Update()
  {
    _repo = new InMemoryRepository<TestEntity>();
  }

  [Fact]
  public void ItExists()
  {
    // Arrange
    var entity = _repo.Create(new TestEntity());
   // Act
    var result = _repo.Update(entity);
  }

  [Fact]
  public void ItUpdatesAnEntity()
  {
    // Arrange
    var entity = _repo.Create(new TestEntity(){ Name = "Test Name" });
    entity.Name = "New Name";
    // Act
    var result = _repo.Update(entity);
   // Assert
    Assert.Equal(entity.Name, result.Name);
  }

  [Fact]
  public void ItUpdatesAnEntityInTheRepository()
  {
    // Arrange
    var entity = _repo.Create(new TestEntity() { Name = "Test Name" });
    entity.Name = "New Name";
   // Act
    var updatedEntity = _repo.Update(entity);
   // Audit
    var result = _repo.Get(entity.Id);
   // Assert
    Assert.Equal("New Name", result.Name);
  }

  [Fact]
  public void ItThrowsNotFoundExceptionWhenEntityDoesNotExist()
  {
    // Arrange
    var entity = new TestEntity { Id = 5, Name = "Test Name" };
    // Act
    var result = Record.Exception(() => _repo.Update(entity));
    // Assert
    Assert.IsAssignableFrom<EntityNotFoundException>(result.GetBaseException());
  }
}

现在我们有一个可以与任何具有 ID 的数据模型一起使用的通用仓储。如果我们需要确保数据完整性,我们还可以通过继承并创建一个克隆所需保护的数据对象的方法来实现。

实体框架

对象关系映射ORM)框架,如 Entity Framework,有助于提高生产力和优化代码重用及可维护性。然而,您不应将应用程序紧密耦合到 ORM。像对待任何第三方库或系统一样,抽象化 ORM,如 Entity Framework。

Speaker Meet 通过通用仓储使用 Entity Framework;为了开始,添加对 Entity Framework – Sql Server 的 NuGet 引用。

Microsoft.EntityFrameworkCore.SqlServer

接下来,将连接字符串添加到 appsettings.json

 "ConnectionStrings": {"DefaultConnection":   
 "Server=.;Database=SpeakerMeetBook;Trusted_Connection=True;MultipleAct
  iveResultSets=true"}

DbContext

最新版本的 Entity Framework,EF Core 2.0,已添加 DbContext 缓存,这有助于通过节省每次请求初始化新的 DbContext 实例的一些成本来提高性能。

修改 Startup.cs 中的 ConfigureServices 以引用连接字符串:

 var connectionString =   
  Configuration.GetConnectionString("DefaultConnection");
 services.AddDbContextPool<SpeakerMeetContext>(options =>   
  options.UseSqlServer(connectionString));

将一个 DbContext 添加到您的应用程序中。创建一个名为 SpeakerMeetContext 的新文件,并使用以下选项:

using Microsoft.EntityFrameworkCore;

namespace SpeakerMeet.Api.Entities
{
  public class SpeakerMeetContext : DbContext
  {
    public SpeakerMeetContext(DbContextOptions<SpeakerMeetContext> options) : base(options)
    { }

    public virtual DbSet<Speaker> Speakers { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
      modelBuilder.Entity<Speaker>().ToTable("Speaker");
    }
  }
}

模型

Entity Framework 模型可能包含您可能不希望暴露给系统其他部分或应用程序消费者的额外信息。创建一个名为Speaker的新模型。这将是由 Entity Framework 和通用仓库使用的模型。服务或业务层将负责将 Entity Framework 模型转换为应用程序其他部分使用的数据传输对象DTOs)或 ViewModels:

using System.ComponentModel.DataAnnotations;

namespace SpeakerMeet.Api.Entities
{
  public class Speaker
  {
    public int Id { get; set; }

    [Required]
    [StringLength(50)]
    public string Name { get; set; }

    [Required]
    [StringLength(50)]
    public string Location { get; set; }

    [Required]
    [StringLength(255)]
    public string EmailAddress { get; set; }

    public bool IsDeleted { get; set; }
  }
}

通用仓库

为了看到 Entity Framework 特定的通用仓库运行,您可能需要将以下类添加到您的应用程序中:

using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;

namespace SpeakerMeet.Api.Repository
{
  public class Repository<T> : IRepository<T> where T : class
  {
    private readonly DbSet<T> _dbSet;
    protected readonly DbContext Context;

    public Repository(DbContext context)
    {
      Context = context;
      _dbSet = context.Set<T>();
    }

    public T Create(T entity)
    {
      throw new NotImplementedException();
    }

    public T Get(int id)
    {
      return _dbSet.Find(id);
    }

    public IQueryable<T> GetAll()
    {
      return _dbSet;
    }

    public T Update(T speaker)
    {
      throw new NotImplementedException();
    }

    public void Delete(T entity)
    {
      throw new NotImplementedException();
    }
  }
}

依赖注入

为了将所有组件连接起来,Speaker Meet 应用程序利用了内置的 依赖注入DI)容器。依赖注入允许系统实现松耦合。有一些方法可以在不使用容器的情况下实现这一点(例如“穷人版 DI”等),这些方法将在后面的章节中介绍。测试本身并不依赖于 DI,而是选择按需实例化类。

连接所有组件

为了配置 DI 容器,请将以下内容添加到Startup.cs中的ConfigureServices

services.AddSingleton(typeof(DbContext), typeof(SpeakerMeetContext));
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddTransient<ISpeakerService, SpeakerService>();
services.AddTransient<IGravatarService, GravatarService>();

这样可以避免在SpeakerController内部实例化SpeakerService的需要。DI 容器会为您处理这个问题。

如果您已经创建了数据库并填充了演讲者表,现在您应该能够运行应用程序并访问SpeakerController端点。试一试吧!

  • http://localhost:41436/api/speaker/

  • http://localhost:41436/api/speaker/1

  • http://localhost:41436/api/speaker/search?searchString=test

Postman

有许多工具可用于手动测试 API。Postman 只是其中之一,并且在业界很受欢迎。Postman 提供了许多功能来帮助您进行 API 开发和测试。如果您对此感兴趣,值得一试。

要安装 Postman,只需访问网站(www.getpostman.com)并按照说明操作。当 Speaker Meet 应用程序运行时,将 URL(例如:http://localhost:41436/api/speaker/search)输入框中,添加参数(例如:[{"key":"searchString","value":"te","description":""}]),然后点击发送。默认情况下,响应显示在消息体的 JSON 中。

这可以是一个非常强大的工具。随着 Speaker Meet API 的复杂性和功能集的增长,可以使用POSTPUTPATCHDELETE发送更复杂的消息。这些将在后面的章节中介绍。

摘要

本章主要讲述了抽象:何时以及如何使用它。你现在应该明白为什么在您自己编写的代码和第三方代码之间创建抽象如此重要。你也应该对如何实现这些抽象有一个很好的了解。

在本章中,我们抽象了一个 Gravatar 服务,扩展了仓库模式,并使用通用仓库为 Entity Framework。

在第九章,测试 JavaScript 应用程序,我们将专注于测试 JavaScript 应用程序。我们将逐步创建一个 React 应用程序,并讨论测试 JavaScript 应用程序的不同方法。

第九章:测试 JavaScript 应用

要开始使用 JavaScript 进行测试,我们需要创建一个 ReactJS 应用,并使用 Mocha、Chai、Enzyme 和 Sinon 库来配置它进行测试。

这些步骤在第三章中已详细讨论,“设置 JavaScript 环境”,因此在这里,我们只需概述这些步骤,而不进行详细解释。

本章的目标是:

  • 创建演讲者遇见 React 应用

  • 讨论我们测试应用的行动计划:

    • 我们的方法是什么?

    • 我们甚至可以测试应用的部分是什么?

    • 我们从应用的哪个部分开始?

  • 为应用编写测试并完成几个功能:

    • 演讲者列表

    • 演讲者详情

一旦这一章完成,你应该能够对任何基于 React 的应用进行单元测试。

创建 React 应用

对于本书中的应用,为了保持兼容性,您希望使用 Node.js 版本 8.5.0、NPM 版本 5.4.2 和 create-react-app 版本 1.4.0。

执行以下命令来安装和执行应用:

>npm install
>npm test
>npm start

所有三个命令都应该成功运行。运行npm test后,您需要通过按<q>退出测试运行。运行npm start后,您需要通过按Ctrl + C退出服务器。

推出应用

假设前面的步骤没有遇到任何问题,我们可以继续执行将 React 应用“推出”的操作。同样,正如在第三章中详细解释的,“设置 JavaScript 环境”,我们在这里只做简要回顾。

推出应用只有一个命令。推出后,我们将重新运行上一节中的命令,以确保应用仍然按预期工作。

执行以下命令来推出:

>npm run eject

配置 Mocha、Chai、Enzyme 和 Sinon

现在,我们准备添加我们希望用于此应用的测试设施。与之前一样,这些实用工具的添加已在之前的章节中详细说明。因此,我们只提供执行命令和要安装的包的版本。

执行以下命令来安装我们将要使用的库:

>npm install mocha@3.5.3
>npm install chai@4.1.2
>npm install enzyme@2.9.1
>npm install sinon@3.2.1

我们还将使用一些其他库作为我们 Redux 工作流程的一部分:

>npm install nock@9.0.1
>npm install react-router-dom@4.2.2
>npm install redux@3.7.2
>npm install redux-mock-store@1.3.0
>npm install redux-thunk@2.2.0

在安装命令中包含版本将确保您使用与我们相同的库版本,并将减少潜在问题的数量。

要使用我们刚刚安装的库,我们还需要安装一个额外的 babel 预设:

>npm install babel-preset-es2015@6.24.1

package.json中更新您的 babel 配置,以删除 react-app 并包括reactes2015

"babel": {
   "presets": [
     "react",
     "es2015"
   ]
 },

如第三章中所述,“设置 JavaScript 环境”,从package.json中删除测试配置部分。然后,更新测试脚本为:

"test": "mocha --require ./scripts/test.js --compilers babel-core/register ./src/**/*.spec.js"

并添加一个测试监视脚本:

"test:watch": "npm test -- -w"

我们现在可以更新脚本文件夹中的测试执行文件test.js,使其与 Mocha 兼容。将文件中的所有内容更改为:

'use strict'; 

import jsdom from 'jsdom'; 
global.document = jsdom.jsdom('<html><body></body></html>'); 
global.window = document.defaultView; 
global.navigator = window.navigator; 

function noop() { 
  return {}; 
}

// prevent mocha tests from breaking when trying to require a css file 
require.extensions['.css'] = noop; 
require.extensions['.svg'] = noop;

在我们能够使用新的测试库之前,最后一步是更新App.test.js文件以匹配与 Mocha 和 Chai 一起使用的约定。因此,将文件名更改为App.spec.js,并将内容更新为如下所示:

import React from 'react'; 
import ReactDOM from 'react-dom'; 
import { expect } from 'chai'; 

import App from './App'; 

describe('(Component) App', () => { 
  it('renders without crashing', () => { 
        const div = document.createElement('div'); 
        ReactDOM.render(<App />, div); 
    }); 
}); 

现在,就像之前一样,执行测试脚本并启动应用程序,以确保在转换到 Mocha 的过程中没有出现任何问题。

>npm test
>npm run test:watch
>npm start

这三个命令都应该能正常工作。如果你遇到问题,检查我们刚刚讨论的所有步骤,并查看第三章,设置 JavaScript 环境,以获取更详细的解释。

计划

现在我们测试配置已经更新并且运行正确,我们可以开始考虑测试驱动我们的第一个功能。

在前面的章节中,我们讨论了从哪里开始测试,并决定如果可能的话,一个从内到外的方法更受欢迎。为了保持这种方法,我们想要确定我们 React 应用程序的不同部分,这样我们就可以针对最纯粹的业务逻辑。

首先,无论其他架构选择如何,我们都可以识别出 React 组件和代表与我们的数据源通信的服务。我们计划在这个应用程序中使用 Redux,这使得它成为缺失的部分,并将我们的组件与我们的数据连接起来。

那么这些中的哪一个代表业务逻辑呢?从这些基本选项中,我们会测试什么呢?让我们更仔细地检查每一个,看看我们能测试什么,这将被视为单元测试。

考虑 React 组件

通常,我们想要避免对第三方库进行单元测试。因此,让我们将 React 组件的第三方方面与我们可能进行单元测试的部分分开。

第三方方面包括任何继承的功能和特性;这包括一定程度上的任何生命周期方法和 JSX。那么,剩下什么呢?这个问题的答案取决于所讨论的组件是呈现组件还是容器组件。

呈现组件几乎是纯 HTML 和视图机制。几乎没有传统上可进行单元测试的行为。当然,没有真正的业务逻辑。

容器组件是 React 应用程序中真正发生动作的地方。这些组件可以操作数据并做出业务决策,这些决策可以控制应用程序的流程。因此,让我们将容器组件保留在可能的单元测试起点列表中。

查看 Redux 的可测试性

Redux 是一个第三方库,它控制着整个应用中的数据流,并管理了我们可能想要进行单元测试的大量正常数据交换。因为它是第三方库,所以表面上似乎没有太多可以单元测试的。让我们更仔细地看看 Redux 数据流的各个方面,以确定是否真的没有什么可以测试的,或者我们是否仍然需要单元测试 Redux 的部分。

Store

Redux store 是应用获取所有数据后数据所在的地方。通常,每个使用 Redux 的应用只有一个 store。store 几乎完全包含在 Redux 库中,我们与它的直接交互非常少。因此,似乎没有太多我们可以或必须为 store 进行测试的,它完全属于我们必须简单地信任的第三方代码领域。

Actions

Redux 中的 Actions 代表携带数据包的事件。这个事件通常是一个命令,用于从数据源中检索或更新数据,这些数据应该由 store 反映出来。因为 actions 只是一个带有一些数据的键,所以在这里似乎没有太多可以测试的。

Reducers

如果在 Redux 交互中有什么可以测试的,那很可能是在 reducers 中。Reducers 接收 actions,并根据请求的 actions 和作为这些 actions 一部分提供的数据来确定要做什么,如果需要的话。

通常,一旦确定了适当的 service 调用,reducer 将简单地调用 API 服务。有可能 reducer 也可能将接收到的数据映射到更适合必须进行的 service 调用的格式。

那么,如果实际上 reducer 只是将要调用服务,我们应该为 reducer 测试什么?除了确保使用适当的数据调用适当的 service 方法之外,似乎没有太多可以测试的。为了完整性,我们可能想要测试这些事情,但它们并不代表我们的业务逻辑的核心。

总之,Redux 中似乎没有太多可以测试的内容,而且可以测试的内容并不代表我们的业务逻辑的核心。

单元测试 API 服务

最后,让我们看看 API 服务。通常,前端应用中的服务表现得就像后端应用中的仓库一样。服务的主要功能是抽象与某些数据源的数据交互。这些交互不一定包含任何可定义的业务逻辑。如果有的话,服务的真实逻辑存在于服务器上,并且不需要作为前端应用的一部分进行测试。至少,它不需要以你可能会认为的方式进行测试。

那么,如果服务不包含任何业务逻辑,Redux 也不包含太多业务逻辑,组件也不包含太多业务逻辑,我们应该测试什么,以及如何进行单元测试呢?

简短的回答是,我们还没有摆脱测试的责任,但我们必须跳过一些障碍才能进行任何测试,因为从集成测试中抽身是很困难的。在典型的前端应用程序中,与 C#不同,我们的代码和他们的代码之间没有明确的界限。因此,我们将不得不做出一些妥协,并编写大量的代码来抽象第三方代码的部分,以便我们可以测试我们需要测试的内容。

那么,当我们谈论测试方向时,这又把我们带向何方呢?遗憾的是,似乎没有明确的胜者。为了这个应用程序的目的,我们将从数据源开始工作,这样在我们编写应用程序的用户界面方面时,我们可以清楚地了解我们可用的数据操作。

演讲者列表

按照我们的 C#后端的功能,我们首先将测试可用的演讲者列表。我们还没有准备好连接到后端,并且对于我们将要在这里编写的任何测试(作为单元测试),我们需要模拟后端通常会呈现的行为。

目前,我们不会关心任何类型的身份验证。因此,我们将要实现的重要功能是,当没有演讲者存在时,我们应该让用户知道,当演讲者存在时,我们应该列出他们。

我们将产生这两种情况的方式是通过模拟 API。虽然这听起来可能很奇怪,但我们的大部分业务逻辑将位于模拟 API 中。因为它对于我们将要编写的所有其他测试都至关重要,我们必须像对待生产代码一样对模拟 API 进行单元测试。

模拟 API 服务

为了开始测试模拟 API 服务,让我们创建一个新的服务文件夹,并添加一个mockSpeakerService.spec.js文件。

在该文件中,我们需要导入我们的断言库,创建我们的初始 describe,并编写一个存在性测试。

import { expect } from 'chai';

describe('Mock Speaker Service', () => {
  it('exits', () => {
    expect(MockSpeakerService).to.exist;
  });
});

使用带有 watch 的 npm 测试脚本开始。我们刚刚编写的测试应该失败。为了使测试通过,我们必须创建一个MockSpeakerService对象。让我们扮演一下魔鬼的代言人,在这个文件中创建一个对象,但只创建足够使测试通过的对象。

let MockSpeakerService = {};

这行代码通过了目前失败的测试,但显然不是我们想要的。然而,它确实迫使我们编写更健壮的测试。我们可以编写的下一个测试是证明MockSpeakerService可以被构造。这个测试应该确保我们已经将MockSpeakerService定义为一个类。

it('can be constructed', () => {
  // arrange
  let service = new MockSpeakerService();

  // assert
  expect(service).to.be.an.instanceof(MockSpeakerService);
});

这个测试失败了,显示MockSpeakerService不是一个构造函数。解决这个问题的方式是将MockSpeakerService改为一个类。

class MockSpeakerService {
}

现在我们有一个可以实例化的类,接下来我们编写的测试可以开始测试实际的功能。那么,我们将测试哪些功能呢?查看需求,我们可以看到第一个场景涉及请求所有演讲者并接收没有演讲者。这是一个相对简单的测试场景。我们在MockSpeakerService中会称什么函数为获取所有演讲者的函数呢?因为我们试图获取所有演讲者,一个简单且不重复且符合我们在 C#后端讨论的存储库模式的名称是简单地getAll。让我们创建一个嵌套的 describe 和一个getAll类方法的存活性测试。

describe('Get All', () => {
  it('exists', () => {
    // arrange
    let service = new MockSpeakerService();

    // assert
    expect(service.getAll).to.exist;
  });
});

如同往常,这个测试应该失败,并且应该以expected undefined to exist失败。让这个测试通过相对简单,只需在MockSpeakerService类中添加一个getAll方法。

class MockSpeakerService {
  getAll() {
  }
}

下一步我们需要决定的是当没有演讲者时我们应该期望什么结果。回顾后端,当没有演讲者时,我们应该收到一个空数组。查看需求,系统应该显示NO_SPEAKERS_AVAILABLE消息。服务应该负责显示这个消息吗?在这种情况下,答案是不了。当到达代码的这一部分时,react 组件应该负责显示NO_SPEAKERS_AVAILABLE消息。现在,我们应该期望,当没有演讲者存在时,接收一个空的数据集。

因为我们在扩展测试上下文,让我们为这个上下文扩展创建另一个 describe。

describe('No Speakers Exist', () => {
  it('returns an empty array', () => {
    // arrange
    let service = new MockSpeakerService();

    // act
    let promise = service.getAll();

    // assert
    return promise.then((result) => {
      expect(result).to.have.lengthOf(0);
    });      
  });
});

注意我们在这个测试中使用的语法。我们在then函数中返回承诺并做出断言。这是因为我们希望我们的测试在服务中的异步代码上操作。大多数后端操作将需要异步执行,处理这种异步性的一个惯例是使用承诺。在 Mocha 中,异步测试,即处理承诺的测试,要求承诺必须从测试中返回,这样 Mocha 就可以知道在关闭测试之前等待承诺解析。

现在为了让测试通过,我们只需要从getAll方法返回一个解析为空数组的承诺。在这里我们将使用一个零延迟的setTimeout,这样我们就可以为开发目的稍后实现某种延迟。我们想要延迟的原因是,这样我们就可以测试在慢速网络响应事件中 UI 的操作。

getAll() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(Object.assign([], this._speakers));
    }, 0);
  });
}

现在我们已经通过了第一个场景,并且有足够的代码可以进行重构。我们在多个地方声明了服务变量,并且我们没有表示该变量基线实例化的上下文。让我们创建一个 describe 来包裹所有后实例化测试,并添加一个beforeEach来初始化一个作用域在 describe 内的服务变量,并且使其对所有该 describe 内的测试可用。

这里是重构后的测试:

describe('Mock Speaker Service', () => {
   it('exits', () => {
     expect(MockSpeakerService).to.exist;
   });

   it('can be constructed', () => {
     // arrange
     let service = new MockSpeakerService();

     // assert
     expect(service).to.be.an.instanceof(MockSpeakerService);
   });

   describe('After Initialization', () => {
     let service = null;

     beforeEach(() => {
       service = new MockSpeakerService();
     });

     describe('Get All', () => {
       it('exists', () => {
         // assert
         expect(service.getAll).to.exist;
       });

       describe('No Speakers Exist', () => {
         it('returns an empty array', () => {
           // act
           let promise = service.getAll();

           // assert
           return promise.then((result) => {
             expect(result).to.have.lengthOf(0);
           });
         });
       });
     });
   });
 });

下一个场景,演讲者列表,是在演讲者存在时的情况。这个场景的第一个测试需要至少向模拟 API 添加一个演讲者。让我们在 GetAll 内创建一个新的 describe,但与 No Speakers Exist 分开。

describe('Speaker Listing', () => {
   it('returns speakers', () => {
     // arrange
     service.create({});

     // act
     let promise = service.getAll();

     // assert
     return promise.then((result) => {
       expect(result).to.have.lengthOf(1);
     });
   });
 });

作为这个测试设置的一部分,我们已经添加了对一个 Create 方法的引用。这个方法目前还不存在,我们的测试没有它无法通过。因此,我们需要暂时忽略这个测试,并编写 Create 的测试。我们可以通过跳过它来忽略这个测试。

it.skip('returns speakers', () => {

现在,我们可以在 After Initialization 块内为 Create 编写一个新的 describe 块。

describe('Create', () => {
   it('exists', () => {
     expect(service.create).to.exist;
   });
 });

为了使测试通过,我们向模拟服务类中添加了 Create 方法。

class MockSpeakerService {
   create() {

   }
 …

从这个点开始,我们可以编写一些测试来向 Create 方法添加验证逻辑。然而,我们目前没有任何引用 API 上 Create 方法的场景。由于这个方法仅用于测试目的,我们将让它保持原样,只进行存在性测试。让我们回到我们的场景测试。

现在 Create 存在了,我们应该收到测试预期的失败,即我们期望长度为 1,但实际长度为 0。从测试中移除 skip 并验证。

为了使这个测试通过,我们实际上必须实现创建的基本逻辑并对 getAll 进行修改。

class MockSpeakerService {
   constructor() {
     this._speakers = [];
   }

   create(speaker) {
     this._speakers.push(speaker);
   }

   getAll() {
     return new Promise((resolve, reject) => {
       setTimeout(() => {
         resolve(Object.assign([], this._speakers));
       }, 0);
     });
   }
 }

我们可以认为当前的测试足够了,可以开始测试我们的数据流。

获取所有演讲者的动作

要开始使用 Redux 进行测试,我们可以从几个测试入口点开始。我们可以从测试动作、reducer 或甚至与存储的交互开始。存储测试将更多是集成测试,而我们希望在这个章节中专注于单元测试。这留下了动作和 reducer。两者都是良好的起点,但我们将从动作开始,因为它们在测试概念上非常简单且不复杂。

我们现在需要的动作是请求检索演讲者信息的一个动作;本质上,就是一个获取所有演讲者的动作。如前所述,动作可以非常简单;然而,我们有一个问题,那就是我们的获取所有演讲者的服务调用是异步的。动作并不是真正设计来处理异步调用的。因此,让我们从一个稍微简单一点的东西开始,等我们了解了如何测试正常动作后,再回来解决这个问题。

测试标准动作

我们需要在演讲者加载完毕后通知 Redux 我们已经获取了演讲者。我们没有理由不能从这里开始。所以,让我们编写一个测试来验证成功检索演讲者。

import { expect } from 'chai';

 describe('Speaker Actions', () => {
   describe('Sync Actions', () => {
     describe('Get Speakers Success', () => {
       it('exists', () => {
         expect(getSpeakersSuccess).to.exist;
       });
     });
   });
 });

运行这个测试应该会失败。为了使测试通过,定义一个名为 getSpeakersSuccess 的函数。

function getSpeakersSuccess() {
 }

由于典型动作的简单性,我们的下一个测试实际上将测试动作的功能。我们可以将其分解成多个测试,但我们实际上只是在断言返回数据的结构。关于单一断言规则,我们仍然只断言了一件事情。

it('is created with correct data', () => {
   // arrange
   const speakers = [{
     id: 'test-speaker',
     firstName: 'Test',
     lastName: 'Speaker'
   }];

   // act
   const result = getSpeakersSuccess(speakers);

   // assert
   expect(result.type).to.equal(GET_SPEAKERS_SUCCESS);
   expect(result.speakers).to.have.lengthOf(1);
   expect(result.speakers).to.deep.equal(speakers);
 });

为了使这个测试通过,我们需要对我们当前实现的 getSpeakersSuccess 函数进行重大修改。

const GET_SPEAKERS_SUCCESS = 'GET_SPEAKERS_SUCCESS';

 function getSpeakersSuccess(speakers) {
   return { type: GET_SPEAKERS_SUCCESS, speakers: speakers };
 }

在 Redux 中,动作有一个预期的格式。它们必须包含一个类型属性,通常包含某些数据结构。在 getSpeakersSuccess 的情况下,我们的类型是一个常量,GET_SPEAKERS_SUCCESS,数据是传递给动作的演讲者数组。为了让它们对应用程序可用,让我们将动作和常量移动到它们自己的文件中。我们需要一个 speakerActions 文件和一个 actionTypes 文件,

src/actions/speakerActions.js:

import * as types from '../reducers/actionTypes';

 export function getSpeakersSuccess(speakers) {
   return { type: types.GET_SPEAKERS_SUCCESS, speakers: speakers };
 }

src/reducers/actionTypes.js:

export const GET_SPEAKERS_SUCCESS = 'GET_SPEAKERS_SUCCESS';

在测试中添加导入语句,并且所有测试都应该通过。对于一个典型的动作,这是测试的格式。在 reducers 文件夹中放置动作类型是为了依赖反转的原因。从 SOLID 的角度来看,reducers 定义了一个交互合同,这由动作类型表示。动作是履行这个合同的。

测试 thunk

由于 getSpeakersSuccess 动作旨在表示成功服务调用的结果,我们需要一种特殊类型的动作来表示服务调用本身。如前所述,Redux 并不固有地支持异步动作。因此,我们需要其他方式与后端进行通信。幸运的是,Redux 支持中间件,许多中间件已被设计为向 Redux 添加异步能力。我们将为了简单起见使用 redux-thunk

要开始下一个测试,我们首先需要将 redux-thunkredux-mock-store 导入到我们的演讲者动作测试中。

import thunk from 'redux-thunk';
 import configureMockStore from 'redux-mock-store';

然后我们可以测试获取演讲者。

describe('Async Actions', () => {
   describe('Get Speakers', () => {
     it('exists', () => {
       expect(speakerActions.getSpeakers).to.exist;
     });
   });
 });

如同往常,我们从存在性测试开始。而且,如同往常,使这个测试通过相当容易。在演讲者动作文件中,添加 getSpeakers 函数的定义并将其导出。

export function getSpeakers() {
 }

下一个测试比我们之前工作的测试稍微复杂一些,所以我们将更详细地解释它。

我们首先需要做的是配置一个模拟存储库并添加 thunk 中间件。我们需要这样做,因为为了正确测试 thunk,我们必须假装 Redux 正在运行,这样我们才能派发我们的新动作并检索结果。所以,让我们将我们的模拟存储库配置添加到 Async Actionsdescribe 中:

const middleware = [thunk];
 let mockStore;

 beforeEach(() => {
   mockStore = configureMockStore(middleware);
 });

现在我们有了可用的存储库,我们就可以开始编写测试了。

it('creates GET_SPEAKERS_SUCCESS when loading speakers', () => {
  // arrange
  const speaker = {
    id: 'test-speaker',
    firstName: 'Test',
    lastName: 'Speaker'
  };

  const expectedActions = speakerActions.getSpeakersSuccess([speaker]);
  const store = mockStore({
    speakers: []
  });

在安排阶段,我们配置了一个最基础的演讲者。然后,我们调用之前测试过的动作来构建合适的数据结构。最后,我们定义了一个模拟存储库及其初始状态。

  // act
   return store.dispatch(speakerActions.getSpeakers()).then(() => {
     const actions = store.getActions();

     // assert
     expect(actions[0].type).to.equal(types.GET_SPEAKERS_SUCCESS);
   });
 });

现在,当在 Mocha 中进行异步测试时,我们可以返回一个 promise,Mocha 将自动知道这个测试是异步的。对于异步测试,我们的断言放在 promise 的 resolve 或 reject 函数中。在获取演讲者动作的情况下,我们将假设服务器交互成功,并测试解析的 promise。

因为我们从getSpeakers动作中没有返回任何内容,mockStore抛出一个错误,指出动作可能不是 undefined。为了使测试继续进行,我们必须返回一些内容。为了朝着使用thunk的方向前进,我们需要返回一个函数。

export function getSpeakers() {
   return function(dispatch) {
   };
 };

添加一个什么也不做的函数的返回值将测试失败信息向前推进,现在呈现的是无法读取 undefined 的then属性。所以,现在我们需要从我们的动作中返回一个 promise。我们已经在模拟 API 服务中构建了服务端点,所以现在让我们调用它。

export function getSpeakers() {
   return function(dispatch) {
     return new MockSpeakerService().getAll().then(speakers => {
       dispatch(getSpeakersSuccess(speakers))
     }).catch(err => {
       throw(err);
     });
   };
 }

现在测试通过了,我们已经编写了第一个处理 thunks 的测试。如您所见,测试和通过测试的代码都相当容易编写。

获取所有演讲者的 reducer

现在我们已经测试了与获取所有演讲者相关的动作,是时候转向测试 reducers 了。像往常一样,让我们从一个存在性测试开始。

describe('Speaker Reducers', () => {
   describe('Speakers Reducer', () => {
     it('exists', () => {
       expect(speakersReducer).to.exist;
     });
   });
 });

为了使这个测试通过,我们只需要定义一个名为speakersReducer的函数。

function speakersReducer() {
 }

我们下一个测试将检查 reducer 的功能。

it('Loads Speakers', () => {
   // arrange
   const initialState = [];

   const speaker = {
     id: 'test-speaker',
     firstName: 'Test',
     lastName: 'Speaker'
   };
   const action = actions.getSpeakersSuccess([speaker]);

   // act
   const newState = speakersReducer(initialState, action);

   // assert
   expect(newState).to.have.lengthOf(1);
   expect(newState[0]).to.deep.equal(speaker);
 });

这个测试比我们通常喜欢的要大,所以让我们来分析一下。在安排阶段,我们配置初始状态并创建一个由单个演讲者组成的动作结果数组。当一个 reducer 被调用时,应用的前一个状态和动作的结果会被传递给它。在这种情况下,我们从一个空数组开始,修改是添加一个单个演讲者。

接下来,在测试的 Act 部分,我们调用 reducer,传入initialState和我们的动作调用结果。reducer 返回一个新的状态,供我们在应用中使用。

最后,在断言中,我们期望新状态由单个演讲者组成,并且演讲者具有与我们为动作创建的演讲者相同的数据。

为了使测试通过,我们需要处理传递给 reducer 的动作。

function speakersReducer(state = [], action) {
   switch(action.type) {
     case types.GET_SPEAKERS_SUCCESS:
       return action.speakers;
     default:
       return state;
   }
 }

因为在使用 Redux 的应用中,对于每个动作都会调用 reducer,我们需要确定对于任何不是我们想要处理的动作,我们应该做什么。在这些情况下,适当的响应是简单地返回未修改的状态。

对于我们想要处理的动作类型,在这种情况下,我们返回动作的演讲者数组。在其他 reducers 中,我们可能会将初始状态与动作结果合并,但对于获取演讲者成功,我们想要用我们接收的值替换状态。

最后一步,现在所有测试都通过了,我们需要将演讲者 reducer 从测试文件中提取出来,并将其移动到speakerReducer.js

演讲者列表组件

我们还可以测试应用的其他部分,即组件。在典型的 React + Redux 应用中,有两种类型的组件。我们有容器组件和表示性组件。

容器组件通常不包含任何真实的 HTML。容器组件的渲染函数只是引用单个表示性组件。

表示性组件通常不包含任何业务逻辑。它们接收属性并显示这些属性。

在我们从后端到前端的过程中,我们已经涵盖了数据的检索和更新。接下来,让我们看看将使用这些数据的容器组件。

我们的容器组件将是一个简单的组件。让我们从典型的存在性测试开始。

import { expect } from 'chai';
import { SpeakersPage } from './SpeakersPage';

 describe('Speakers Page', () => {
   it('exists', () => {
     expect(SpeakersPage).to.exist;
   });
 });

简单直接;现在来让它通过。

export class SpeakersPage {
 }

接下来是组件的渲染函数。

import React from 'react';
import Enzyme, { mount, shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import { SpeakersPage } from './SpeakersPage';

 describe('Render', () => {
   beforeEach(() => {
      Enzyme.configure({ adapter: new Adapter() });
   });

   it('renders', () => {
     // arrange
     const props = {
       speakers: [{ 
         id: 'test-speaker', 
         firstName:'Test', 
         lastName:'Speaker' 
       }]
     };

     // act  
     const component = shallow(<SpeakersPage { ...props } />);

     // assert
     expect(component.find('SpeakerList')).to.exist;                 
     expect(component.find('SpeakerList').props().speakers)
       .to.deep.equal(props.speakers);
   });
 });

这个测试引入了一些新的概念。从测试的 act 部分开始。我们使用 Enzyme 的浅渲染。浅渲染会渲染 React 组件,但不会渲染组件的子组件。在这种情况下,我们期望存在一个SpeakerList组件,并且这个组件正在渲染它。这里显示了 Enzyme 适配器的配置,但也可以在测试通过后将它移动到test.js文件中。

我们还在检查属性,以确保我们将演讲者传递给表示性组件。为了让这个测试通过,我们必须修改SpeakersPage组件,但我们也必须创建一个SpeakerList组件。现在让我们来做这件事。

export class SpeakersPage extends Component {
   render() {
     return (
       <SpeakerList speakers={this.props.speakers} />
     );
   }
 }

然后在新的文件中,我们需要添加SpeakerList

export const SpeakerList = ({speakers}) => {}

你可能已经注意到我们的容器组件没有任何逻辑。事实上,它所做的只是渲染SpeakerList组件。如果它只做这些,为什么它是一个容器组件呢?原因在于这个组件将是一个 Redux 连接组件。我们希望将 Redux 代码保留在我们的业务逻辑中,而不是在我们的显示组件中。因此,我们将它视为一个高阶组件,只是用它来将数据传递给表示性组件。稍后,当我们到达演讲者详情组件时,你会看到一个带有少量业务逻辑的容器组件。

目前,我们的SpeakerList组件看起来有点瘦弱,并不能真正作为一个 React Redux 应用的一部分工作。是时候测试我们的表示性组件了。

describe('Speaker List', () => {
   it('exists', () => {
     expect(SpeakerList).to.exist;
   });
 });

由于上一个测试,这个测试将自动通过。通常情况下,如果我们遵循我们刚才所做的进度,我们不会编写这个测试。实际上,我们应该忽略之前的测试,创建这个测试,然后创建SpeakerList组件。之后,我们可以重新启用之前的测试并让它通过。

下一步是测试当演讲者数组为空时,是否渲染了没有可用演讲者的消息。

function setup(props) {
   const componentProps = {
     speakers: props.speakers || []
   };

   return shallow(<SpeakerList {...componentProps} />);
 }

 describe('On Render', () => {
   describe('No Speakers Exist', () => {
     it('renders no speakers message', () => {
       // arrange
       const speakers = [];

       // arrange
       const component = setup({speakers});

       // assert
       expect(component.find('#no-speakers').text())
         .to.equal('No Speakers Available.');
     });
   });
 });

对于这次测试,我们创建了一个辅助函数来初始化组件,并传入我们需要的属性。为了让测试通过,我们只需返回一个包含正确文本的div

export const SpeakerList = ({speakers}) => {
   return (
     <div>
     <h1>Speakers</h1>
     <div id="no-speakers">No Speakers Available.</div>
     </div>
   );
 };

当我们只测试 no-speakers div 时,我们可以有装饰,但我们决定不测试它。在这种情况下,我们希望在页面上有一个标题。我们的测试应该通过。

因此,现在我们准备测试当演讲者存在的情况。

describe('Speakers Exist', () => {
   it('renders a table when speakers exist', () => {
     // arrange
     const speakers = [{
       id: 'test-speaker-1',
       firstName: 'Test',
       lastName: 'Speaker 1'
     }, {
       id: 'test-speaker-2',
       firstName: 'Test',
       lastName: 'Speaker 2'
     }];

     // act
     const component = setup({speakers});

     // assert        
     expect(component.find('.speakers')
       .children()).to.have.lengthOf(2);
     expect(component.find('.speakers')
       .childAt(0).type().name).to.equal('SpeakerListRow');
   });
 });

在这个测试中,我们检查了两件事。我们希望显示正确的演讲者行数,并且希望它们由新的 SpeakerListRow 组件渲染。

export const SpeakerList = ({speakers}) => {
   let contents = <div>Error!</div>;

   if(speakers.length === 0) {
     contents = <div id="no-speakers">No Speakers Available.</div>;
   } else {
     contents = (
       <table className="table">
         <thead>
           <tr>
             <th>Name</th>
             <th></th>
           </tr>
         </thead>
         <tbody className="speakers">
           { 
             speakers.map(speaker => 
               <SpeakerListRow key={speaker.id} speaker={speaker} />) 
           }
         </tbody>
       </table>
     );
   }

   return (
     <div>
     <h1>Speakers</h1>
     { contents }
     </div>
   );
 };

由于我们最新的测试,组件代码已经发生了显著变化。我们不得不添加一些逻辑,并且我们还添加了一个默认错误情况,以防内容在没有分配的情况下通过。

为了使这一部分的代码正确工作,我们还需要一个组件。虽然我们不会在这个书中测试该组件,但该组件内部没有逻辑,留作你的练习来创建。

为了创建该组件,如果应用程序能够运行会更好。目前,我们没有连接 Redux,所以应用程序不会渲染任何内容。让我们回顾一下我们现在使用的 Redux 配置。

index.js 中,我们需要添加一些项目以让 Redux 工作。你的索引应该看起来像这样:

 import React from 'react';
 import ReactDOM from 'react-dom';
 import {BrowserRouter} from 'react-router-dom';
 import {Provider} from 'react-redux';
 import registerServiceWorker from './registerServiceWorker';
 import configureStore from './store/configureStore';
 import { getSpeakers } from './actions/speakerActions';
 import 'bootstrap/dist/css/bootstrap.min.css';
 import './index.css';
 import App from './components/App.js';

 const store = configureStore();
 store.dispatch(getSpeakers());

 ReactDOM.render(
   <Provider store={store}>
     <BrowserRouter>
       <App/>
     </BrowserRouter>
   </Provider>,
   document.getElementById('root')
 );

 registerServiceWorker();

我们添加的两个部分是 Redux 存储,包括一个初始调用以分发加载演讲者的动作,以及添加 Redux 提供者的标记。

在定义其他路由的地方,你需要为演讲者部分添加路由。我们将路由放在 App.js 中。

<Route exact path='/speakers/:id' component={SpeakerDetailPage}/>
<Route exact path='/speakers' component={SpeakersPage}/>

最后,我们必须将我们的组件转换为 Redux 组件。将以下行添加到演讲者页面组件文件底部。

import { connect } from 'react-redux';

function mapStateToProps(state) {
   return {
     speakers: state.speakers
   };
 }

 function mapDispatchToProps(dispatch) {
   return bindActionCreators(speakerActions, dispatch);
 }

 export default connect(mapStateToProps, mapDispatchToProps)(SpeakersPage);

从代码示例的底部开始,connect 函数由 Redux 提供,它将所有 Redux 功能连接到我们的组件中。传入的两个函数 mapStateToPropsmapDispatchToProps 是作为填充状态和为我们的组件提供执行动作的方式传入的。

mapDispatchToProps 中,我们调用 bindActionCreators;这是另一个 Redux 提供的函数,将给我们一个包含所有动作的对象。通过直接从 mapDispatchToProps 返回该对象,我们将动作直接添加到 props 中。我们也可以创建一个包含动作属性的对象,然后将 bindActionCreators 的结果分配给该属性。

在应用程序内部任何引用 SpeakersPage 的地方,现在都可以改为仅 SpeakersPage,这将获取我们新的默认导出。不要在测试中做这个更改。在测试中,我们仍然想要命名导入。

完成这些操作后,我们应该能够运行应用程序并导航到演讲者路由。如果你还没有添加演讲者路由的链接,现在是一个好时机,这样你就不必每次都直接在 URL 中输入路由了。

当你到达演讲者路由时,你应该看到没有演讲者,并且我们收到了我们的消息。我们需要某种方式来填充演讲者,这样我们就可以测试列表。我们将在下一节中介绍填充演讲者的方法。现在,在模拟 API 中修改构造函数以包含几个演讲者。以这种方式修改服务将导致一些测试失败,所以在你视觉上验证了一切看起来都很好之后,务必删除或至少注释掉你添加的代码。

演讲者详情

现在我们有了演讲者列表,能够查看更多关于特定演讲者的信息会很好。让我们看看涉及检索和查看演讲者详细信息的测试。

添加到模拟 API 服务

在模拟 API 中,我们需要添加一个调用以获取特定演讲者的详细信息。我们可以假设演讲者有一个 ID 字段,我们可以用它来收集这些信息。像往常一样,让我们从简单的存在性检查开始我们的测试。我们需要在After Initialization描述中添加一个新的 describe 来通过 ID 获取演讲者。

describe('Get Speaker By Id', () => {
   it('exists', () => {
     // assert
     expect(service.getById).to.exist;
   });
 });

要使这个测试通过,我们需要向模拟 API 添加一个方法。

getById() {
 }

现在,我们可以编写一个测试来验证当找不到匹配的演讲者时我们期望的功能。在这种情况下,我们想要的功能是在我们到达用户界面时显示SPEAKER_NOT_FOUND消息。在模拟 API 级别,我们可以假设服务器会发送一个 404。我们可以从模拟 API 中响应一个包含SPEAKER_NOT_FOUND类型的错误。这类似于使用操作的方式。

让我们为演讲者未找到的场景创建另一个 describe。

describe('Speaker Does Not Exist', () => {
   it('SPEAKER_NOT_FOUND error is generated', () => {
     // act
     const promise = service.getById('fake-speaker');

     // assert
     return promise.catch((error) => {
       expect(error.type).to.equal(errorTypes.SPEAKER_NOT_FOUND);
     });
   });
 });

你可能已经注意到我们悄悄地加入了errorTypeserrorTypes有自己的文件夹,但构建方式与actionTypes完全相同。

要使这个测试通过,我们必须向我们的模拟 API 添加一个拒绝的承诺。

getById(id) {
   return new Promise((resolve, reject) => {
     setTimeout(() => {
         reject({ type: errorTypes.SPEAKER_NOT_FOUND });
     }, 0);
   });
 }

我们没有测试来强制该方法返回一个积极的结果,所以现在我们可以每次都拒绝。

这就带我们到了下一个测试。如果找到演讲者会发生什么?理想情况下,演讲者和所有演讲者详情都应该返回给调用者。现在让我们编写这个测试。

describe('Speaker Exists', () => {
   it('returns the speaker', () => {
     // arrange
     const speaker = { id: 'test-speaker' };
     service.create(speaker);

     // act
     let promise = service.getById('test-speaker');

     // assert
     return promise.then((speaker) => {
       expect(speaker).to.not.be.null;
       expect(speaker.id).to.equal('test-speaker');
     });
   });
 });

要通过这个测试,我们将在生产代码中添加一些逻辑。

getById(id) {
   return new Promise((resolve, reject) => {
     setTimeout(() => {
       let speaker = this._speakers.find(x => x.id === id);
       if(speaker) {
         resolve(Object.assign({}, speaker));
       } else {
         reject({ type: errorTypes.SPEAKER_NOT_FOUND });
       }
     }, 0);
   });
 }

要使测试通过,我们首先需要检查演讲者是否存在。如果演讲者存在,我们返回该演讲者。如果演讲者不存在,我们拒绝承诺并提供我们的错误结果。

获取演讲者操作

我们现在有一个模拟 API 可以调用,它的行为方式符合我们的预期。接下来在我们的列表中是创建将处理模拟 API 结果的操作。对于获取演讲者的过程,我们需要两个操作。其中一个操作将通知应用程序成功找到并提供了找到的演讲者给 reducers。另一个操作将通知应用程序请求的演讲者找不到。

让我们编写一个测试来确认其存在。这个测试应该在演讲者动作测试的同步测试部分中。我们还将为获取演讲者成功动作创建一个新的 describe。

describe('Find Speaker Success', () => {
   it('exists', () => {
     expect(speakerActions.getSpeakerSuccess).to.exist;
   });
 });

要使这个测试通过,我们只需创建动作函数。

export function getSpeakerSuccess() {
 }

现在我们需要验证动作的返回值。就像我们的获取所有演讲者的成功动作一样,获取演讲者成功动作将接收找到的演讲者并返回一个包含类型和演讲者数据的对象。现在让我们为这个动作编写测试。

it('is created with correct data', () => {
   // arrange
   const speaker = {
     id: 'test-speaker',
     firstName: 'Test',
     lastName: 'Speaker'
   };

   // act
   const result = speakerActions.getSpeakerSuccess(speaker);

   // assert
   expect(result.type).to.equal(types.GET_SPEAKER_SUCCESS);
   expect(result.speaker).to.deep.equal(speaker);
 });

这个测试相当直接,所以让我们看看生产代码来通过它。

export function getSpeakerSuccess(speaker) {
   return { type: types.GET_SPEAKER_SUCCESS, speaker: speaker };
 }

再次,这段代码很简单。接下来,让我们处理失败动作。我们还需要为这个测试创建一个新的 describe。

describe('Get Speaker Failure', () => {
   it('exists', () => {
     expect(speakerActions.getSpeakerFailure).to.exist;
   });
 });

这里没有新的内容,你现在应该开始对流程有感觉了。让我们继续并使这个测试通过。

export function getSpeakerFailure() {
 }

在检索演讲者失败时,我们应该得到的是SPEAKER_NOT_FOUND错误类型。在我们的下一个测试中,我们将收到这个错误并从中创建动作类型。

it('is created with correct data', () => {
   // arrange
   const error = {
     type: errorTypes.SPEAKER_NOT_FOUND
   };

   // act
   const result = speakerActions.getSpeakerFailure(error);

   // assert
   expect(result.type).to.equal(types.GET_SPEAKER_FAILURE);
   expect(result.error).to.deep.equal(error);
 });

使这个测试通过的过程与其它同步动作的实现非常相似。

export function getSpeakerFailure(error) {
   return { type: types.GET_SPEAKER_FAILURE, error: error }
 }

看着代码,有一个重要的区别。这段代码没有演讲者数据。原因是这个动作需要由不同的 reducer,即错误 reducer 来处理。我们将很快创建错误 reducer 和错误组件。但首先,我们需要创建一个异步动作,该动作将调用模拟 API。

在测试获取演讲者的异步动作时,我们应该从失败情况开始。在这种情况下,失败情况是GET_SPEAKER_FAILURE。这里有一个测试来确保触发了正确的次要动作。

it('creates GET_SPEAKER_FAILURE when speaker is not found', () => {
   // arrange
   const speakerId= 'not-found-speaker';
   const store = mockStore({
     speaker: {}
   });

   // act
   return (
     store.dispatch(speakerActions.getSpeaker(speakerId)).then(() => {
       const actions = store.getActions();

       // assert
       console.log(actions);
       expect(actions[0].type).to.equal(types.GET_SPEAKER_FAILURE);
     })
   );
 });

使这个测试通过的代码与我们获取所有演讲者的代码相似。

export function getSpeaker(speakerId) {
   return function(dispatch) {
     return new MockSpeakerService().getById(speakerId).catch(err => {
       dispatch(getSpeakerFailure(err));
     });
   };
 }

在这里,我们已经调用了模拟 API,并期望它拒绝承诺,从而导致getSpeakerFailure动作的派发。

我们接下来的测试是成功检索一个特定的演讲者。不过,我们确实有一个问题。你可能已经注意到,我们为每个异步动作创建一个新的MockSpeakerService。这是有问题的,因为它阻止我们预先在我们的模拟 API 中填充测试值。在应用程序的开发后期,后端将准备就绪,我们希望将前端代码指向一个真实后端。只要我们直接引用并创建模拟 API 服务,我们就无法做到这一点。

我们面临的问题有很多解决方案。我们将探索创建一个工厂来决定为我们提供哪个后端。工厂还将允许我们将模拟 API 作为单例处理。将服务作为单例处理将允许我们在测试设置中预先填充服务。

在服务文件夹中,让我们为创建工厂类和功能创建一组新的测试。

import { expect } from 'chai';
import { ServiceFactory as factory } from './serviceFactory';

describe('Service Factory', () => {
  it('exits', () => {
    expect(factory).to.exist;
  });
});

我们通过定义一个类就能使这个测试通过。

export class ServiceFactory {
}

现在我们需要一个创建演讲者服务的方法。在工厂测试中添加一个新的 describe。

describe('Create Speaker Service', () => {
   it('exists', () => {
     expect(factory.createSpeakerService).to.exist;
   });
 });

注意我们使用工厂的方式,我们并没有初始化它。我们希望工厂成为一个具有静态方法的类。拥有静态函数将赋予我们想要的单例能力。

static createSpeakerService() {
 }

接下来,我们想要确保createSpeakerService工厂方法能为我们提供一个模拟 API 的实例。

it('returns a speaker service', () => {
   // act
   let result = factory.createSpeakerService();

   // assert
   expect(result).to.be.an.instanceof(MockSpeakerService);
 });

使这个测试通过很简单,只需从工厂方法返回一个新的模拟演讲者服务。

static createSpeakerService() {
   return new MockSpeakerService();
 }

然而,这并不是一个单例。因此,我们在这里还有一些更多的工作要做。在我们替换应用中所有服务调用为工厂调用之前,让我们在工厂中再写一个测试。为了验证某个东西是单例,我们必须确保它在整个应用中都是相同的。我们可以通过在连续调用上进行引用比较来实现这一点。另一个选项是创建演讲者服务,向其中添加一个演讲者,创建一个新的演讲者服务,并尝试从第二个服务中拉取演讲者。如果我们正确地做了事情,第二个选项是最彻底的。我们将在这里执行第一个选项,但自己尝试第二个选项将是一个很好的练习。

it('returns the same speaker service', () => {
   // act
   let service1 = factory.createSpeakerService();
   let service2 = factory.createSpeakerService();

   // assert
   expect(service1).to.equal(service2);
 });

为了通过测试,我们必须确保每次都返回相同的演讲者服务实例。

export default class ServiceFactory {
   constructor() {
     this._speakerService = null;
   }

   static createSpeakerService() {
     return this._speakerService = this._speakerService || 
                                   new MockSpeakerService();
   }
 }

现在,工厂将返回当前值,如果当前值为null,则创建一个新的演讲者服务。

下一步是前往每个直接实例化模拟演讲者服务的地方,并用工厂调用替换它。我们将把这个作为你的练习,但要知道,向前推进时,我们将假设它已经被完成。

现在我们已经替换了工厂,并且它正在生成单例,我们可以编写下一个动作测试。我们想要测试成功检索一个演讲者。

it('creates GET_SPEAKER_SUCCESS when speaker is found', () => {
   // arrange
   const speaker = {
     id: 'test-speaker',
     firstName: 'Test',
     lastName: 'Speaker'
   };
   const store = mockStore({ speaker: {} });
   const expectedActions = [
     speakerActions.getSpeakerSuccess([speaker.id])
   ];
   let service = factory.createSpeakerService();
   service.create(speaker);

   // act
   return store.dispatch(
     speakerActions.getSpeaker('test-speaker')).then(() => {
       const actions = store.getActions();

       // assert
       expect(actions[0].type).to.equal(types.GET_SPEAKER_SUCCESS);
       expect(actions[0].speaker.id).to.equal('test-speaker');
       expect(actions[0].speaker.firstName).to.equal('Test');
       expect(actions[0].speaker.lastName).to.equal('Speaker');
     });
 });

这个测试中有很多事情在进行;让我们一步步来看。首先在 arrange 阶段,我们创建一个演讲者对象,将其放入服务中,并用于断言。接下来,仍然在 arrange 阶段,我们创建并配置模拟存储。最后,在 arrange 阶段,我们创建演讲者服务,并使用该服务创建我们的测试演讲者。

接着,在 act 中,我们派发一个调用以获取测试演讲者。记住,这个调用是异步的。因此,我们必须订阅 then。

当承诺解决时,我们将动作存储在一个变量中,并断言第一个动作具有正确的类型和有效载荷。

现在为了让这个测试通过,我们需要对服务上的getById方法进行一些修改。

export function getSpeaker(speakerId) {
   return function(dispatch) {
     return factory.createSpeakerService().getById(speakerId).then(
       speaker => {
         dispatch(getSpeakerSuccess(speaker));
       }).catch(err => {
         dispatch(getSpeakerFailure(err));
       });
   };
 }

我们真正做的只是添加了一个 then 来处理承诺的解析。现在,从所有当前目的来看,我们有一个工作的演讲者服务。让我们继续创建处理获取演讲者动作的 reducers。

获取演讲者 reducer

要处理与获取演讲者相关的动作,我们必须创建两个还原器。第一个还原器与为我们创建的获取演讲者动作的还原器非常相似。第二个将需要稍微不同,用于处理错误情况。

让我们从两个中最简单的一个开始,创建演讲者还原器。

describe('Speaker Reducer', () => {
     it('exists', () => {
       expect(speakerReducer).to.exist;
     });
 });

我们的典型存在测试很容易通过。

export function speakerReducer() {
 }

下一个测试确保还原器正确更新状态,并将结束这个还原器所需的测试。

it('gets a speaker', () => {
   // arrange
   const initialState = { id: '', firstName: '', lastName: '' };
   const speaker = { id: 'test-speaker', firstName: 'Test', lastName: 'Speaker'};
   const action = actions.getSpeakerSuccess(speaker);

   // act
   const newState = speakerReducer(initialState, action);

   // assert
   expect(newState).to.deep.equal(speaker);
 });

这个测试的变化是还原器的输入和状态的输出。让我们通过模仿演讲者还原器来使这个测试通过。

export function speakerReducer(state = { 
   id: '', 
   firstName: '', 
   lastName: ''
 }, action) {
   switch(action.type) {
     case types.GET_SPEAKER_SUCCESS:
       return action.speaker;
     default:
       return state;
   }
 }

与演讲者还原器类似,这个还原器只是检查动作类型是否为GET_SPEAKER_SUCCESS,如果找到,则返回动作附加的演讲者作为新状态。否则,我们只返回我们收到的状态对象。

接下来,我们需要一个错误还原器。

describe('Error Reducer', () => {
   it('exists', () => {
     expect(errorReducer).to.exist;
   });
 });

通过这个测试就像通过所有其他存在测试一样简单。

import * as types from './actionTypes';
import * as errors from './errorTypes';

export function errorReducer() {
}

错误还原器将具有一些有趣的功能。在接收到错误的情况下,我们希望错误堆叠起来,这样我们就不会替换状态。相反,我们将克隆并添加到状态中。然而,当我们接收到不是错误的动作时,我们希望清除错误并允许正常程序执行继续。我们还想忽略重复的错误。首先,我们将处理我们已知的错误。

it('returns error state', () => {
   // arrange
   const initialState = [];
   const error = { type: errorTypes.SPEAKER_NOT_FOUND };
   const action = actions.getSpeakerFailure(error);

   // act
   const newState = errorReducer(initialState, action);

   // assert
   expect(newState).to.deep.equal([error]);
 });

我们的测试与之前的还原器测试略有不同。主要区别在于我们正在将预期的值包裹在一个数组中。我们这样做是为了满足可能堆叠多个错误并显示给用户的需求。

要使这个测试通过,我们遵循熟悉的还原器模式,我们已经在使用。

export function errorReducer(state = [], action) {
   switch(action.type) {
     case types.GET_SPEAKER_FAILURE:
       return [...state, action.error];
     default:
       return state;
   }
 }

与之前所述的原因相同,注意我们如何使用其余的参数语法将现有状态展开到新数组中,从而有效地克隆状态。

我们还有两个针对错误还原器的测试;第一个是确保不添加重复的错误。第二个测试将在调用非错误动作时清除错误。

it('ignores duplicate errors', () => {
   // arrange
   const error = { type: errorTypes.SPEAKER_NOT_FOUND };
   const initialState = [error];
   const action = actions.getSpeakerFailure(error);

   // act
   const newState = errorReducer(initialState, action);

   // assert
   expect(newState).to.deep.equal([error]);
 });

在测试中,为了设置具有预填充状态的条件,我们只需修改initialState参数。

export function errorReducer(state = [], action) {
   switch(action.type) {
     case types.GET_SPEAKER_FAILURE:
       let newState = [...state];

       if(newState.every(x => x.type !== action.error.type)) {
         newState.push(action.error);
       }

       return newState;
     default:
       return state;
   }
 }

要使这个测试通过,我们只需确保错误类型尚未存在于状态数组中。有许多方法可以做到这一点;我们选择使用every函数作为一个检查,以确保现有的错误中没有匹配项。这种方法可能不是非常高效,但由于错误数量最多只有几个,因此不应该成为性能问题。

下一个测试是在接收到非错误时清除错误状态。

it('clears when a non-error action is received', () => {
   // arrange
   const error = { type: errorTypes.SPEAKER_NOT_FOUND };
   const initialState = [error];
   const action = { type: 'ANY_NON_ERROR' };

   // act
   const newState = errorReducer(initialState, action);

   // assert
   expect(newState).to.deep.equal([]);
 });

使这个测试通过极其简单。我们只需替换默认功能,即返回现有状态。

default:
   return [];

说话者详情组件

现在我们已经准备好创建我们的SpeakerDetailPage。这个组件没有太多内容。它将需要成为一个容器组件,以便它可以使用获取演讲者动作。因为它是一个容器组件,所以我们将不会在这个组件中直接放置任何标记。对我们来说,好消息是这意味着我们的测试将会简短且简单。

为了启动测试,创建一个存在性测试。

describe('Speaker Detail Page', () => {
   it('exists', () => {
     expect(SpeakerDetailPage).to.exist;
   });
 });

创建一个SpeakerDetailPage文件,并向其中添加一个组件。

export class SpeakerDetailPage extends Component {
   render() {
     return (<div></div>);
   }
 }

我们接下来想要测试的,在没有直接指定设计的情况下唯一可以测试的另一件事是,模型被接收并且以某种方式显示在屏幕上。现在我们只需要测试模型的一个属性。我们将编写一个测试来显示演讲者的名字被显示出来。

describe('Render', () => {
   it('renders', () => {
     // arrange
     const props = {
       match: { params: { id: 'test-speaker' } },
       actions: { getSpeaker: (id) => { return Promise.resolve(); } },
       speaker: { firstName: 'Test' } 
     };

     // act
     const component = mount(<SpeakerDetailPage { ...props } />);

     // assert
     expect(component.find('first-name').text()).to.contain('Test');
   });
 });

如果你注意到了,你可能想知道为什么获取演讲者动作只是返回一个空的已解析的承诺。我们并没有绑定到 Redux,所以启动动作不会触发 reducer,这不会更新 store,也不会触发组件状态的刷新。尽管如此,我们仍然想在测试设置中完成组件的合约,这个组件将调用那个函数。我们可以省略这一行,但一旦我们连接了 Redux,我们就会将其添加回来。

为了使测试通过,我们需要在SpeakerDetailPage组件中做一些简单的修改,并创建一个全新的组件。以下是该组件的修改,但创建下一个组件将是你的练习。它仅用于显示,我们正在测试它是否在这里被填充,所以你只需要编写一个表现组件。

export class SpeakerDetailPage extends Component {
   constructor(state, context) {
     super(state, context);

     this.state = {
       speaker: Object.assign({}, this.props.speaker)
     };
   }

   render() {
     return (
       <SpeakerDetail speaker={this.state.speaker} />
     );
   }
 }

之前的代码将使测试通过,但现在我们必须将组件连接到 Redux。我们将添加对getSpeaker动作的调用,绑定到componentWillReceiveProps生命周期事件,并使用 connect 函数映射属性和分发。以下是最终的SpeakerDetailPage组件。

export class SpeakerDetailPage extends Component {
   constructor(state, context) {
     super(state, context);

     this.state = {
       speaker: Object.assign({}, this.props.speaker)
     };

     this.props.actions.getSpeaker(this.props.match.params.id)
   }

   componentWillReceiveProps(nextProps) {
     if(this.props.speaker.id !== nextProps.speaker.id) {
       this.setState({ speaker: Object.assign({}, nextProps.speaker) });
     }
   }

   render() {
     return (
       <SpeakerDetail speaker={this.state.speaker} />
     );
   }
 }

 function mapStateToProps(state, ownProps) {
   let speaker = { id: '', firstName: '', lastName: '' }

   return {
     speaker: state.speaker || speaker
   };
 }

 function mapDispatchToProps(dispatch) {
   return {
     actions: bindActionCreators(speakerActions, dispatch)
   }
 }

 export default  connect(
   mapStateToProps,
   mapDispatchToProps
 )(SpeakerDetailPage);

现在一切测试都通过了,我们还需要完成最后一件事,才能正确地进一步开发。之前我们用对工厂的调用替换了模拟 API。我们这样做是为了让测试能够影响动作中模拟 API 的状态。同样的修改使得我们可以为我们的应用程序配置一个起点。在index.js文件中,在配置完 store 之后添加以下代码;现在,当你运行应用程序时,你将会有可用的演讲者来测试 UI。

const speakers = [{
   id: 'clayton-hunt',
   firstName: 'Clayton',
   lastName: 'Hunt'
 }, {
   id: 'john-callaway',
   firstName: 'John',
   lastName: 'Callaway'
 }];

 let service = factory.createSpeakerService();
 speakers.forEach((speaker) => {
   service.create(speaker);
 });

摘要

目前为止,我们已经完成了对 React 应用的单元测试。我们还没有一个测试某种输入的例子。尝试测试并实现一个CreateSpeakerPage。从 React 的角度来看,你需要做些什么?Redux 会要求你做些什么?在本章中,我们像对待组件一样对待了 React 组件。对于仅用于显示的组件,这正是这些组件所做的工作,这种方法可能是更好的。然而,对于具有一些实际功能的组件,你可能希望在将其附加到 React 之前,先尝试以普通的 JavaScript 类测试其功能。我们也在本章中为你留下了相当多的工作要做。如果你在填写空白以完成代码时迷失方向或需要提示,不要害羞地去查看与本章相关的源代码。

第十章:探索集成

在本章中,我们将探讨集成测试 Speaker Meet 应用程序。我们将测试并配置 React 前端应用程序以击中真实的后端 API,并测试.NET 应用程序以确保它从控制器到数据库都能正常工作。

在本章中,我们将涵盖:

  • 实现一个真实的 API 服务

  • 移除模拟 API 调用

  • 端到端集成

  • 集成测试

实现一个真实的 API 服务

现在是真正从服务器接收数据的时候了。我们当前的数据模型仍然不是 100%正确,但基础工作已经完成。当我们从服务器接收到正确的数据结构时,我们需要相应地更新我们的视图。我们将把这个部分留给你作为练习。

在本节中,我们将查看从我们创建的工厂中拉出模拟 API,并用真实 API 替换它。在我们的现有测试中,我们将使用 Sinon 来覆盖我们的 Ajax 组件的默认功能,使用我们的模拟 API 的功能。

最后,我们需要创建一个应用程序配置对象来管理 API 的基本路径,以确定开发和生产中的正确路径。

用真实 API 服务替换模拟 API

为了尽可能保持简单,我们将使用 fetch API 从服务器获取数据。我们将首先中断所有目前使用模拟 API 的测试。这是因为我们将创建一个实现与模拟 API 相同接口的存根类,但它不会做任何事情:

src/services/fetchSpeakerService.js

import * as errorTypes from '../reducers/errorTypes';

 export default class FetchSpeakerService {
   constructor() { }

   create(speaker) {
     return;
   }

   getAll() {
     return;
   }

   getById(id) {
     return;
   }
 }

现在,用基于 fetch 的服务创建来替换工厂创建的模拟服务:

import FetchSpeakerService from './fetchSpeakerService';

 export default class ServiceFactory {
   constructor() {
     this._speakerService = null;
   }

   static createSpeakerService() {
     return this._speakerService = 
       this._speakerService || new FetchSpeakerService();
   }
 }

幸运的是,只有四个测试因为这次更改而失败。查看失败的测试,其中三个失败是因为我们没有返回一个承诺。然而,有一个测试失败是因为我们不再返回模拟 API。我们将忽略由缺少承诺引起的失败测试,暂时排除它们。然后,我们将专注于检查特定实例的测试。

失败的测试是在服务工厂测试中。我们实际上不希望服务工厂返回一个MockSpeakerService。我们希望它返回一个FetchSpeakerService。更准确地说,我们希望返回任何SpeakerService的实现。让我们创建一个基类,它将表现得像 C#中的接口或抽象类:

/src/services/speakerService.js

export default class SpeakerService {
   create(speaker) {
     throw new Error("Not Implemented!")
   }

   getAll() {
     throw new Error("Not Implemented!")
   }

   getById(id) {
     throw new Error("Not Implemented!")
   }
 }

现在我们有一个抽象基类,我们需要在我们的现有服务类中继承这个基类:

import SpeakerService from './speakerService';

 export default class MockSpeakerService extends SpeakerService {
   constructor() {
     super();

     this._speakers = [];
  }
 …
import SpeakerService from './speakerService';

 export default class FetchSpeakerService extends SpeakerService {
   constructor() {
     super();
   }
 …

然后我们需要修改工厂测试,以期望基类的一个实例而不是派生类的一个实例:

it('returns a speaker service', () => {
   // act
   let result = factory.createSpeakerService();

   // assert
   expect(result).to.be.an.instanceOf(SpeakerService);
 });

使用 Sinon 模拟 Ajax 响应

现在,是时候解决我们忽略的三个测试了。它们期望从我们的服务中获得实际响应。目前,我们的服务是完全空的。记住,那些测试是为了编写单元测试而编写的,我们需要保护它们免受真实端点随时间变化响应的影响。为此,我们最终将引入 Sinon。

我们将使用 Sinon 从我们的模拟 API 返回结果,而不是使用真实 API。这将允许我们继续使用我们已经投入模拟 API 的工作。

在我们有现有的测试覆盖后,我们将通过使用 Sinon 模拟后端服务器来引入集成测试。以这种方式使用 Sinon 将允许我们测试驱动基于 fetch 的演讲者服务。

修复现有测试

首先,我们必须让现有的测试通过。在speakerActions.spec.js文件中,找到我们跳过的第一个测试,并移除跳过。这将导致该测试失败,原因如下:

无法读取未定义的属性 'then'

回到beforeEach方法,在那里我们创建演讲者服务,我们需要为服务方法创建一个新的 Sinon 模拟。查看测试,我们可以看到我们第一次服务调用是获取所有演讲者。所以,让我们从这里开始:

beforeEach(() => {
   let service = factory.createSpeakerService();
   let mockService = new MockSpeakerService();

   getAll = sinon.stub(service, "getAll");
   getAll.callsFake(mockService.getAll.bind(mockService));

   mockStore = configureMockStore(middleware);
 });

看着这段代码,我们所做的是创建一个新的 Sinon 模拟,并将对服务getAll方法的调用重定向到mockService getAll方法。最后,我们将mockService调用绑定到mockService以保留对私有变量的访问。

再次运行测试,我们得到一个新的错误:

尝试包装已包装的 getAll

这个错误告诉我们,我们已经为我们要模拟的方法创建了一个模拟。起初,这个错误可能没有意义。但是,如果你看,我们在beforeEach中这样做。Sinon 是一个单例,我们在beforeEach中运行我们的模拟命令,所以当第二个测试准备运行时,它已经注册了一个getAll模拟。我们必须做的是在我们再次注册之前移除该注册。另一种说法是,我们必须在每个测试运行后移除注册。让我们添加一个afterEach方法并在那里移除注册:

afterEach(() => {
   getAll.restore();
 });

这修复了我们遇到的第一个失败的测试,现在让我们修复其他两个。过程将大致相同,让我们开始。

从下一个测试中移除跳过。测试失败。我们在这次测试中调用getSpeaker动作,如果我们查看演讲者动作,我们可以看到它使用了getById服务方法。和之前一样,我们将在beforeEach中模拟这个方法。

getById = sinon.stub(service, "getById");

getById.callsFake(mockService.getById.bind(mockService));

如前所述,我们现在正在获取已经包装的消息:

尝试包装已包装的 getById

我们可以通过与修复上一个错误相同的方式修复这个问题,即在afterEach函数中移除模拟。

getById.restore();

我们现在回到了所有通过测试,只有一个跳过的测试。最后一个测试是相同的过程。以下是当我们完成时完整的beforeEachafterEach函数:

beforeEach(() => {
   let service = factory.createSpeakerService();
   let mockService = new MockSpeakerService();

   getAll = sinon.stub(service, "getAll");
   getAll.callsFake(mockService.getAll.bind(mockService));

   getById = sinon.stub(service, "getById");
   getById.callsFake(mockService.getById.bind(mockService));

   create = sinon.stub(service, "create");
   create.callsFake(mockService.create.bind(mockService));

   mockStore = configureMockStore(middleware);
 });

 afterEach(() => {
   create.restore();
   getAll.restore();
   getById.restore();
 });

不要忘记从最后一个测试中移除跳过。当一切完成时,你应该有 42 个通过测试和 0 个跳过测试。

模拟服务器

现在我们已经修复了现有的测试,我们准备好开始编写对真实服务fetchSpeakerService的测试。让我们从查看我们用于模拟服务的测试开始。测试将大致相同,因为我们试图实现相同的功能模式。

首先,我们将想要创建测试文件fetchSpeakerService.spec.js。一旦文件创建完成,我们就可以添加标准的存在性测试:

describe('Fetch Speaker Service', () => {
   it('exits', () => {
     expect(FetchSpeakerService).to.exist;
   });
 });

由于我们之前模拟了 fetch 演讲者服务,这个测试在添加适当的导入后应该直接通过。

在模拟演讲者服务测试之后,下一个测试是一个构造和类型验证测试:

it('can be constructed', () => {
   // arrange
   let service = new FetchSpeakerService();

   // assert
   expect(service).to.be.an.instanceof(FetchSpeakerService);
 });

这个测试也应该立即通过,因为我们模拟了 fetch 服务时,我们将其创建为一个类。继续跟随模拟服务测试的进展,我们有一个After Initialization部分,其中包含一个Create部分。Create部分中唯一的测试是对Create方法的exists测试。编写这个测试时,它应该通过:

describe('After Initialization', () => {
   let service = null;

   beforeEach(() => {
     service = new FetchSpeakerService();
   });

   describe('Create', () => {
     it('exists', () => {
       expect(service.create).to.exist;
     });
   });
 });

由于我们正在从模拟服务测试中复制流程,我们已经将服务提取到beforeEach实例化中。

在下一节中,我们的测试将开始变得有趣,而不会立即通过。在我们继续之前,为了验证测试是否正在执行它们应该执行的操作,注释掉 fetch 服务的一部分是一个好主意,以查看适当的测试是否通过。

接下来是Get All部分,仍然在After Initialization部分内,我们有一个检查getAll 方法的存在性测试:

describe('Get All', () => {
   it('exists', () => {
     // assert
     expect(service.getAll).to.exist;
   });
 });

与迄今为止的其他测试一样,要使这个测试失败,你将不得不在 fetch 服务中注释掉getAll方法以查看其失败。紧接着这个测试的是两个更多部分:No Speakers ExistSpeaker Listing。我们将逐个添加它们,从No Speakers Exist开始:

describe.skip('No Speakers Exist', () => {
   it('returns an empty array', () => {
     // act
     let promise = service.getAll();

     // assert
     return promise.then((result) => {
       expect(result).to.have.lengthOf(0);
     });
   });
 });

最后,我们有一个失败的测试。失败的原因是它看起来我们没有返回一个 promise。让我们开始正确实现 fetch 服务,并在测试中使用 Sinon 来模拟后端。在 fetch 服务中,添加以下内容:

constructor(baseUrl) {
   super();

   this.baseUrl = baseUrl;
 }

 getAll() {
   return fetch(`${this.baseUrl}/speakers`).then(r => {
     return r.json();
   });
 }

这是一个非常基本的 fetch 调用。我们使用 HTTP 动词GET,所以没有必要在 fetch 上调用方法;默认情况下,它将使用GET

在我们的测试中,我们现在得到了一个有意义的结果。fetch is not defined。这个结果是因为 fetch 在我们的测试设置中还不存在。我们需要导入一个新的 NPM 包来处理测试中的 fetch 调用。我们想要导入的包是fetch-ponyfill

>npm install fetch-ponyfill

安装了ponyfill库之后,我们必须修改我们的测试设置文件scripts/test.js

import { JSDOM } from'jsdom';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import fetchPonyfill from 'fetch-ponyfill';
const { fetch } = fetchPonyfill(); 
const jsdom = new JSDOM('<!doctype html><html><body></body></html>');
const { window } = jsdom;
window.fetch = window.fetch || fetch; 
…

global.window = window;
global.document = window.document;
global.fetch = window.fetch;

在这些修改之后,我们必须重新启动我们的测试以使更改生效。我们现在得到了一个测试失败,告诉我们只支持绝对 URL。我们得到这个消息是因为当我们实例化我们的 fetch 服务时,我们没有传递一个 baseURL。对于测试来说,URL 是什么并不重要,所以让我们就使用 localhost:

beforeEach(() => {
   service = new FetchSpeakerService('http://localhost');
 });

在进行这个更改后,我们将错误向前移动,现在我们得到了一个 fetch 错误,表明 localhost 拒绝了一个连接。我们现在准备好用 Sinon 替换后端。我们将从beforeEachafterEach开始:

let fetch = null;

 beforeEach(() => {
   fetch = sinon.stub(global, 'fetch');
   service = new FetchSpeakerService('http://localhost');
 });

 afterEach(() => {
   fetch.restore();
 });

在测试中,我们需要fetch-ponyfill包中的某些项目,所以在我们接近文件顶部的时候,让我们添加导入语句。

import fetchPonyfill from 'fetch-ponyfill';
 const {
   Response,
   Headers
 } = fetchPonyfill();

现在在测试中,我们需要配置来自服务器的响应:

it('returns an empty array', () => {
   // arrange
   fetch.returns(new Promise((resolve, reject) => {
     let response = new Response();
     response.headers = new Headers({
       'Content-Type': 'application/json'
     });
     response.ok = true;
     response.status = 200;
     response.statusText = 'OK';
     response.body = JSON.stringify([]);

     resolve(response);
   }));

   // act
   let promise = service.getAll();

   // assert
   return promise.then((result) => {
     expect(result).to.have.lengthOf(0);
   });
 });

这样就完成了“无演讲者存在”的场景。一旦我们有了更好的数据变化想法,我们将重构服务器响应。

我们现在准备好进行演讲者列表场景了。和之前一样,我们首先从模拟服务测试中复制测试。从模拟服务测试中移除 arrange,并复制我们之前的测试中的 arrange。

在添加了无演讲者测试的 arrange 之后,我们得到了一个期望长度为 1 而不是 0 的消息。这是一个简单的修复,为了这个测试的目的,我们可以在响应的主体数组中简单地添加一个空对象。以下是测试通过后应该看起来像的样子:

describe('Speaker Listing', () => {
     it('returns speakers', () => {
       // arrange
       fetch.returns(new Promise((resolve, reject) => {
         let response = new Response();
         response.headers = new Headers({
           'Content-Type': 'application/json'
         });
         response.ok = true;
         response.status = 200;
         response.statusText = 'OK';
         response.body = JSON.stringify([{}]);

         resolve(response);
       }));

       // act
       let promise = service.getAll();

       // assert
       return promise.then((result) => {
         expect(result).to.have.lengthOf(1);
       });
     });
   });
 });

现在我们基本上两次使用了相同的 arrange,是时候重构我们的测试了。唯一真正改变的是主体。让我们提取一个okResponse函数来使用:

function okResponse(body) {
   return new Promise((resolve, reject) => {
     let response = new Response();
     response.headers = new Headers({
       'Content-Type': 'application/json'
     });
     response.ok = true;
     response.status = 200;
     response.statusText = 'OK';
     response.body = JSON.stringify(body);

     resolve(response);
   });
 }

我们已经将这个辅助函数放在了After Initialization describe的顶部。现在在每个测试中,用对这个函数的调用替换 arrange,传递特定于该测试的主体。

现在所有演讲者的获取功能都由测试覆盖了。让我们继续通过 ID 获取特定演讲者的测试。从模拟服务测试中复制getById的测试,并应用一个 skip 到 describes。现在,从最外层的 describe 中移除 skip。这应该启用存在测试,它应该通过。

下一个测试是当找不到演讲者时;从那个测试中移除 skip 会导致出现一个消息,表明我们没有返回一个 promise。让我们进入getById函数的主体,并使用 fetch 获取一个演讲者:

getById(id) {
   return fetch(`${this.baseUrl}/speakers/${id}`);
 }

给我们的函数添加 fetch 应该已经修复了错误,但实际上并没有。记住,我们正在模拟 fetch 的响应,如果我们不设置响应,那么 fetch 将不会返回任何内容。让我们配置模拟响应。在这种情况下,我们期望服务器返回 404 错误,所以让我们配置这个响应:

// arrange
 fetch.returns(new Promise((resolve, reject) => {
   let response = new Response();
   response.headers = new Headers({
     'Content-Type': 'application/json'
   });
   response.ok = false;
   response.status = 404;
   response.statusText = 'NOT FOUND';

   resolve(response);
 }));

这样我们的测试就通过了,但这并不是正确的理由。让我们在断言中添加一个then子句来证明这是一个假阳性:

// assert
 return promise}).then(() => {
   throw { type: 'Error not returned' };
 }).catch((error) => {
   expect(error.type).to.equal(errorTypes.SPEAKER_NOT_FOUND);
 });

现在我们的测试将失败,预期的'Error not returned'将等于'SPEAKER_NOT_FOUND'。为什么是这样?不应该是一个 404 导致 promise 的拒绝吗?对于 fetch 来说,唯一会导致 promise 拒绝的是网络连接错误。因此,当我们模拟服务器响应时我们没有拒绝。我们需要做的是在服务中检查那个条件,并在那一侧引起 promise 拒绝。最简单的方法是将 fetch 调用包裹在我们的自己的 promise 中。一旦包裹,我们就可以检查适当的条件并拒绝我们的 promise:

getById(id) {
   return new Promise((resolve, reject) => {
     fetch(`${this.baseUrl}/speakers/${id}`).then((response) => {
       if (!response.ok) {
         reject({
           type: errorTypes.SPEAKER_NOT_FOUND
         });
       }
     });
   });
 }

这应该就完成了这个测试。我们现在准备进行最后一个测试。在我们继续之前,让我们快速重构这个测试中的 arrange 部分,以缩短测试并使其对未来的读者更有意义。当我们这样做的时候,我们将重构现有的响应函数以减少重复并强制执行一些默认值:

function baseResponse() {
   let response = new Response();
   response.headers = new Headers({
     'Content-Type': 'application/json'
   });
   response.ok = true;
   response.status = 200;
   response.statusText = 'OK';

   return response;
 }

 function okResponse(body) {
   return new Promise((resolve, reject) => {
     let response = baseResponse();       
     response.body = JSON.stringify(body);

     resolve(response);
   });
 }

 function notFoundResponse() {
   return new Promise((resolve, reject) => {
     let response = baseResponse();
     response.ok = false;
     response.status = 404;
     response.statusText = 'NOT FOUND';

     resolve(response);
   })
 }

在测试中使用notFoundResponse函数,就像我们使用okResponse函数一样。接下来,我们将进行当前获取服务功能的最后一个测试,移除下一个 describe 中的 skip,我们将开始查看生成的错误并做出必要的更改以使测试通过。

在我们已经做了让模拟响应更容易的工作之后,这个最后的测试相当简单。我们需要获取调用返回一个带有演讲者作为主体的ok响应:

describe('Speaker Exists', () => {
   it('returns the speaker', () => {
     // arrange
     const speaker = {
       id: 'test-speaker'
     };
     fetch.returns(okResponse(speaker));

     // act
     let promise = service.getById('test-speaker');

     // assert
     return promise.then((speaker) => {
       expect(speaker).to.not.be.null;
       expect(speaker.id).to.equal('test-speaker');
     });
   });
 });

现在,我们遇到了超时错误。这是因为我们的服务实际上没有处理演讲者存在的情况。现在让我们添加这个功能:

getById(id) {
   return new Promise((resolve, reject) => {
     fetch(`${this.baseUrl}/speakers/${id}`).then((response) => {
       if (response.ok) {
         resolve(response.json());
       } else {
         reject({
           type: errorTypes.SPEAKER_NOT_FOUND
         });
       }
     });
   });
 }

现在我们所有的测试都通过了,我们已经验证了系统的所有预期行为。我们还可以做几件事情,一些开发者可能会选择去做。我们将讨论其中的一些,但不会提供示例。

应用程序配置

现在所有测试都通过了,但在应用程序可以使用之前,还有一些应用程序配置需要处理。

在服务工厂中,我们必须为运行中的应用程序设置一个用于获取服务的基 URL。这可以通过多种方式完成,具体方式由你决定。最简单但最不灵活的方式是将一个字符串值硬编码为构建服务的基 URL。然而,你也可以让它变得非常复杂,比如有一个动态类,根据应用程序和运行环境来设置值。再次,这个决定留给你。

端到端集成测试

本章最后要讨论的主题是端到端集成测试。这些测试涉及实际调用服务器并检查真实响应。

优势

那么,测试实际客户端服务器连接的好处是什么?最有价值的优势是,你知道你的应用程序将在部署环境中工作。有时应用程序会被部署但无法工作,因为网络或数据库连接配置不正确,这将对部署造成破坏。

此外,这还将帮助验证系统是否正常工作。在部署后,可以执行一系列烟雾测试,以确保部署成功。

损害

端到端测试通常由于以下两个原因之一而被跳过。第一个原因是它们很难编写。你需要进行很多额外的设置才能运行这些测试,包括一个与通常用于单元测试完全不同的测试运行器。如果不是不同的运行器,它们至少需要是单独的测试运行,而不是包含在你的正常单元测试中。

第二个问题是端到端测试很脆弱。系统中的任何更改都会导致这些测试失败。它们不像单元测试那样经常运行,因此,直到它们在生产环境中运行时,才会注意到代码已损坏。

由于这些原因,我们通常不会编写很多端到端测试,如果不是一个都不写的话。

你应该进行多少端到端测试?

如果你选择进行端到端测试,你希望尽可能少做。这些是最高级别的测试,应该是你系统中数量最少的测试类型。建议只编写与你的应用程序第三方连接一样多的测试,也就是说,每个你必须与之通信的后端服务器一个测试。此外,使用最简单、最基本的情况,这种情况下预计不会发生变化。

这样就完成了前端集成测试。还有一些事情可以做。我们将把它们留给你作为练习。你可能已经注意到,前端和后端在传递回和 forth 的模型上并不完全一致。作为练习,添加或删除并完善两个系统都使用的模型,以便它们在格式上达成一致。

另一个任务是设置获取服务的基 URL 并在本地运行两个应用程序以验证互操作性。

配置 API 项目

现在 React 项目已配置为调用真实 API,是时候将我们的注意力转向 .NET 解决方案了。为了验证一切是否正确连接,你需要编写一系列集成测试,以确保整个系统正常工作。

集成测试项目

在现有解决方案中创建一个新的 xUnit 项目,名为 SpeakerMeet.Api.IntegrationTest。这将是在其中创建 .NET 集成测试的地方。你可能希望根据你的偏好和/或团队编码标准将它们分离出来,但这可以稍后再说。现在,一个单独的集成测试项目就足够了。

对于我们的目的,我们将测试系统是否从 API 入口到数据库,然后再返回的功能。然而,最好从小测试单个集成点开始,然后逐步扩展。

从哪里开始?

你当然可以从创建一个调用 API 端点的测试开始。为了实现这一点,需要向控制器发出一个 HTTP 请求。然后控制器将调用业务层中的服务,该服务随后将调用仓库,最终向数据库发送命令。这感觉有很多移动部件。也许有一个更好的起点。

为了将问题分解成更小、更易于管理的部分,也许从测试应用程序的持久层开始测试是最好的。

验证仓库是否调用 DB 上下文

一个好的开始是验证系统是否完全集成;让我们首先测试仓库是否可以访问数据库。在集成测试项目中创建一个名为RepositoryTests的文件夹,并创建一个名为GetAll的新测试文件。这里将创建仓库GetAll方法的集成测试。

你可以创建一个测试来验证仓库可以被创建,如下所示:

[Fact]
public void ItExists()
{
  var options = new DbContextOptions<SpeakerMeetContext>();
  var context = new SpeakerMeetContext(options);

  var repository = new Repository.Repository<Speaker>(context);
}

然而,这并不能通过。如果你运行测试,你将收到以下错误:

System.InvalidOperationException: No database provider has been configured for this DbContext

这很容易解决,只需配置一个合适的提供程序。

内存数据库

对 SQL Server 进行测试既耗时又容易出错,可能还相当昂贵。建立数据库连接需要时间,记住,你希望你的测试套件能够快速运行。如果数据库被其他人使用,无论是开发环境中的,还是质量保证工程师等,这也可能是一个问题。你当然不希望对你的生产数据库运行集成测试。此外,对托管在云中的数据库(例如 AWS、Azure 等)进行测试可能会在带宽和处理方面产生成本。

幸运的是,配置一个使用 Entity Framework 的解决方案以使用InMemory数据库相当简单。

首先,安装一个用于InMemory数据库的NuGet包。

Microsoft.EntityFrameworkCore.InMemory

现在,修改你之前创建的测试,以便在内存中创建数据库上下文:

[Fact]
public void ItExists()
{
  var options = new DbContextOptionsBuilder<SpeakerMeetContext>()
      .UseInMemoryDatabase("SpeakerMeetInMemory")
      .Options;

  var context = new SpeakerMeetContext(options);

  var repository = new Repository.Repository<Speaker>(context);
}

测试现在应该通过,因为上下文现在是在内存中创建的。

接下来,创建一个测试来验证当调用GetAll方法时,会返回一个演讲者实体集合:

[Fact]
public void GivenSpeakersThenQueryableSpeakersReturned()
{
  using (var context = new SpeakerMeetContext(_options))
  {
    // Arrange
    var repository = new Repository.Repository<Speaker>(context);

    // Act
    var speakers = repository.GetAll();

    // Assert
    Assert.NotNull(speakers);
    Assert.IsAssignableFrom<IQueryable<Speaker>>(speakers);
  }
}

现在,将注意力转向仓库中的Get方法。创建一个新的测试方法来验证当找不到具有给定 ID 的演讲者时,会返回一个 null 的演讲者实体:

[Fact]
public void GivenSpeakerNotFoundThenSpeakerNull()
{
  using (var context = new SpeakerMeetContext(_options))
  {
    // Arrange
    var repository = new Repository.Repository<Speaker>(context);

    // Act
    var speaker = repository.Get(-1);

    // Assert
    Assert.Null(speaker);
  }
}

这应该立即通过。现在,创建一个测试来验证当存在具有提供的 ID 的演讲者时,会返回一个演讲者实体:

[Fact]
public void GivenSpeakerFoundThenSpeakerReturned()
{
  using (var context = new SpeakerMeetContext(_options))
  {
    // Arrange
    var repository = new Repository.Repository<Speaker>(context);

    // Act
    var speaker = repository.Get(1);

    // Assert
    Assert.NotNull(speaker);
    Assert.IsAssignableFrom<Speaker>(speaker);
  }
}

这个测试现在还不能通过。无论 ID 为 1 的演讲者是否存在于您的开发数据库中,InMemory数据库中的演讲者表目前是空的。向InMemory数据库添加数据非常简单。

InMemory数据库添加演讲者

为了测试仓库在查询数据库时是否会返回特定的Speaker实体,您首先必须将Speaker添加到数据库中。为了做到这一点,在您的测试文件中添加几行代码:

using (var context = new SpeakerMeetContext(_options))
{
  context.Speakers.Add(new Speaker { Id = 1, Name = "Test"... });
  context.SaveChanges();
}

随意添加尽可能多的演讲者,以及您认为必要的尽可能多的细节。现在,您的测试应该可以通过。可以创建更多测试,并且随着系统的功能复杂度增长,应该继续添加测试。大部分的逻辑应该在单元测试中已经测试过了,但验证整个系统是否正常工作同样重要。

验证服务是否通过仓库调用数据库

接下来,转向业务层,您应该验证每个服务是否可以通过仓库从InMemory数据库中检索数据。

首先,在集成测试项目中创建一个名为ServiceTests的新文件夹。在该文件夹中,创建一个名为SpeakerServiceTests的文件夹。这个文件夹是创建针对SpeakerService的特定测试的地方。

创建一个名为GetAll的新测试文件。添加一个测试方法来验证服务是否可以创建:

[Fact]
public void ItExists()
{
  var options = new DbContextOptionsBuilder<SpeakerMeetContext>()
      .UseInMemoryDatabase("SpeakerMeetInMemory")
      .Options;

  var context = new SpeakerMeetContext(options);

  var repository = new Repository<Speaker>(context);
  var gravatarService = new GravatarService();

  var speakerService = new SpeakerService(repository, gravatarService);
}

ContextFixture

这里有很多设置代码,以及从前面的测试中复制的大量代码。幸运的是,您可以使用所谓的测试固定装置

测试固定装置只是一些用于配置待测系统的代码。在我们的情况下,创建一个上下文固定装置来设置InMemory数据库。

创建一个名为ContextFixture的新类,其中将发生所有InMemory数据库的创建:

public class ContextFixture : IDisposable
{
  public SpeakerMeetContext Context { get; }

  public ContextFixture()
  {
    var options = new DbContextOptionsBuilder<SpeakerMeetContext>()
        .UseInMemoryDatabase("SpeakerMeetContext")
        .Options;

    Context = new SpeakerMeetContext(options);

    if (!Context.Speakers.Any())
    {
      Context.Speakers.Add(new Speaker {Id = 1, Name = "Test"...});
      Context.SaveChanges();
    }
  }

  public void Dispose()
  {
    Context.Dispose();
  }
}

现在,修改测试类以使用新的ContextFixture类:

[Collection("Service")]
[Trait("Category", "Integration")]
public class GetAll : IClassFixture<ContextFixture>
{
  private readonly IRepository<Speaker> _repository;
  private readonly IGravatarService _gravatarService;

  public GetAll(ContextFixture fixture)
  {
    _repository = new Repository<Speaker>(fixture.Context);
    _gravatarService = new GravatarService();
  }

  [Fact]
  public void ItExists()
  {
    var speakerService = new SpeakerService(_repository, _gravatarService);
  }
}

这要干净得多。现在,创建一个新的测试来确保当调用SpeakerServiceGetAll方法时,会返回一个SpeakerSummary对象的集合:

[Fact]
public void ItReturnsCollectionOfSpeakerSummary()
{
  // Arrange
  var speakerService = new SpeakerService(_repository, _gravatarService);

  // Act
  var speakers = speakerService.GetAll();

  // Assert
  Assert.NotNull(speakers);
  Assert.IsAssignableFrom<IEnumerable<SpeakerSummary>>(speakers);
}

接下来,为SpeakerServiceGet方法创建一个新的测试类。第一个测试应该验证当提供的 ID 不存在时,会抛出异常:

[Fact]
public void GivenSpeakerNotFoundThenSpeakerNotFoundException()
{
  // Arrange
  var speakerService = new SpeakerService(_repository, _gravatarService);

  // Act
  var exception = Record.Exception(() => speakerService.Get(-1));

  // Assert
  Assert.IsAssignableFrom<SpeakerNotFoundException>(exception);
}

您可以重用之前创建的ContextFixture

[Fact]
public void GivenSpeakerFoundThenSpeakerDetailReturned()
{
  // Arrange
  var speakerService = new SpeakerService(_repository, _gravatarService);

  // Act
  var speaker = speakerService.Get(1);

  // Assert
  Assert.NotNull(speaker);
  Assert.IsAssignableFrom<SpeakerDetail>(speaker);
}

验证对服务的 API 调用

现在,将您的注意力转向 Web API 控制器。如前一章所述,您可以简单地创建一个控制器的新实例并调用测试中的方法。然而,那样并不能测试整个系统。

用 HTTP 请求调用方法会更好。部署到 Web 服务器会非常耗时。

TestServer

ASP.NET Core 有配置测试目的主机的能力。从NuGet安装TestServer

Microsoft.AspNetCore.TestHost

这里有一些设置工作。首先,你将添加一个TestServer的实例。创建一个新的WebHostBuilder并使用 Web API 项目的现有Startup类。这将连接之前设置的依赖注入容器。现在,配置服务以设置一个新的InMemory数据库。

看看这里的测试以了解所需的设置:

[Fact]
public async void ItShouldCallGetSpeakers()
{
  // Arrange
  var server = new TestServer(new WebHostBuilder()
      .UseStartup<Startup>()
      .ConfigureServices(services =>
      {
        services.AddDbContext<SpeakerMeetContext>(o =>
          o.UseInMemoryDatabase("SpeakerMeetInMemory"));
      }));

  var client = server.CreateClient();

  // Act
  var response = await client.GetAsync("/api/speaker");

  // Assert
  Assert.NotNull(response);
}

ServerFixture

为了将设置从控制器测试中移除,再次使用测试固定装置。这次,创建一个名为ServerFixture的新类。这将是在控制器测试中设置的地方:

public class ServerFixture : IDisposable
{
  public TestServer Server { get; }
  public HttpClient Client { get; }

  public ServerFixture()
  {
    Server = new TestServer(new WebHostBuilder()
             .UseStartup<Startup>()
             .ConfigureServices(services =>
             {
               services.AddDbContext<SpeakerMeetContext>(o =>
                 o.UseInMemoryDatabase("SpeakerMeetContext"));
             }));

    if (Server.Host.Services.GetService(typeof(SpeakerMeetContext)) is SpeakerMeetContext context)
    {
      context.Speakers.Add(new Speaker {Id = 1, Name = "Test"...});
      context.SaveChanges();
    }

    Client = Server.CreateClient();
  }

  public void Dispose()
  {
    Server.Dispose();
    Client.Dispose();
  }
}

现在,回到之前的测试。修改测试类以使用ServerFixture

[Collection("Controllers")]
[Trait("Category", "Integration")]
public class GetAll : IClassFixture<ServerFixture>
{
  private readonly HttpClient _client;

  public GetAll(ServerFixture fixture)
  {
    _client = fixture.Client;
  }

  [Fact]
  public async void ItShouldCallGetSpeakers()
  {
    // Act
    var response = await _client.GetAsync("/api/speaker");

    Assert.NotNull(response);
  }
}

现在,通过创建一个新的测试来验证响应返回了一个OK状态码:

[Fact]
public async void ItShouldReturnSuccess()
{
  // Act
  var response = await _client.GetAsync("/api/speaker/");
  response.EnsureSuccessStatusCode();

  // Assert
  Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

最后,确保返回了正确的演讲者:

[Fact]
public async void ItShouldReturnSpeakers()
{
  // Act
  var response = await _client.GetAsync("/api/speaker");
  response.EnsureSuccessStatusCode();

  var responseString = await response.Content.ReadAsStringAsync();
  var speakers = JsonConvert.DeserializeObject<List<SpeakerSummary>>(responseString);

  // Assert
  Assert.Equal(1, speakers[0].Id);
}

记住,你想要确保你的测试套件干净且维护良好。为了稍微清理这个测试,你可能想要考虑创建一个ReadAsJsonAsync扩展。这可能看起来是这样的:

public static class Extensions
{
  public static async Task<T> ReadAsJsonAsync<T>(this HttpContent content)
  {
    var json = await content.ReadAsStringAsync();

    return JsonConvert.DeserializeObject<T>(json);
  }
}

现在,修改测试以使用新的扩展方法:

[Fact]
public async void ItShouldReturnSpeakers()
{
  // Act
  var response = await _client.GetAsync("/api/speaker");
  response.EnsureSuccessStatusCode();

  var speakers = await response.Content.ReadAsJsonAsync<List<SpeakerSummary>>();

  // Assert
  Assert.Equal(1, speakers[0].Id);
}

这样就更好了。现在这个扩展可以被反复使用,并且它的第一次使用已经在ItShouldReturnSpeakers测试中进行了文档记录。

现在,继续测试单个演讲者端点是否可以被调用。创建一个名为ItShouldCallGetSpeaker的测试,并确保返回了一个响应:

[Fact]
public async void ItShouldCallGetSpeaker()
{
  // Act
  var response = await _client.GetAsync("/api/speaker/-1");

  Assert.NotNull(response);
}

现在,测试如果给定的 ID 不存在,是否返回了正确的响应码:

[Fact]
public async void ItShouldReturnError()
{
  // Act
  var response = await _client.GetAsync("/api/speaker/-1");

  // Assert
  Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

现在,验证当提供的 ID 存在时,返回了一个OK状态码:

[Fact]
public async void ItShouldReturnSuccess()
{
  // Act
  var response = await _client.GetAsync("/api/speaker/1");
  response.EnsureSuccessStatusCode();

  // Assert
  Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

最后,确认返回的演讲者正是预期的那个。注意,这里可以使用ReadAsJsonAsync

[Fact]
public async void ItShouldReturnSpeaker()
{
  // Act
  var response = await _client.GetAsync("/api/speaker/1");
  response.EnsureSuccessStatusCode();

  var speakerSummary = await response.Content.ReadAsJsonAsync<SpeakerDetail>();

  // Assert
  Assert.Equal(1, speakerSummary.Id);
}

在前面的页面中,只有演讲者的GetGetAll方法被测试过。你可以自由地添加对Search方法的测试,以扩展你的集成测试套件。

摘要

你现在应该对集成测试、其优势和劣势有了牢固的理解。模拟 API 调用已被移除,并实现了真实的 API 服务。已经创建了集成测试,并确保应用程序的各个部分能够良好地协同工作。

变化是不可避免的,尤其是在软件开发中。在下一章中,我们将讨论如何处理需求的变化。这些变化可能包括新功能、修复缺陷或更改现有逻辑,通过 TDD 这些都可以轻松管理。

第十一章:需求变更

在任何应用程序的进展过程中,可能会添加新的和不同的需求。有时这些需求增强了应用程序的现有功能。在其他时候,这些新需求可能与现有功能冲突。当需求冲突时,重要的是要解决这些问题,以便构建适当的功能。

那么,你可能会看到哪些需求的变化?变化通常包括对业务规则的修改、新功能或增强,或者对系统中发现的错误或缺陷所需的修改。

随着时间的推移,通常需要修改现有的业务规则。这可能是对用户反馈的反应、来自业务的澄清,或者通过系统使用过程中发现的需求。当发现需要变化时,现有的应用程序将需要做出改变。一个全面的测试套件将确保在实施新更改后,系统的其余部分仍然按预期运行。首先,通过修改和/或创建新的测试来覆盖系统的新期望功能。

在软件开发中有一个常见的说法,软件永远不会完成;它只是被放弃。也就是说,如果一个应用程序要继续有用,它将通过新的开发继续增长和演变。如果没有添加新功能,那么很可能项目已经被放弃。如果一个应用程序要继续有用,那么你可以预期需要实现新功能。再次强调,从测试开始,添加新的测试来帮助指导任何和所有新功能的实现。

当发现错误并确定根本原因时,需要做出更改以解决问题。为了防止这个错误在未来再次出现,应该编写一个新的测试或一系列测试来覆盖任何可能导致错误行为的潜在场景。

在本章中,我们将了解:

  • 需求变更

  • 新功能

  • 处理缺陷

  • Speaker Meet 的变更

  • 过早优化

Hello World

回顾我们最早的例子之一,看看这个 Hello World 示例应用程序。记住,根据一天中的时间,用户会看到不同的消息。在中午之前,用户会被问候为早上好,中午之后,用户会收到下午好的问候。

需求变更

根据一天中的时间,用户会被问候为早上好或下午好。为了扩展功能并引入新功能,如果一天中的时间是晚上 6 点到午夜,让我们用晚上好来称呼用户。

晚上好

为了引入这个新功能,从测试开始。需要修改现有的测试,以及添加一个或多个新的测试来覆盖需求的变化。

修改提供给GivenAfternoon_ThenAfternoonMessageTheory数据,以便只包括中午到下午 6 点之间的测试。现在,创建一个新的测试方法,GivenEvening_ThenEveningMessage

[Theory]
[InlineData(19)]
[InlineData(20)]
[InlineData(21)]
[InlineData(22)]
[InlineData(23)]
public void GivenEvening_ThenEveningMessage(int hour)
{
  // Arrange  
  var eveningTime = new TestTimeManager();
  eveningTime.SetDateTime(new DateTime(2017, 7, 13, hour, 0, 0));
  var messageUtility = new MessageUtility(eveningTime);

  // Act
  var message = messageUtility.GetMessage();

  // Assert
  Assert.Equal("Good evening", message);
}

现在通过修改现有代码来使Theory通过:

public string GetMessage()
{
  if (_timeManager.Now.Hour < 12)
    return "Good morning";
  if (_timeManager.Now.Hour <= 18)
    return "Good afternoon";
  return "Good evening";
}

这确实是一个相当简单的例子。实现开始增长一个你可能满意或不满意的设计。你可以自由地尝试替代实现。你现在应该有足够的测试,让你感到安全地重构到一个你更喜欢的模式。如果你破坏了实现或发现了你引入的错误,为这个场景添加一个测试。

FizzBuzz

接下来,从第二章的 FizzBuzz 示例,设置.NET 测试环境,扩展这个代码 kata 的经典行为,并引入一些新行为。

新特性

已经向经典的 FizzBuzz kata 添加了一个新要求。新的要求是,当一个数字不能被 3 或 5 整除,且大于 1 时,应返回“未找到数字”的消息。这应该足够简单。再次从测试开始,并做出必要的修改。

未找到数字

要开始,需要一个新的测试方法来验证是否返回了“未找到数字”的消息:

[Fact]
public void GivenNonDivisibleGreaterThan1ThenNumberNotFound()
{
  // Arrange
  // Act
  var result = FizzBuzz(2);
  // Assert
  Assert.Equal("Number not found", result);
}

现在,通过修改现有代码来使测试通过:

private object FizzBuzz(int value)
{
  if (value % 15 == 0)
    return "FizzBuzz";
  if (value % 5 == 0)
    return "Buzz";
  if (value % 3 == 0)
    return "Fizz";
  if (value == 2)
    return "Number not found";
}

这涵盖了第一个实例。然而,这满足新的要求吗?创建一个Theory集来强制正确的解决方案:

[Theory]
[InlineData(2)]
[InlineData(4)]
[InlineData(7)]
[InlineData(8)]
public void GivenNonDivisibleGreaterThan1ThenNumberNotFound(int number)
{
  // Arrange
  // Act
  var result = FizzBuzz(number);
  // Assert
  Assert.Equal("Number not found", result);
}

正确地使测试通过。修改现有代码,以便返回所需的结果:

private object FizzBuzz(int value)
{
  if (value % 15 == 0)
    return "FizzBuzz";
  if (value % 5 == 0)
    return "Buzz";
  if (value % 3 == 0)
    return "Fizz";
  return value == 1 ? (object)value : "Number not found";
}

注意,在整个练习过程中,所有现有的测试都应该继续通过。如果你发现了一个错误,写一个新的测试来验证这个场景,并相应地纠正代码。

TODO 应用

TODO应用是我们早期的 TDD 示例之一。这个应用远未完成,并且我们已经从业务那里收到了新的要求,要求向应用中添加一个功能。

现在业务希望有完成 TODO 列表中任务的能力。这个特性是“当前迭代计划”的,是我们接下来要工作的下一个故事。

标记完成

对于“标记完成”的故事,我们被要求允许用户完成 TODO 列表中的任何任务。添加这个功能应该与这本书中的任何其他 TDD 练习类似。在阅读我们对这个问题的解决方案之前,试着独立完成这个任务。在你通过测试后,再回来查看这本书中的解决方案。

添加测试

ToDoApplicationTests文件中,我们添加了一个“剃羊毛”测试来强迫我们创建完成方法。这个测试也有助于定义方法的 API:

[Fact(Skip = "Yak shaving - no longer needed")]
public void CompleteTodoExists()
{
  // Arrange
  var todo = new TodoList();
  var item = new Todo();

  todo.AddTodo(item);

  // Act
  todo.Complete(item);
}

这导致我们在TodoList类中创建了一个方法存根。为了使这个测试通过,我们必须从生成的方法中移除未实现异常。创建方法后,我们添加了一个跳过到这个测试,类似于同一文件中之前的“剃羊毛”测试。

接下来,我们需要创建一个TodoListCompleteTests文件来存放完成方法的函数测试:

public class TodoListCompleteTests
{
  [Fact]
  public void ItRemovesAnItemFromTheList()
  {
    // Arrange
    var todo = new TodoList();
    var item = new Todo();

    todo.AddTodo(item);
    // Act
    todo.Complete(item);
    // Assert
    Assert.Equal(0, todo.Items.Count());
  }
}

在编写第一个测试并实现代码使其通过之后,我们很难再编写另一个失败的测试。因此,我们假设现在我们已经完成了。

生产代码

完成任务的测试代码相当简单,只需要一个单行方法:

public void Complete(Todo item)
{
  _items.Remove(item);
}

这就是我们需要的所有内容。我们现在已经准备好进行冲刺演示。

但不要从列表中删除!

在冲刺演示期间,我们的产品负责人询问任务完成后发生了什么。我们解释说,它被从这份列表中移除了。这并不好。产品负责人希望我们能提供关于未来任务的指标。她希望我们跟踪任务的完成情况,而不是删除它。

在与其他开发者讨论之后,我们决定任务将获得一个完成属性,并从列表中隐藏。为了实现这一点,我们不得不进行一些重构并添加新的测试。再次提醒,尝试自己完成这个练习,然后再看看我们的解决方案进行比较。

添加测试

这个更改需要相当多的新测试。然而,在我们能够创建新测试之前,我们必须首先将现有的完成测试重命名,以表示正确的功能。在TodoListCompleteTests文件中添加两个额外的测试,我们验证了项目被标记为完成,并且它没有被从 TODO 列表中删除:

public class TodoListCompleteTests
{
  [Fact]
  public void ItHidesAnItemFromTheList()
  {
    // Arrange
    var todo = new TodoList();
    var item = new Todo { Description = "Test Todo" };

    todo.AddTodo(item);

    // Act
    todo.Complete(item);

    // Assert
    Assert.Equal(0, todo.Items.Count());
  }

  [Fact]
  public void ItMarksAnItemComplete()
  {
    // Arrange
    var todo = new TodoList();
    var item = new Todo { Description = "Test Todo" };

    todo.AddTodo(item);

    // Act
    todo.Complete(item);

    // Assert
    Assert.True(item.IsComplete);
  }

  [Fact]
  public void ItShowsCompletedItems()
  {
    // Arrange
    var todo = new TodoList();
    var item = new Todo { Description = "Test Todo" };

    todo.ShowCompleted = true;
    todo.AddTodo(item);

    // Act
    todo.Complete(item);

    // Assert
    Assert.Equal(1, todo.Items.Count());
  }
}

为了添加ShowComplete,我们在ToDoApplicationTests文件中创建了一个用于完整性的“剃毛”测试:

[Fact(Skip = "Yak shaving - no longer needed")]
public void ShowCompletedExists()
{
  // Arrange
  var todo = new TodoList();

  // Act
  todo.ShowCompleted = true;
}

我们还必须在TodoModelTests文件中添加一个类似的测试:

[Fact]
public void ItHasIsComplete()
{
  // Arrange
  var todo = new Todo();

  // Act
  todo.IsComplete = true;
}

生产代码

对于如此小的代码库,新测试所需的变化相当显著。首先,我们在Todo模型中添加了一个IsComplete属性:

internal class Todo
{
  public bool IsComplete { get; set; }
  public string Description { get; set; }

  internal void Validate()
  {
    Description = Description ?? throw new DescriptionRequiredException();
  }
}

其余的更改影响TodoList类。添加了一个布尔属性来切换已完成项的可见性,修改了Complete方法,使其仅标记项目为完成,并在从列表检索的项目中添加了一个where子句:

internal class TodoList
{
  private readonly List<Todo> _items = new List<Todo>();

  public IEnumerable<Todo> Items => _items.Where(t => !t.IsComplete || ShowCompleted);

  public bool ShowCompleted { get; set; }

  public void AddTodo(Todo item)
  {
    item = item ?? throw new ArgumentNullException();
    item.Validate();
    _items.Add(item);
  }

  public void Complete(Todo item)
  {
    item.IsComplete = true;            
  }        
}

Speaker Meet 的更改

在任何应用程序中,变化都是不可避免的。由于新的业务规则、功能增强、缺陷的发现和修复等原因,需求会发生变化。当进行测试驱动开发时,变化尤其确定。幸运的是,通过 TDD 的过程,你的应用程序应该可以轻松且安全地进行修改。

如果一个系统是松散耦合的,那么理论上,对系统某一部分的更改应该对系统的其余部分影响很小或没有影响。一套全面的单元测试应该减轻对做出更改的恐惧。

不幸的是,这些测试只对它们定义的场景有效。如果未能编写足够的测试来覆盖某些场景或边缘情况,那么确实有可能出现错误进入生产环境。如果不采用 TDD 方法,或者更糟糕的是,根本不编写测试,那么你可能会发现错误很容易通过你的代码审查过程和 CI/CD 构建管道的所有检查。

查看 Speaker Meet 应用的新要求。

后端更改

随着 Speaker Meet 应用的进展,引入了新的要求。演讲者必须在可见于系统部分之前被批准。这包括演讲者的完整列表、返回演讲者详细信息以及通过搜索结果。

在这种情况下,一位开发者来帮忙实施。这位开发者不熟悉 TDD,并且没有编写测试来验证他的工作。新要求得到了实施,并提交了代码审查:

public Models.SpeakerDetail Get(int id)
{
  var speaker = _repository.Get(id);

  if (speaker == null || speaker.IsDeleted || speaker.IsActive)
  {
    throw new SpeakerNotFoundException(id);
  }

  var gravatar = _gravatarService.GetGravatar(speaker.EmailAddress);

  return new Models.SpeakerDetail
  {
    Id = speaker.Id,
    Name = speaker.Name,
    Location = speaker.Location,
    Gravatar = gravatar
  };
}

并且添加了对类的更改:

public class Speaker
{
  public int Id { get; set; }

  [Required]
  [StringLength(50)]
  public string Name { get; set; }

  [Required]
  [StringLength(50)]
  public string Location { get; set; }

  [Required]
  [StringLength(255)]
  public string EmailAddress { get; set; }

  public bool IsDeleted { get; set; }

  public bool IsActive { get; set; }
}

你能发现问题吗?

代码被审查,并留下了评论。然而,评论被误解了(或者只是简单地被忽略了),代码被提交、合并并通过了部署流程。当然是一个故障,但这种情况有时会发生。

CI 服务器运行了测试套件。现有的测试通过了。由于没有现有的场景会捕捉到这个错误,所以错误没有被发现。由于没有创建新的测试,所以没有测试失败。CD 过程运行,代码进入了生产环境。

那么可以添加什么测试来确保正确代码的实现?在处理错误时,通常最好的做法是简单地编写验证错误行为的测试。在这种情况下,我们希望抛出一个错误。所以下面的测试应该断言抛出了正确的错误:

[Fact]
public void GivenSpeakerIsNotActiveThenSpeakerNotFoundException()
{
  // Arrange
  var expectedSpeaker = SpeakerFactory.Create(_fakeRepository);
  expectedSpeaker.IsActive = false;
  var service = new SpeakerService(_fakeRepository, _fakeGravatarService);
  // Act
  var exception = Record.Exception(() => service.Get(expectedSpeaker.Id));
  // Assert
  Assert.IsAssignableFrom<SpeakerNotFoundException>(exception);
}

通过修改服务来使这个新测试通过:

public Models.SpeakerDetail Get(int id)
{
  var speaker = _repository.Get(id);

  if (speaker == null || speaker.IsDeleted || !speaker.IsActive)
  {
    throw new SpeakerNotFoundException(id);
  }

  var gravatar = _gravatarService.GetGravatar(speaker.EmailAddress);

  return new Models.SpeakerDetail
  {
    Id = speaker.Id,
    Name = speaker.Name,
    Location = speaker.Location,
    Gravatar = gravatar
  };
}

然而,随着这个变化,现在将有一系列现有的测试会失败。这是因为IsActive属性的默认值是false

为了快速让这些测试通过,你可以做些类似的事情:

public bool IsActive { get; set; } = true;

这可能会引入意外的结果,所以务必创建一些保护测试来验证正确性。

这解释了为什么这个错误最初没有被捕捉到。IsActive属性被添加到数据库中,默认值为true。直到新演讲者被添加到数据库中,IsActive列的值为false时,错误才被发现。一旦发现了不正确的行为,缺陷就很容易被识别和修复。

前端更改

从概念或方法的角度来看,前端更改没有区别。你需要编写适当的测试来确保应用程序期望的行为,然后编写生产代码以使测试通过。

尽管如此,让我们以一个快速示例为例,为我们在前端代码中添加一个新功能。

按客户端评分排序

我们将要添加的功能是按评分对演讲者进行排序。在之前的章节中,评分并未讨论过,甚至没有强制执行,因此需要对迄今为止构建的模型进行修改,以包含评分。当然,如果你还没有按照 C#代码定义完成整个模型,那就需要这样做。

就像本章前面的例子一样,尝试自己添加这种行为,然后看看我们的后续解决方案。

speakerReducer.spec.js文件中,我们为默认按排名排序演讲者添加了一个单一测试。该测试可以添加到演讲者还原器的 describe 块中:

it('sorts speakers by rank', () => {
   // Arrange
   const initialState = [];
   const speaker1 = { id: 'test-speaker-1', firstName: 'Test 1', lastName: 'Speaker', rank: 1};
   const speaker2 = { id: 'test-speaker-2', firstName: 'Test 2', lastName: 'Speaker', rank: 2};
   const action = actions.getSpeakersSuccess([speaker1, speaker2]);

   // Act
   const newState = speakersReducer(initialState, action);

   // Assert
   expect(newState).to.have.lengthOf(2);
   expect(newState[0]).to.deep.equal(speaker2);
 });

使这个测试通过的相关代码位于speakerReducer.js文件中:

export function speakersReducer(state = [], action) {
   switch(action.type) {
     case types.GET_SPEAKERS_SUCCESS:
       return action.speakers.sort((a, b) => {
         return b.rank > a.rank;
       });
     default:
       return state;
   }
 }

那么,接下来该做什么呢?

今后,实施任何必要的变更都应该很容易。这可能包括新功能、需求变更或发现的缺陷。这并不是说应用程序已经完成或没有错误,但你应该对应用程序以现有测试套件所考虑的方式运行有一定的信心。

过早优化

为了澄清,我们将优化定义为任何使代码变得模糊、使其更难以理解或限制了测试要求之外的可能性的事情。过早优化是指出于任何非要求原因进行的优化。

通常,优化工作是以性能为由进行的。在进行这些代码修改之前,应该存在一个明确说明需要变更的要求。

即使是通过测试驱动开发(Test-Driven Development)的实践,也有可能把自己逼入死角。在重构或设计下一个测试的过程中,有时可能会一次性解决太多问题,或者重构得过多。

总要记住,在 TDD 中,我们希望将问题分解成尽可能小的步骤。此外,如果解决方案超过一行或两行,不要在第一个测试中就追求解决方案。同时,即使是小解决方案,如果解决方案计算或算法密集,也应该分解,即使最终的解决方案只是一行生产代码。

警惕过早优化。

根据 Kent Beck 的说法,重构是去除重复的过程。记住,在重构测试时。仅通过去除重复,我们可以通过重构避免过早优化。有时重构解决方案并显著减少代码量,或者甚至使用一些花哨的新语言特性或 Linq 表达式来使测试通过,这是完全可能的,甚至在某些情况下是有吸引力的。但这些解决方案从长远来看是可行的,但在测试仍在构建的过程中,这些隐藏的优化可能会让你和你的测试迅速偏离轨道。

摘要

你现在可以看到,需求的变更、新的功能请求或缺陷可能需要应用程序进行更改。通过测试驱动开发(TDD)和一套全面的单元测试,这些更改可以安全且容易地进行。

在第十二章,“遗留问题”,我们将讨论如何处理可能没有考虑到测试的遗留应用程序。

第十二章:历史代码问题

本章全部关于历史代码。如果你从未处理过历史代码,那么你很幸运,并且知道它终将到来。有些人可能永远被困在维护开发中。你的生活就是历史代码。无论情况如何,本章都是关于处理历史代码的。我们希望要么防止历史代码的产生,要么将其反击到它起源的深处。

在本章中,我们讨论:

  • 什么因素使代码成为历史代码

  • 历史代码可能引发的问题

  • 历史代码如何阻碍测试

  • 我们可以采取哪些措施来应对和反击历史代码问题

什么是历史代码?

你们中的大多数人可能都不得不处理过令人讨厌的历史代码项目。在那个项目中工作并不有趣;代码一团糟,你想要找到编写它的人,并想知道他们在编写代码时在想什么。

在你的职业生涯中,你曾经或将会成为别人眼中的那个人。我们都会编写出自己以后不会感到自豪的代码。但为什么代码会变得如此糟糕?一个项目何时会变成历史代码?最后,我们可以做些什么来防止这种情况发生?

为什么代码会变坏?

简而言之,代码变坏是因为我们害怕改变它。为什么代码的不改变会导致它变坏?你希望,当代码被编写时,它是当时开发者能够编写出的最好的代码。所以,那应该是一段好的代码,对吧?这是一个复杂的问题,但暂时假设,当代码最初被编写时,它是一段值得骄傲的代码。但这仍然提出了一个问题,它是如何变坏的?

答案就在你的面前。你之所以看到这段代码,是因为它需要改变。很可能,你不是第一个需要修改这段代码的人。所以,这并不是一个开发者尽力编写好代码所编写的代码。这段代码是由许多开发者编写的。尽管如此,每个开发者都应该尽力编写好代码。那么,这段代码又是如何变坏的?

正是因为我们害怕改变代码,所以才会产生恐惧。当我们不得不改变代码时,我们通常尽量只做最小的改动,以使所需的更新能够正常工作。毕竟,我们不想因为重构了整个代码而强迫自己或 QA 团队进行全面回归测试,对吧?因此,我们修改代码;我们改变它预期的工作方式。但我们不能改变其结构,也不能修改最初编写代码的开发者的决策。

随着时间的推移,进行这些小的改动并害怕修改原始开发者的结构和架构决策,会导致严重的代码退化。很快,代码将包含大量的条件语句和方法,这些语句和方法已经无法在页面上显示。包含这些代码的类将增长到包含数十个方法,而文件长度也将达到数千行。

一个项目何时会变成历史代码?

这是一个很多人以很多不同方式回答的问题。通常,当一个应用程序不再有人愿意对其进行工作时,它就变成了遗留代码。

在一开始,应用程序是按照一个小而明确的目的来构建的。随着时间的推移,系统的范围和广度可能会超出其原始意图。当任何对应用程序的更改导致开发者与应用程序的设计背道而驰时,它将引起摩擦。

如前所述,应用程序的设计并不是因为开发者担心应用程序会崩溃而简单地改变。因此,越来越多的冗余代码被添加到系统中。好吧,那么这需要多长时间?我们何时停止将新的修改硬塞到现有应用程序中,而只是重新编写它?

说实话,冗余代码是从原始开发者第一次编写应用程序时就开始添加的。当你开始开发一个新应用程序,或者甚至在现有应用程序中开发一个新功能时,你开始有一个初步的设计在心中。每个人都是这样做的。一些开发者会在白板上绘制设计或制作完整的UML统一建模语言)图。其他开发者只是有一个在头脑中的想法来指导决策。无论如何,当你坐下来开发应用程序时,你都有一个想要的设计。

你发现设计问题并开始修改它需要多长时间?你可能写了一行代码就不得不改变你的设计,或者你可能完成了 75%的工作才发现了问题。这很大程度上取决于你正在解决的问题的复杂性和你的计划的详细程度。无论你的计划多么周密,你都会发现一个问题,并在第一次质量审查之前开始改变你的设计。

你一做出改变,就是在添加冗余代码,所以几乎所有的时候,你都在一个没有为强制放入其中的代码而设计的系统中工作。换句话说,即使你正在开发一个新应用程序,你下一次工作时可能也会编写遗留代码。

在软件中,冗余代码是指任何不必要的或过于复杂的代码。

我们能做些什么来防止遗留代码的退化?

真的必须做些什么来阻止这种退化,对吧?考虑到这本书的主题,答案可能很容易预测。但让我用迈克尔·费瑟斯关于遗留代码定义的引言来回答:

对我来说,遗留代码就是没有测试的代码。

  • 迈克尔·费瑟斯,《与遗留代码有效工作》

正如我们在前面的章节中讨论的那样,测试允许你重构。改变代码结构的能力正是可以防止遗留代码的腐烂和退化。

当测试可以防止遗留代码的形成时,请注意,它们本身并不能防止遗留问题的出现。这需要团队中每个人的奉献精神,理解将冗余引入系统是一种负面行为,必须避免。如果你感觉自己正在与系统的设计作对,那么将应用程序重构为适合今天需求且足够灵活以适应明天需求的设计就是你的责任。

使系统灵活并不像你想象的那么困难。遵循 SOLID 原则(在第三章中讨论,设置 JavaScript 环境),将有助于产生可维护和灵活的系统。即使是一个灵活的系统,也需要纪律和决心来维持识别和修复应用程序中摩擦的标准。

寻找这种摩擦的过程可以被认为是PDD痛苦驱动开发)。这个概念意味着做最简单的事情来解决你现有的问题,并积极识别在将来对应用程序进行修改时出现的任何摩擦。

PDD 可以应用于任何系统,包括应用程序、你的团队和你的个人生活。遵循这个策略,你将痴迷于消除所有事物的摩擦,可能会有些过分。因此,重要的是要记住,你可能是在寻找这种摩擦的唯一一个人,而其他人可能对自身造成的痛苦一无所知。此外,记住人们通常不喜欢有人指出他们的无知。

由遗留代码引起的典型问题

我们害怕处理遗留代码是有原因的。但是,我们在处理遗留代码时害怕的是什么?不是代码本身;代码不会伤害我们。相反,我们害怕的是听到我们引入了错误。这是开发者最害怕听到的词。错误意味着我们失败了,我们不得不再次处理遗留代码。

探索在处理遗留代码时可能遇到的问题类型,我们发现有几个。首先,因为我们不了解代码,对某一部分的更改可能会在应用程序的另一个部分产生意外的副作用。另一个问题是代码可能过度优化,或者是由试图在编写时显得聪明的人编写的。最后,紧密耦合可能会使更新代码变得困难。

意外的副作用

在推动应用程序走向遗留领域的所有变化中,通常应用程序中的方法或函数会在意想不到的地方被使用,远离你正在更改的代码。

有两个主要的 SOLID 原则违反导致了这个问题,而且同样的两个原则可以帮助你避免未来发生。第一个是OCP开放封闭原则),第二个是LSP里氏替换原则)。

开放封闭原则与遗留代码

如前所述,开放封闭原则指出代码应该易于扩展但不易于修改。这个原则旨在防止遗留代码的问题。

如果你被要求进行的修改将改变应用程序特定部分的行为,那么尝试克隆相关的方法并修改克隆。然后,需要变更的应用程序部分可以调用克隆,这样就可以防止变更影响除了你打算影响的部分以外的任何应用程序部分。

在我们确定我们刚刚避免的代码没有在其他地方被应用程序使用之前,我们不能删除它。最终,一旦我们确定旧代码确实是孤立的并且没有被使用,我们希望清理并删除未使用的方法,以保持一个稍微少一些冗余的代码库。

另一方面,如果变更是为了修复一个错误,那么修复过程就稍微复杂一些。你必须首先确定这个错误是否应该在代码可能被使用的所有地方修复,或者这个错误是否仅与应用程序的特定部分相关。如果有疑问,可以回退到克隆方法,只影响应用程序的有意部分。

Liskov 替换原则和遗留代码

如何确定变更应该影响整个应用程序还是仅仅影响一个部分呢?一种方法就是采用 LSP(里氏替换原则)。简单来说,LSP 表示一个类应该做它听起来应该做的事情。任何会改变这种行为的变更都应该是一个不同的依赖。

也就是说,任何改变行为的变更可能应该是一个新的方法或一个新的类,其中包含适当的方法。这将防止在应用程序的其他部分产生意外的副作用,并保持你的代码更干净。

过度优化

人们常说,过早的优化是件坏事。那么,优化究竟是什么呢?一般来说,优化就是减少从 A 点到 B 点的步骤数量。在计算机程序中,这意味着减少计算结果所需的循环次数。

优化代码的一个不幸副作用是代码通常变得难以阅读和理解。优化往往会以这种方式使代码变得晦涩难懂,以至于只有编写它的人才能理解它,而且经过一段时间,他们可能也无法理解它了。

事实是,难以理解的代码是难以更改的代码。这就是为什么在需要之前发生的优化是坏事的原因。

那么,何时需要优化呢?当明确当前实现未来无法在合理的时间内满足系统需求时,就需要进行优化。

合理的时间框架取决于所需优化的复杂性和业务的速度。当业务快速增长时,需求将沿着相同的曲线增长。

一个行动较慢的公司可能需要几个月的时间来规划和准备,才能将工作分配给开发者。在这种情况下,提前几个月规划优化是合理的。重要的是要监控应用程序的性能,以便预测这些需求。

过于聪明的代码

大多数开发者开始编写代码是因为他们喜欢这样做。很少能找到一个进入这个领域仅仅是因为他们听说可以赚很多钱的开发者。一直为公司编写同样的无聊代码可能会导致开发者偶尔想要找点乐趣。

当开发者感到无聊时,他们会想出有趣且往往过于复杂的解决方案,而这些解决方案实际上并不需要。有时,开发者会想出他们能想到的最聪明的解决方案来解决问题。

聪明解决方案的问题是,为了修复一个问题,你必须比修复它的人更有技能。所以,如果你写了你能写的最聪明的代码,那么你就不再有资格调试代码了,你带来的,以及每个人的,进步都会停滞。

与第三方软件的紧密耦合

每个人都会使用一些第三方插件或库。在软件社区中,不可避免地,你将不得不依赖他人的代码。当你使用那段代码时,你不知道的是其质量、稳定性以及满足你未来需求的能力。

考虑到这一点,直接依赖第三方提供的类和接口是个坏主意。相反,使用六边形架构,也称为端口和适配器。对于任何使用 C#的开发者来说,这包括抽象.NET 框架。

任何你和你的团队没有编写的代码都应该被抽象化,以保护你的代码免受潜在的外部变化的影响。这包括在公司内部由不同团队编写的代码。如果它不在你的控制之下,就将其放在抽象层后面。首选的抽象方式是一个或多个提供所需功能的接口。

阻碍添加测试的问题

截止日期很紧张。范围不断变化。我们根本没有时间写测试。把功能推出门更重要。我们都有过这样的经历。无论情况如何,有时你会发现自己在从事一个没有考虑到测试的项目。

总是觉得时间不够用来做正确的事,但总有时间来重做。

那么,你可能会遇到哪些问题,阻止你向遗留应用程序添加测试?

当一个系统没有考虑到测试时,在稍后添加测试可能会非常困难。具有具体依赖和紧密耦合的类会使软件应用程序难以测试。像大型类和函数、Demeter 法则违反、全局状态和静态方法等问题也会使系统变得非常难以测试。

就像从摇摇晃晃的基础开始建造房屋一样,不可测试的代码会滋生出更多不可测试的代码。除非系统的某些部分可以从应用程序的其他部分中解耦,否则不可测试的趋势很可能会持续下去,而且通常就是这样。

直接依赖框架和第三方代码

如前一章所述,对框架和第三方代码的依赖会导致紧密耦合的系统。例如,每次在 C#中调用new运算符时,都会对该特定类产生直接依赖。我们希望尽可能减少这些依赖。

记住,甚至框架依赖也应该避免,或者至少尽可能抽象化。回想一下DateTime的例子,在示例应用程序中,我们能够为测试目的提供自己的DateTime值。

在类或文件顶部出现的任何usingimport语句都应该仔细考虑,并在可能的情况下避免。相反,确保你的代码依赖于一个定义直接在你控制下的接口。这样,你可以最小化耦合,并在你自己的类和方法中隔离功能。这将帮助你编写更干净、更易于测试的代码。

德米特法则

德米特法则在其最简单形式中表明,每个单元应该只对其他单元有有限的知识:只有与当前单元“紧密”相关的单元。进一步来说,每个单元应该只与其朋友交流;不要与陌生人交流。简单来说,只与你的直接朋友交流

当一个类或函数了解其直接控制之外的某个事物的内部工作时,那么那里就存在一些紧密耦合。为了测试一个违反德米特法则的方法,所需的设置量通常相当大。为了测试一个违反德米特法则的类的方法,你必须设置另一个类或方法,或者提供一个合理的模拟实现,以便有效地进行测试。

记住,保持你的测试方法小巧灵活,这样它们就能快速运行,并且易于理解。如果你遵循这个规则,你的生产代码可能也会同样简单且易于遵循。从长远来看,这将是有益的,因为将来维护起来会更容易。

构造函数中的工作

当创建一个具有构造函数逻辑的新类实例时,通常很难测试该类。如果由于某种原因,你需要设置一个需要不同值或行为而不是构造函数中设置的测试场景,那么进行测试将会非常困难。最好是尽量减少构造函数中的工作,并提取辅助方法或某些其他更容易测试和在其他地方更好地实现的情况。

请记住,特定的模式可能证明是设置特定类或函数的更好替代方案。你应该熟悉常见的软件模式以及如何最好地实现它们。这将帮助你通过解决其他人之前已经解决的问题来增长应用程序。通过利用已知的软件模式,你可以更容易地在系统内的代码中传达你的意图。

例如,构建器模式可能被用来构建具有适当值的对象,否则这些值将添加到构造函数中。

以以下Car类的例子为例:

public Car(string make, string model, int doors)
{
  Make = make;
  Model = model;
  Doors = doors;
}

你可以轻松地编写一个构建器类来构建特定类型的汽车,例如ToyotaCamryBuilderFordMustangBuilder。创建丰田凯美瑞或福特野马的新实例将非常容易、简单和干净。更不用说,这将非常容易进行测试。

全局状态

全局状态容易受到应用程序远处部分的副作用的影响。这些副作用将改变你运行代码的结果。近年来,函数式编程已经流行起来,因为其一个原则是减少副作用,因为它们可能导致系统中的不可预测和不受欢迎的行为。相反,你应该努力将你的代码分解成所谓的纯函数。纯函数接受输入并产生输出。对于任何给定的输入,输出总是相同的。

静态方法

静态方法本身并不坏,但它们确实暗示了责任错位的代码异味。静态任何事物都告诉你,你把代码放在了错误的地方。它不与作用域内其他代码共享任何共同点,可能应该被移除并放在与其朋友在一起的地方。

大类和函数

类的大小真的重要吗?拥有一个大方法或函数有什么问题?大类和函数通常意味着复杂性。记住 SOLID 原则以及每个字母代表的意义。一个大类或方法很可能会违反一个或多个原则。

我们希望我们的类和函数小而只有一个改变的理由(单一职责原则)。一个大类很可能会隐藏可以并且应该分解成两个或更多独立且不同的类的逻辑。一个大方法或函数同样经常隐藏两个或更多方法。寻找保持你的方法简单的方法,并留意可能的逻辑边界,以便将较小的辅助程序、类和实用工具分离出来。

类和函数应该逻辑上划分和分组。系统的目的应该通过与应用程序相关的文件名称和分组容易理解。系统的结构应该是简单且对负责增强和维护应用程序的人来说有意义的。

处理遗留问题

我们一直在讨论遗留代码的所有问题。现在是我们解决这些问题的时候了。我们必须做的第一件事是使目标遗留代码恢复理智,然后我们可以开始测试,最终修复代码,让它从死亡中复活。

安全的重构

术语重构经常被错误地使用。当你重构时,你只是在改变代码的结构。如果代码的逻辑和/或签名发生变化,那么这不算是重构。这是一个变化;很可能是破坏性的变化。

如果我在更改代码的结构(重构),那么我永远不会同时更改其行为。如果我在更改调用某些逻辑的接口时,我永远不会同时更改逻辑本身。

– Kent Beck

安全的重构是指保证不会意外破坏代码的重构。其他不是有意改变代码行为但可能意外改变行为的更改被认为是不可安全的重构。这些通常涉及对代码的私有区域的更改,这些区域没有直接暴露给应用程序的消费者。

将值转换为变量

可以做的第一件简单的事情之一是将任何硬编码的值提取出来,由变量来表示。拥有变量可以更快、更一致地更新。这也有助于传达意图。

在创建变量时,确保变量的名称足够描述性,以适应变量的作用域。作用域短的变量可以有短名称。另一方面,作用域长的变量必须有更长、更描述性的名称。变量与其使用地点的距离越远,就需要越多的描述性,以便不会丢失它所代表的上下文。

检查变量的作用域,确保它们的作用域不大于必要的范围。还要检查那些本应具有更大作用域但被传递给私有方法而不是作为类成员的变量。

目前不建议更新依赖于可能移动到类作用域的变量的私有和受保护的方法。相反,注意它们,并在添加了测试之后移动它们。

提取方法

与遗留代码一起工作通常涉及处理非常长的代码。任何超过二十行的代码都可以被认为是长的。最好将方法保持得尽可能小,甚至只有几行。

一个大的方法可能意味着代码违反了单一职责原则。需要做的事情是找到方法中的缝隙。可以通过注释方法的不同部分来找到缝隙。一旦你注释了部分,你就已经识别出了缝隙。

缝隙

在代码中,这是两段业务逻辑相遇的位置。通常,你可能将私有方法被公共方法调用的位置称为公共方法中的“接缝”。代码在那个位置已经被缝合在一起。在这种情况下,没有私有方法,所以我们正在确定我们想要接缝的位置。

那些接缝中的每一个可能都是一个可以提取的低级方法。在大多数编辑器和 IDE 中,突出显示你想要提取的代码,然后使用通过右键单击、上下文菜单或菜单栏提供的提取方法重构功能。

提取一个类

就像方法一样,有时一个大的方法实际上应该是一个类。在提取方法的过程中,如果你提取了三个或更多的方法,那么你很可能发现了一个需要提取的类。

提取类与提取方法类似,并且很可能被你的编辑器或 IDE 支持。分组并突出显示你想要提取的代码,然后使用提取类菜单选项。

如果你的编辑器不支持提取类,并不意味着一切都完了。相反,突出显示并剪切出你想要放入类中的所有提取方法。创建一个新的类文件,并将这些方法粘贴到新类中。最后,用新类和方法的实例化和调用替换原始方法中对这些方法的调用。

抽象第三方库和框架代码

现在我们已经抽象了变量、方法和类,是时候抽象第三方库、框架代码以及我们刚刚创建的类了。

首先,让我们从框架细节开始。像DateTimeRandomConsole这样的东西最好隐藏在你设计的适合你应用程序需求的类后面。这样做有几个原因;最重要的是,将这些放入它们自己的类中将允许进行测试。如果不将这些抽象为单独的类,几乎不可能使用像DateTime这样的会自行更改值的对象进行测试。

接下来是第三方库。任何代码调用第三方库以创建新类的位置,你都需要将其抽象为一个新类,专门用于利用那个第三方库。目前,用实例化你的类来替换实例化第三方库的调用。

最后,我们现在可以处理代码中留下的对new的调用。代码中任何调用new的地方都需要用依赖注入来替换。这将允许进行测试,并使代码在未来更加干净和灵活。

为了在不修改类签名的情况下创建依赖注入,我们将使用一种称为“穷人依赖注入”的模式,也称为属性注入。下面是一个 C#中属性注入的示例。几乎不需要修改就可以在 JavaScript 中完成相同的操作:

public class Example
{
  private Injected _value;
  public Injected Value
  {
    get => _value = _value ?? new Injected();
    set => _value = value;
  }
}

使用这种模式,可以在需要时让类懒加载其依赖项。也可以为测试或其他目的设置依赖项的值。尽管在这个模式的快速示例中没有展示,但最好让属性和后置变量是接口类型。这将使注入其他值更容易。

早期测试

如果一个大型且复杂的应用程序没有被正确分割,那么知道如何以及在哪里开始编写测试可能是一项相当艰巨的任务。通过在测试遗留系统方面进行一些实践,这将会变得更容易。

在遗留系统中何时编写测试可以很容易地回答:“当它有意义时。”向任何企业主推销这个想法,即应该花费时间(和金钱)回头编写测试来覆盖遗留系统的现有功能,可能会很困难。在添加增强功能或解决缺陷时添加测试要合理得多。当你正在代码中工作,并且立即有关于你希望测试的功能的上下文时,这就是开始测试遗留应用程序部分的最佳时机。

那么,你如何开始为遗留系统编写测试呢?隔离那些可以轻松测试的小函数。根据需要提取方法和更小的类。确保功能没有被修改,但代码只是被重新组织以方便测试性。

可能需要将私有方法更改为受保护的,以便可以进行测试。改变方法的范围确实使其更易于访问,并可能减少有效抽象,但如果这种改变是为了帮助测试,那么这种权衡几乎总是值得的。你也可能考虑,使私有方法公开可能更适合不同的实用程序或辅助类,因此可以保持公开。这取决于具体的方法,但确实有可用的选项可以帮助使遗留系统更具可测试性。

黄金标准测试

黄金标准测试,或称特征测试,是那些仅定义方法预期功能的测试。如果你要向遗留系统添加测试,你可能会首先编写黄金标准测试来定义系统的“快乐路径”。你可能运行应用程序以确定给定方法基于给定输入返回的值,然后编写一个测试来复制这些结果。

使用黄金标准测试是因为它们提供了捷径。通常,为了测试遗留代码,你可能需要抽象第三方库并设置某种类型的依赖注入。你可能还必须对代码进行重大重构,才能达到可以测试任何东西的程度。通过使用黄金标准测试,大部分这项工作可以暂时绕过。唯一需要的抽象是屏幕输出、日期/时间和随机。几乎所有其他东西都可以直接使用。

这将为测试套件提供一个基线,并有助于确保预期的功能在未来的重构或修改中不会发生变化。黄金标准测试并不验证正确性;它们只是确认系统做了系统应该做的事情。

作为基础,黄金标准测试提供了一定程度的安慰,以防止任何不希望的行为变化。这些可能不足以提供足够的代码覆盖率,并且应该添加额外的测试来覆盖边缘情况和系统中的替代路径。

随着测试套件的成长和覆盖率的提高和完整性,可能证明删除原始黄金标准测试是明智的。再次强调,你希望你的测试套件能够快速执行,以便始终运行并经常运行。删除可能多余的测试将有助于在运行测试时最小化反馈周期。换句话说,如果你知道你破坏了某些东西,测试完成得更快,你更有可能运行测试。

测试所有潜在结果

测试一个方法的所有可能值并不一定很重要。例如,在黄金标准测试的例子中,你当然不希望用所有可能的值运行应用程序,以便为每种可能性编写测试。测试执行路径的每个路径要重要得多。

如果一个方法足够小,其潜在结果范围有限,那么编写一些测试来覆盖所有潜在场景应该是相当简单的。以下方法为例:

public int GetPercent(int current, int maximum)
{
  if (maximum == 0)
  {
    throw new DivideByZeroException();
  }

  return (int) ((double) current / maximum * 100);
}

通过此方法有哪些潜在路径?你可能编写哪些测试来确保充分的覆盖?

首先,你可能考虑编写一个测试,假设最大值输入参数等于0。这应该涵盖此场景中的DivideByZeroException

接下来,你可能编写一个测试,其中当前参数为0,确保此方法的结果始终为零,假设最大值非零。

最后,你可能想要编写一个或多个测试来验证上述算法确实根据输入正确地计算百分比。

在这一点上,你可能想添加测试来检查负值或检查 C#正在进行的舍入,但请记住,我们正在处理遗留代码,并且从业务的角度来看,这段代码是按原样工作的。你没有关于产生此代码的业务需求的记录,因此测试超过代码告诉你的内容是不必要的,甚至可能是不负责任的。所以,如果你认为代码在它没有覆盖某些业务标准或可能产生不正确值方面有缺陷,与你的业务讨论这些问题,并共同做出决定。任何对代码的更改都必须通过错误或新工作来实现。

前进

一旦遗留系统已经足够重构,并且添加了一个全面的测试套件,你就可以开始将应用程序视为非遗留、当前或当代系统。现在,添加新功能和平息任何新发现的缺陷应该变得非常简单。从这一点开始,任何新请求的功能都应该能够轻松添加,并且有信心其他系统部分不会受到负面影响。

遗留应用程序不再被视为遗留。有了全面的测试套件,你现在可以安全地以测试驱动开发的方式继续前进,并在添加每个新功能时编写测试。记住,要像生产系统中的任何部分一样保持你的测试干净和重构良好。

以上面的GetPercent示例为例,你该如何修改它以返回两位小数?当然是通过编写新的测试来实现!首先创建一个基于输入值返回两位小数的测试。

你的测试可能看起来像这样:

[Fact]
public void ItReturnsTwoDecimalPlaces()
{
  // Arrange
  // Act
  var result = GetPercent(1, 3);
  // Assert
  Assert.Equal(33.33, result);
}

现在,修改现有的方法以只返回两位小数。我们将把这个作为练习留给读者。

修复错误

在遗留系统中修复错误是一项危险的任务。请记住,任何现有行为可能已经在系统的其他部分或由应用程序的外部消费者考虑在内。通过修复错误,你可能会破坏其他人依赖的功能,尽管是错误的。因此,在执行之前,应该仔细考虑代码执行结果的任何更改。

自由地进行不安全的重构

重构,按照定义,是在不修改其行为的情况下修改代码的结构。安全的重构包括变量注入、方法提取等。不安全的重构将影响代码的架构,代码与系统其他部分交互的方式,等等。通过拥有一个完全测试过的代码部分,你现在可以修改架构并确信这部分仍然在做它应该做的事情。

摘要

在本章中,我们讨论了如何定义遗留代码以及遗留代码可能引起的问题。遗留代码可能会阻碍测试,但现在你应该知道如何反击遗留问题。

在第十三章,“解开混乱”,我们将探讨在遗留系统中可能会遇到的一些相当极端的例子。我们将探讨安全的重构以及如何最好地将混乱解开成结构良好、可测试的代码。

第十三章:解开混乱

并非所有应用程序都是考虑到测试而编写的。其中很少是最初使用 TDD 开发的。通常,原始的开发者已经离开,文档不正确、不完整或完全缺失。

在本章中,我们将了解:

  • 处理继承的代码

  • 特征测试

  • 带着测试进行重构

继承代码

本章是关于需要(应该是)微小更改的遗留代码的案例研究。我们将很快发现,这个更改并不那么微小。首先,让我们看看遗留应用程序做了什么。

这里是这段代码运行的一些示例输出:

Take a guess: AAAA
---+
Take a guess: BBBA
-+-+
Take a guess: CBCA
++
Take a guess: DBDA
++-+
Take a guess: DBEA
++++
Congratulations you guessed the password in 5 tries.
Press any key to quit.

看起来,这个程序并不那么糟糕。在与业务分析师交谈中,应用程序被解释为是一款游戏。

游戏

这款特定的游戏被称为 Mastermind,是一种解码谜题。根据业务分析师的说法,密码由字母 A 到 F 组成,包含随机选择的四个字母。玩家的目标是确定通行码。

玩家在游戏中会收到提示。对于放置正确的字母,玩家会收到一个加号符号。对于位置错误但正确的字母,玩家会收到一个减号符号。如果字母不正确,玩家则不会收到任何符号。

请求进行更改

在游戏测试过程中,发现玩家发现通行码的速度太快。因此,游戏并没有像预期的那样有趣。建议的解决方案是通过允许使用超过六个字母来使密码更复杂。我们的任务是扩展字符范围到 A 到 Z。

我们可以先查看现有代码,以确定我们可能需要做出更改的地方。那就是我们发现这个地方的地方!

在文件 Program.cs 中:

class Program
{
  static void Main(string[] args)
  {
    char[] g;
    char[] p = new[] { 'A', 'A', 'A', 'A' };
    int i = 0;
    int j = 0;
    int x = 0;
    int c = 0;
    Random rand = new Random(DateTime.Now.Millisecond);
    if (args.Length > 0 && args[0] != null) p = args[0].ToCharArray();
    else goto randomize_password;
    guess: Console.Write("Take a guess: ");
    g = Console.ReadLine().ToArray();
    i = i + 1;
    if (g.Length != 4) goto wrong_size;
    if (g == p) goto success;
    x = 0;
    c = 0;
    check_loop:
    if (g[x] > 65 + 26) g[x] = (char)(g[x] - 32);
    if (g[x] == p[x]) Console.Write("+", c = c + 1);
    else if (p.Contains(g[x])) Console.Write("-");
    x = x + 1;
    if (x < 4) goto check_loop;
    Console.WriteLine();
    if (c == 4) goto success;
    goto guess;
    success: Console.WriteLine("Congratulations you guessed the   
    password in " + i + " tries.");
    goto end;
    wrong_size: Console.WriteLine("Password length is 4.");
    goto guess;
    randomize_password: j = 0;
    password_loop: p[j] = (char)(rand.Next(6) + 65);
    j = j + 1;
    if (j < 4) goto password_loop;
    goto guess;
    end: Console.WriteLine("Press any key to quit.");
    Console.ReadKey();
  }
}

现在我们有几个问题。首先,并不完全清楚字母是从哪里来的。其次,这段代码显然没有经过测试。最后,即使更改是直接的,确保我们没有破坏任何东西也不是那么简单。我们必须进行完整的手动回归测试来验证这些是否正常工作,而尝试验证所有字母,从 A 到 Z,可能需要非常长的时间。

生活有时会给你柠檬

虽然我希望你永远不会收到这么糟糕的代码,但我们将一步步讲解如何将即使是这样的代码也转变为可读、可维护和完全测试的代码。最好的部分,你可能会不相信的部分,是转换这段代码实际上是安全且相对容易的。

开始

在任何这样的代码情况下,我们首先必须做的是将问题代码从我们没有控制的环境中移除。在这种情况下,如果代码位于 Program.main 中,我们就无法测试它。所以,让我们把整个代码块抓取出来,放入一个名为 Mastermind 的类中。我们将有一个名为 Play 的单个函数来运行游戏。这被认为是一种安全的重构,因为我们没有改变任何现有的代码,只是将其移动到其他地方。

在文件 Program.cs 中:

class Program
{
  static void Main(string[] args)
  {
    var game = new Mastermind();
    game.Play(args);
  }
}

在文件 Mastermind.cs 中:

class Mastermind
{
  public void Play(string[] args)
  {
    char[] g;
    char[] p = new[] { 'A', 'A', 'A', 'A' };
    int i = 0;
    int j = 0;
    int x = 0;
    int c = 0;
    Random rand = new Random(DateTime.Now.Millisecond);
    if (args.Length > 0 && args[0] != null) p = args[0].ToCharArray();
    else goto randomize_password;
    guess: Console.Write("Take a guess: ");
    g = Console.ReadLine().ToArray();
    i = i + 1;
    if (g.Length != 4) goto wrong_size;
    if (g == p) goto success;
    x = 0;
    c = 0;
    check_loop:
    if (g[x] > 65 + 26) g[x] = (char)(g[x] - 32);
    if (g[x] == p[x]) Console.Write("+", c = c + 1);
    else if (p.Contains(g[x])) Console.Write("-");
    x = x + 1;
    if (x < 4) goto check_loop;
    Console.WriteLine();
    if (c == 4) goto success;
    goto guess;
    success: Console.WriteLine("Congratulations you guessed the 
    password in " + i + " tries.");
    goto end;
    wrong_size: Console.WriteLine("Password length is 4.");
    goto guess;
    randomize_password: j = 0;
    password_loop: p[j] = (char)(rand.Next(6) + 65);
    j = j + 1;
    if (j < 4) goto password_loop;
    goto guess;
    end: Console.WriteLine("Press any key to quit.");
    Console.ReadKey();
  }
}

在这个点上再次运行代码显示,一切仍然正常工作。下一步是一个外观上的改进;让我们将 Play 方法扩展到几个部分。这应该有助于我们确定大型公共方法内部存在哪些私有方法。

在文件 Mastermind.cs 中:

class Mastermind
{
  public void Play(string[] args)
  {
    // Variable Declarations - Global??
    char[] g;
    char[] p = new[] { 'A', 'A', 'A', 'A' };
    int i = 0;
    int j = 0;
    int x = 0;
    int c = 0;
    // Initialize randomness
    Random rand = new Random(DateTime.Now.Millisecond);
    // Determine if a password was passed in?
    if (args.Length > 0 && args[0] != null) p = args[0].ToCharArray();
    else goto randomize_password; // Create a password if one was not 
    provided
    // Player move - guess the password
    guess: Console.Write("Take a guess: ");
    g = Console.ReadLine().ToArray();
    i = i + 1;
    if (g.Length != 4) goto wrong_size;
    if (g == p) goto success;
    x = 0;
    c = 0;
    // Check if the password provided by the player is correct
    check_loop:
    if (g[x] > 65 + 26) g[x] = (char)(g[x] - 32);
    if (g[x] == p[x]) Console.Write("+", c = c + 1);
    else if (p.Contains(g[x])) Console.Write("-");
    x = x + 1;
    if (x < 4) goto check_loop; // Still checking??
    Console.WriteLine();
    if (c == 4) goto success; // Password must have been correct
    goto guess; // No correct, try again
    // Game over you win
    success: Console.WriteLine("Congratulations you guessed the 
    password in " + i + " tries.");
    goto end;
    // Password guess was wrong size - Error Message
    wrong_size: Console.WriteLine("Password length is 4.");
    goto guess;
    // Create a random password
    randomize_password: j = 0;
    password_loop: p[j] = (char)(rand.Next(6) + 65);
    j = j + 1;
    if (j < 4) goto password_loop;
    goto guess; // Start the game
    // Game is complete - exit
    end: Console.WriteLine("Press any key to quit.");
    Console.ReadKey();
  }
}

我们现在已经使用空白将程序分成几个部分,并添加了注释来解释我们认为每个部分在做什么。到目前为止,我们几乎准备好开始测试了。我们只是有几件事情需要处理,其中最糟糕的是 Console 类。

抽象第三方类

如果我们现在尝试测试,应用程序会调用第一个 ReadLine 调用,并且测试会超时。控制台具有重定向输入和输出的能力,但我们不会使用这个特性,因为它特定于控制台,我们想要展示一个更通用的解决方案,你可以在任何地方应用。

我们需要的是一个提供与控制台类似接口的类。然后我们可以为测试注入我们的类,并为生产代码提供一个薄薄的包装器。现在让我们测试这个接口。

在文件 InputOutputTests.cs 中:

public class InputOutputTests
{
  [Fact]
  public void ItExists()
  {
    var inout = new MockInputOutput();
  }
}

在文件 ReadLineTests.cs 中:

public class ReadLineTests
{
  [Fact]
  public void ItCanBeReadFrom()
  {
    var inout = new MockInputOutput();
    inout.InFeed.Enqueue("Test");

    // Act
    var input = inout.ReadLine();
  }

  [Fact]
  public void ProvidedInputCanBeRetrieved()
  {
    // Arrange
    var inout = new MockInputOutput();
    inout.InFeed.Enqueue("Test");

    // Act
    var input = inout.ReadLine();

    // Assert
    Assert.Equal("Test", input);
  }

  [Fact]
  public void ProvidedInputCanBeRetrievedInSuccession()
  {
    // Arrange
    var inout = new MockInputOutput();
    inout.InFeed.Enqueue("Test 1");
    inout.InFeed.Enqueue("Test 2");

    // Act
    var input1 = inout.ReadLine();
    var input2 = inout.ReadLine();

    // Assert
    Assert.Equal("Test 1", input1);
    Assert.Equal("Test 2", input2);
  }
}

在文件 ReadTests.cs 中:

public class ReadTests
{
  [Fact]
  public void ItCanBeReadFrom() 
  {
    var inout = new MockInputOutput();
    inout.InFeed.Enqueue("T");

    // Act
    var input = inout.Read();
  }

  [Fact]
  public void ProvidedInputCanBeRetrieved()
  {
    // Arrange
    var inout = new MockInputOutput();
    inout.InFeed.Enqueue("T");

    // Act
    var input = inout.Read();

    // Assert
    Assert.Equal('T', input);
  }

  [Fact]
  public void ProvidedInputCanBeRetrievedInSuccession()
  {
    // Arrange
    var inout = new MockInputOutput();
    inout.InFeed.Enqueue("T");
    inout.InFeed.Enqueue("E");

    // Act
    var input1 = inout.Read();
    var input2 = inout.Read();

    // Assert
    Assert.Equal('T', input1);
    Assert.Equal('E', input2);
  }
}

在文件 WriteTests.cs 中:

public class WriteTests
{
  [Fact]
  public void ItCanBeWrittenTo()
  {
    var inout = new MockInputOutput();

    // Act
    inout.Write("Text");
  }

  [Fact]
  public void WrittenTextCanBeRetrieved()
  {
    // Arrange
    var inout = new MockInputOutput();
    inout.Write("Text");

    // Act    
    var writtenText = inout.OutFeed;

    // Assert
    Assert.Single(writtenText);
    Assert.Equal("Text", writtenText.First());
  }
}

在文件 WriteLineTests.cs 中:

public class WriteLineTests
{
  [Fact]
  public void ItCanBeWrittenTo()
  {
    var inout = new MockInputOutput();

    // Act
    inout.WriteLine("Text");
  }

  [Fact]
  public void WrittenTextCanBeRetrieved()
  {
    // Arrange
    var inout = new MockInputOutput();
    inout.WriteLine("Text");

    // Act
    var writtenText = inout.OutFeed;

    // Assert
    Assert.Single(writtenText);
    Assert.Equal("Text" + Environment.NewLine, writtenText.First());
  }
}

在文件 IInputOutput.cs 中:

public interface IInputOutput
{
  void Write(string text);
  void WriteLine(string text);
  char Read();
  string ReadLine();
}

在文件 MockInputOutput.cs 中:

public class MockInputOutput : IInputOutput
{
  public List<string> OutFeed { get; set; }
  public Queue<string> InFeed { get; set; }

  public MockInputOutput()
  {
    OutFeed = new List<string>();
    InFeed = new Queue<string>();
  }

  public void Write(string text)
  {
    OutFeed.Add(text);
  }

  public void WriteLine(string text)
  {
    OutFeed.Add(text + Environment.NewLine);
  }

  public char Read()
  {
    return InFeed.Dequeue().ToCharArray().First();
  }

  public string ReadLine()
  {
    return InFeed.Dequeue();
  }
}

这处理了我们的模拟输入和输出,但我们需要创建控制台的生产包装器类,并需要使用 Program.cs 将该类注入到 Mastermind 类中。

非预期的输入

在将控制台的调用替换为对我们注入的类的调用时,我们发现了一些我们没有计划到的用例。第一个用例相当复杂,有几个参数我们需要处理:

Console.Write("+", c = c + 1);

第二个用例更简单,不需要任何参数:

Console.WriteLine();

第二个用例处理起来最简单,所以现在让我们为它编写一个快速测试。

在文件 WriteLineTests.cs 中:

[Fact]
public void ItCanWriteABlankLine()
{
  // Arrange
  var inout = new MockInputOutput();

  // Act
  inout.WriteLine();

  // Assert
  Assert.Single(inout.OutFeed);
  Assert.Equal(Environment.NewLine, inout.OutFeed.First());
}

在文件 IInputOutput.cs 中:

void WriteLine(string text = null);

在文件 MockInputOutput.cs 中:

public void WriteLine(string text = null)
{
  OutFeed.Add((text ?? "") + Environment.NewLine);
}

下一个问题稍微复杂一些。如果我们想准确处理它,我们需要进行相当多的正则表达式和字符串操作。然而,我们不需要它“正确”;我们只需要它按照应用程序的预期工作。在唯一使用这个功能的情况下,应该放入正在写入的字符串中的值并没有放入。原始的开发者滥用了 Console.Write 的功能来减少 if 语句中的行数,从而避免使用括号。因此,为了让代码继续工作,我们只需要允许输入即可。一个简单的接口扩展应该能为我们提供这个功能。

在文件 IInputOutput.cs 中:

void Write(string text, params object[] args);

在文件 MockInputOutput.cs 中:

public void Write(string text, params object[] args)
{
  OutFeed.Add(text);
}

在应用程序代码中,我们可以完成我们的更改。以下是更新后的应用程序。

在文件 ConsoleInputOutput.cs 中:

public class ConsoleInputOutput : IInputOutput
{
  public void Write(string text, params object[] args)
  {
    Console.Write(text, args);
  }

  public void WriteLine(string text)
  {
    Console.WriteLine(text);
  }

  public char Read()
  {
    return Console.ReadKey().KeyChar;
  }

  public string ReadLine()
  {
    return Console.ReadLine();
  }
}

在文件 Program.cs 中:

class Program
{
  static void Main(string[] args)
  {
    var inout = new ConsoleInputOutput();
    var game = new Mastermind(inout);
    game.Play(args);
  }
}

在文件 Mastermind.cs 中:

public class Mastermind
{
  private readonly IInputOutput _inout;

  public Mastermind(IInputOutput inout)
  {
    _inout = inout;
  }

  public void Play(string[] args)
  {
    // Variable Declarations - Global??
    char[] g;
    char[] p = new[] { 'A', 'A', 'A', 'A' };
    int i = 0;
    int j = 0;
    int x = 0;
    int c = 0;

    // Initialize randomness
    Random rand = new Random(DateTime.Now.Millisecond);

    // Determine if a password was passed in?
    if (args.Length > 0 && args[0] != null) p = args[0].ToCharArray();
    else goto randomize_password; // Create a password if one was not 
    provided
    // Player move - guess the password
    guess: _inout.Write("Take a guess: ");
    g = _inout.ReadLine().ToArray();
    i = i + 1;
    if (g.Length != 4) goto wrong_size;
    if (g == p) goto success;
    x = 0;
    c = 0;

    // Check if the password provided by the player is correct
    check_loop:
    if (g[x] > 65 + 26) g[x] = (char)(g[x] - 32);
    if (g[x] == p[x]) _inout.Write("+", c = c + 1);
    else if (p.Contains(g[x])) _inout.Write("-");
    x = x + 1;
    if (x < 4) goto check_loop; // Still checking??
    _inout.WriteLine();
    if (c == 4) goto success; // Password must have been correct
    goto guess; // No correct, try again

    // Game over you win
    success: _inout.WriteLine("Congratulations you guessed the password 
    in " + i + " tries.");
    goto end;
    // Password guess was wrong size - Error Message
    wrong_size: _inout.WriteLine("Password length is 4.");
    goto guess;

    // Create a random password
    randomize_password: j = 0;
    password_loop: p[j] = (char)(rand.Next(6) + 65);
    j = j + 1;
    if (j < 4) goto password_loop;
    goto guess; // Start the game

    // Game is complete - exit
    end: _inout.WriteLine("Press any key to quit.");
    _inout.Read();
  }
}

快速运行测试确认应用程序正在正确工作:

Take a guess: AAAA
Take a guess: BBBB
Take a guess: CCCC
Take a guess: DDDD
+-++
Take a guess: DEDD
+++
Take a guess: DFDD
++++
Congratulations you guessed the password in 6 tries.

Press any key to quit.

现在,我们可以编写一个黄金标准或特性测试,以验证代码的所有部分是否正常工作。这个测试不会覆盖的唯一代码部分是随机密码生成:

public class GoldStandardTests
{
  [Fact]
  public void StandardTestRun()
  {
    // Arrange
    var inout = new MockInputOutput();
    var game = new Mastermind(inout);

    // Arrange - Inputs
    inout.InFeed.Enqueue("AAA");
    inout.InFeed.Enqueue("AAAA");
    inout.InFeed.Enqueue("ABBB");
    inout.InFeed.Enqueue("ABCC");
    inout.InFeed.Enqueue("ABCD");
    inout.InFeed.Enqueue("ABCF");
    inout.InFeed.Enqueue(" ");

    // Arrange - Outputs
    var expectedOutputs = new Queue<string>();
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("Password length is 4." + 
     Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("-");
    expectedOutputs.Enqueue("-");
    expectedOutputs.Enqueue("-");
    expectedOutputs.Enqueue(Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("-");
    expectedOutputs.Enqueue("-");
    expectedOutputs.Enqueue(Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("-");
    expectedOutputs.Enqueue(Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue(Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue("+");
    expectedOutputs.Enqueue(Environment.NewLine);
    expectedOutputs.Enqueue("Congratulations you guessed the password 
     in 6 tries." + Environment.NewLine);
    expectedOutputs.Enqueue("Press any key to quit." + 
     Environment.NewLine);
    // Act
    game.Play(new[] { "ABCF" });

    // Assert
    inout.OutFeed.ForEach((text) =>
    {    
      Assert.Equal(expectedOutputs.Dequeue(), text);
    });
  }
}

这是一个极其长的测试,它具有非常规的结构,但这个单独的测试几乎涵盖了应用中的所有逻辑。你可能无法总是用一个单独的测试来完成这项工作,但在开始任何重大重构之前,这些测试必须存在。

弄清楚混乱

现在我们已经编写了黄金标准测试,我们可以开始安全地重构代码。任何试图做出的更改,如果破坏了黄金标准测试,都必须撤销,并采取新的方法。

看一下 Mastermind 类,Play 方法顶部所有这些变量都可以移动到类级别字段中。这将使它们对类内的所有代码都可用,并有助于弄清楚它们的作用以及它们在应用中使用的频率:

private char[] g;
private char[] p = new[] { 'A', 'A', 'A', 'A' };
private int i = 0;
private int j = 0;
private int x = 0;
private int c = 0;

接下来,我们将逐步向下工作到 Play 方法,将所有可以提取的内容提取到微小的私有方法中。在我们需要切换到修复这个应用程序中一些过时的逻辑之前,我们只能进行一些微小的重构:

public class Mastermind
{
  private readonly IInputOutput _inout;
  private char[] g;
  private char[] p = new[] { 'A', 'A', 'A', 'A' };
  private int i = 0;
  private int j = 0;
  private int x = 0;
  private int c = 0;

  public Mastermind(IInputOutput inout)
  {
    _inout = inout;
  }

  public void Play(string[] args)
  {
    // Determine if a password was passed in?
    if (args.Length > 0 && args[0] != null) p = args[0].ToCharArray();
    else CreateRandomPassword(); // Create a password if one was not 
    provided
    // Player move - guess the password
    guess:
    _inout.Write("Take a guess: ");
    g = _inout.ReadLine().ToArray();
    i = i + 1;
    if (g.Length != 4) goto wrong_size;
    if (g == p) goto success;
    x = 0;
    c = 0;

    // Check if the password provided by the player is correct
    check_loop:
    if (g[x] > 65 + 26) g[x] = (char)(g[x] - 32);
    if (g[x] == p[x]) _inout.Write("+", c = c + 1);
    else if (p.Contains(g[x])) _inout.Write("-");
    x = x + 1;
    if (x < 4) goto check_loop; // Still checking??
    _inout.WriteLine();
    if (c == 4) goto success; // Password must have been correct
    goto guess; // No correct, try again

    // Password guess was wrong size - Error Message
    wrong_size: _inout.WriteLine("Password length is 4.");
    goto guess;

    // Game over you win
    success: _inout.WriteLine("Congratulations you guessed the password 
     in " + i + " tries.");
    _inout.WriteLine("Press any key to quit.");
    _inout.Read();
  }
  private void CreateRandomPassword()
  {
    // Initialize randomness
    Random rand = new Random(DateTime.Now.Millisecond);

    j = 0;

    password_loop:
    p[j] = (char)(rand.Next(6) + 65);
    j = j + 1;   
    if (j < 4) goto password_loop;
  }
}

我们能够提取出一个密码生成方法。我们还能够简化成功代码的结构。然而,如果不解决所选循环结构的复杂性,我们无法继续前进。编写这个代码的开发者没有使用通用的循环结构,如 while 和 for 循环。我们需要修复这个问题,以便更好地理解和处理这段代码:

public void Play(string[] args)
{
  // Determine if a password was passed in?
  if (args.Length > 0 && args[0] != null) p = args[0].ToCharArray();
  else CreateRandomPassword(); // Create a password if one was not 
   provided
  // Player move - guess the password           
  while (c != 4)
  {
    _inout.Write("Take a guess: ");
    g = _inout.ReadLine().ToArray();

    i = i + 1;

    if (g.Length != 4)
    {
      // Password guess was wrong size - Error Message
      _inout.WriteLine("Password length is 4.");
    }
    else
    {
      // Check if the password provided by the player is correct
      for (x = 0, c = 0; g.Length == 4 && x < 4; x++)
      {
        if (g[x] > 65 + 26) g[x] = (char)(g[x] - 32);
        if (g[x] == p[x]) _inout.Write("+", c = c + 1);
        else if (p.Contains(g[x])) _inout.Write("-");
      }

      _inout.WriteLine();
    }
  }           

  // Game over you win
  _inout.WriteLine("Congratulations you guessed the password in " + i +   
  " tries.");
  _inout.WriteLine("Press any key to quit.");
  _inout.Read();
}

我们现在有一个可以开始工作的结构。让我们先弄清楚这些变量名:

C ~= Correct Letter Guesses
G ~= Current Guess
P ~= Password
I ~= Tries
X ~= Loop Index / Pointer to Guess Character being checked
J ~= Loop Index / Pointer to Password Character being generated

我们将想要更新 Play 方法,使其反映我们对变量含义的确定。以下我们将单个字母变量名替换为更恰当地表示变量用途的名称:

public void Play(string[] args)
{
  // Determine if a password was passed in?
  if (args.Length > 0 && args[0] != null) password =   
   args[0].ToCharArray();
  else CreateRandomPassword(); // Create a password if one was not 
   provided
  // Player move - guess the password           
  while (correctPositions != 4)
  {
    _inout.Write("Take a guess: ");
    guess = _inout.ReadLine().ToArray();

    tries = tries + 1;

    if (guess.Length != 4)
    {
      // Password guess was wrong size - Error Message
      _inout.WriteLine("Password length is 4.");
    }
    else
    {
      // Check if the password provided by the player is correct
      for (x = 0, correctPositions = 0; x < 4; x++)
      {
        if (guess[x] > 65 + 26) guess[x] = (char)(guess[x] - 32);
        if (guess[x] == password[x]) _inout.Write("+", correctPositions 
          = correctPositions + 1);
        else if (password.Contains(guess[x])) _inout.Write("-");
      }
      _inout.WriteLine();
    }
  }           
  // Game over you win
  _inout.WriteLine("Congratulations you guessed the password in " + 
   tries + " tries.");
  _inout.WriteLine("Press any key to quit.");
  _inout.Read();
}

接下来,如果我们现在能更新接口,那会很好,因为我们对应用程序有了更好的理解。我们想要改变的两件事是输入和游戏的最后阶段。如果输入是一个简单的字符串而不是字符数组,那就更好了。Play 方法可以接受一个字符串,程序可以确定如何从参数中获取密码字符串。

沿着同样的思路,我们可以减少总的写入次数,并将连续的加号和减号 Write 命令转换成一个单独的 WriteLine 命令。这将破坏我们的黄金标准测试,但实际上不会改变代码的功能。它仍然会在一行上打印加号和减号。

要将猜测从字符数组转换为字符串,我们首先必须理解这一行正在发生什么:

if (guess[x] > 65 + 26) guess[x] = (char)(guess[x] - 32);

分析这一行,我们看到数字 652632。如果你熟悉 ASCII 码,那么这些行可能对你来说是有意义的。数字 65 是字母字符在 ASCII 表中的起始点。英语字母表中共有 26 个字母。并且,在 "a" 和 "A" 之间有 32 个值。因此,可以假设这段代码是在对指定索引处的字符进行大写或小写转换。我们可以使用 C# 中的 String.ToUpper() 方法来近似实现这一点。

当我们在进行一些小的黄金标准更改时,我们还应该将 Play 方法的最后两行移到 Program.cs 中,因为它们与控制台应用程序更相关。

在文件 Program.cs 中:

class Program
{
  static void Main(string[] args)
  {
    var inout = new ConsoleInputOutput();
    var game = new Mastermind(inout);

    var password = args.Length > 0 ? args[0] : null;
    game.Play(password);

    inout.WriteLine("Press any key to quit.");
    inout.Read();
  }
}

在文件 Mastermind.cs 中:

public class Mastermind
{
  private readonly IInputOutput _inout;
  private string guess;
  private int tries;
  private int correctPositions;

  public Mastermind(IInputOutput inout)
  {
    _inout = inout;
  }

  public void Play(string password = null)
  {
    // Determine if a password was passed in?
    password = password ?? CreateRandomPassword();           

    // Player move - guess the password           
    while (correctPositions != 4)
    {
      _inout.Write("Take a guess: ");
      guess = _inout.ReadLine();
      tries = tries + 1;

      if (guess.Length != 4)
      {
        // Password guess was wrong size - Error Message
        _inout.WriteLine("Password length is 4.");
      }
      else
      {
        // Check if the password provided by the player is correct
        guess = guess.ToUpper();
        var guessResult = "";

        for (var x = 0; x < 4; x++)
        {
          if (guess[x] == password[x])
          {                            
            guessResult += "+";
          }
          else if (password.Contains(guess[x]))
          {
            guessResult += "-";
          }
        }

        correctPositions = guessResult.Count(c => c == '+');
        _inout.WriteLine(guessResult);
      }
    }

    // Game over you win
    _inout.WriteLine("Congratulations you guessed the password in " + 
     tries + " tries.");           
  }

  private string CreateRandomPassword()
  {
    // Initialize randomness
    Random rand = new Random(DateTime.Now.Millisecond);

    var password = new [] {'A', 'A', 'A', 'A'};

    var j = 0;

    password_loop:
    password[j] = (char)(rand.Next(6) + 65);
    j = j + 1;

    if (j < 4) goto password_loop;
    return password.ToString();
  }
}

在文件 GoldStandardTests.cs 中:

public class GoldStandardTests
{
  [Fact]
  public void StandardTestRun()
  {
    // Arrange
    var inout = new MockInputOutput();
    var game = new Mastermind(inout);

    // Arrange - Inputs
    inout.InFeed.Enqueue("AAA");
    inout.InFeed.Enqueue("AAAA");
    inout.InFeed.Enqueue("ABBB");
    inout.InFeed.Enqueue("ABCC");
    inout.InFeed.Enqueue("ABCD");
    inout.InFeed.Enqueue("ABCF");
    inout.InFeed.Enqueue(" ");

    // Arrange - Outputs
    var expectedOutputs = new Queue<string>();
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("Password length is 4." + 
    Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+---" + Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("++--" + Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+++-" + Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+++" + Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("++++" + Environment.NewLine);
    expectedOutputs.Enqueue("Congratulations you guessed the password 
    in 6 tries." + Environment.NewLine);

    // Act
    game.Play("ABCF");

    // Assert
    inout.OutFeed.ForEach(text =>
    {
      Assert.Equal(expectedOutputs.Dequeue(), text);
    });
  }
}

最终美化

现在所有其他工作都已完成,代码也运行正确,是时候在我们开始增强之前进行最后的重构了。我们希望方法尽可能小。在这种情况下,这意味着 Play 函数应该几乎没有任何逻辑在主游戏循环之外。

通常,如果一个方法中包含任何类型的块(例如,if、while、for 等),我们希望该块是方法中唯一的内容。通常,还有检查输入的守卫语句,但应该仅此而已。让我们重构以遵循该约定,并看看重构后的代码是什么样子。

在文件 Mastermind.cs 中:

public class Mastermind
{
  private readonly IInputOutput _inout;
  private int _tries;

  public Mastermind(IInputOutput inout)
  {
    _inout = inout;
  }

  public void Play(string password = null)
  {
    password = password ?? CreateRandomPassword();
    var correctPositions = 0;

    while (correctPositions != 4)
    {
      correctPositions = GuessPasswordAndCheck(password);
    }

    _inout.WriteLine("Congratulations you guessed the password in " + 
    _tries + " tries.");
  }

  private int GuessPasswordAndCheck(string password)
  {
    var guess = Guess();                        
    return Check(guess, password);
  }

  private int Check(string guess, string password)
  {
    var checkResult = "";

    for (var x = 0; x < 4; x++)
    {
      if (guess[x] == password[x])
      {
        checkResult += "+";
      }
      else if (password.Contains(guess[x]))
      {
        checkResult += "-";
      }
    }

    _inout.WriteLine(checkResult);
    return checkResult.Count(c => c == '+');
  }

  private string Guess()
  {
    _tries = _tries + 1;

    _inout.Write("Take a guess: ");
    var guess = _inout.ReadLine();

    if (guess.Length == 4)
    {
      return guess.ToUpper();
    }

    // Password guess was wrong size - Error Message
    _inout.WriteLine("Password length is 4.");
    return Guess();
  }

  private string CreateRandomPassword()
  {
    // Initialize randomness
    Random rand = new Random(DateTime.Now.Millisecond);

    var password = new[] { 'A', 'A', 'A', 'A' };

    var j = 0;

    password_loop:
    password[j] = (char)(rand.Next(6) + 65);
    j = j + 1;

    if (j < 4) goto password_loop;

    return password.ToString();
  }
}

这段代码可以有多种重构方式;这只是其中一种。现在代码已经重构,我们准备继续前进并开始进行增强。

准备进行增强

我们现在到达了一个点,代码已经足够清晰,我们可以开始处理我们的变更请求了。我们已经将随机密码生成部分的代码拆分成了它自己的方法,因此现在我们可以独立地工作在这个方法上。

我们需要做的第一件事是停止使用 RandomRandom 本质上是不可预测的,并且不受我们的控制。我们需要一种方法来提供数字生成,以验证当 Random 提供特定输入时,我们能否得到预期的输出。

我们将提取一个接口和模拟类,类似于我们为 Console 所做的。以下是创建的第一轮测试、模拟类和接口。

在文件 RandomNumberTests.cs 中:

public class RandomNumberTests
{
  private readonly MockRandomGenerator _rand;

  public RandomNumberTests()
  {
    _rand = new MockRandomGenerator();
  }

  [Fact]
  public void ItExists()
  {
    _rand.Number();
  }

  [Fact]
  public void ItReturnsDefaultValue()
  {
    // Act
    var result = _rand.Number();

    // Assert
    Assert.Equal(0, result);
  }

  [Fact]
  public void ItCanReturnPredeterminedNumbers()
  {
    // Arrange
    _rand.SetNumbers(1, 2, 3, 4, 5);

    // Act
    var a = _rand.Number();
    var b = _rand.Number();
    var c = _rand.Number();
    var d = _rand.Number();
    var e = _rand.Number();

    // Arrange
    Assert.Equal(1, a);
    Assert.Equal(2, b);
    Assert.Equal(3, c);
    Assert.Equal(4, d);
    Assert.Equal(5, e);
  }

  [Fact]
  public void ItCanHaveAMaxRange()
  {
    // Arrange
    const int maxRange = 3;
    _rand.SetNumbers(1, 2, 3, 4, 5);

    // Act
    var a = _rand.Number(maxRange);
    var b = _rand.Number(maxRange);
    var c = _rand.Number(maxRange);
    var d = _rand.Number(maxRange);
    var e = _rand.Number(maxRange);

    // Arrange
    Assert.Equal(1, a);
    Assert.Equal(2, b);
    Assert.Equal(3, c);
    Assert.Equal(3, d);
    Assert.Equal(3, e);
  }

  [Fact]
  public void ItCanHaveAMinMaxRange()
  {
    // Arrange
    const int minRange = 2;
    const int maxRange = 3;
    _rand.SetNumbers(1, 2, 3, 4, 5);

    // Act
    var a = _rand.Number(minRange, maxRange);
    var b = _rand.Number(minRange, maxRange);
    var c = _rand.Number(minRange, maxRange);
    var d = _rand.Number(minRange, maxRange);
    var e = _rand.Number(minRange, maxRange);

    // Arrange
    Assert.Equal(2, a);
    Assert.Equal(2, b);
    Assert.Equal(3, c);
    Assert.Equal(3, d);
    Assert.Equal(3, e);
  }
}

在文件 IRandomGenerator.cs 中:

public interface IRandomGenerator
{
  int Number(int max = 100);
  int Number(int min, int max);
}

在文件 MockRandomGenerator.cs 中:

public class MockRandomGenerator : IRandomGenerator
{
  private readonly List<int> _numbers;
  private List<int>.Enumerator _numbersEnumerator;

  public MockRandomGenerator(List<int> numbers = null)
  {
    _numbers = numbers ?? new List<int>();
    _numbersEnumerator = _numbers.GetEnumerator();
  }

  public int Number(int min, int max)
  {
    var result = Number(max);

    return result < min ? min : result;
  }

  public int Number(int max = 100)
  {
    _numbersEnumerator.MoveNext();
    var result = _numbersEnumerator.Current;

    return result > max ? max : result;
  }

  public void SetNumbers(params int[] args)
  {
    _numbers.AddRange(args);
    _numbersEnumerator = _numbers.GetEnumerator();
  }
}

现在,创建生产 RandomGenerator 类并将其注入到我们的应用程序中。

在文件 RandomGenerator.cs 中:

public class RandomGenerator : IRandomGenerator
{
  private readonly Random _rand;

  public RandomGenerator()
  {
    _rand = new Random();
  }

  public int Number(int max = 100)
  {
    return _rand.Next(0, max);
  }

  public int Number(int min, int max)
  {
    return _rand.Next(min, max);
  }
}

在文件 Program.cs 中:

class Program
{
  static void Main(string[] args)
  {
    var rand = new RandomGenerator();
    var inout = new ConsoleInputOutput();
    var game = new Mastermind(inout, rand);

    var password = args.Length > 0 ? args[0] : null;
    game.Play(password);

    inout.WriteLine("Press any key to quit.");
    inout.Read();
  }
}

在文件 Mastermind.cs 中:

public class Mastermind
{
  private readonly IInputOutput _inout;
  private readonly IRandomGenerator _random;

  private int _tries;

  public Mastermind(IInputOutput inout, IRandomGenerator random)
  {
    _inout = inout;
    _random = random;
  }

  public void Play(string password = null)
  {
    password = password ?? CreateRandomPassword();
    var correctPositions = 0;

    while (correctPositions != 4)
    {
      correctPositions = GuessPasswordAndCheck(password);
    }

    _inout.WriteLine("Congratulations you guessed the password in " + 
    _tries + " tries.");
  }

  private int GuessPasswordAndCheck(string password)
  {
    var guess = Guess();
    return Check(guess, password);
  }

  private int Check(string guess, string password)
  {
    var checkResult = "";

    for (var x = 0; x < 4; x++)
    {
      if (guess[x] == password[x])
      {
        checkResult += "+";
      }
      else if (password.Contains(guess[x]))
      {
        checkResult += "-";
      }
    }

    _inout.WriteLine(checkResult);
    return checkResult.Count(c => c == '+');
  }

  private string Guess()
  {
    _tries = _tries + 1;

    _inout.Write("Take a guess: ");
    var guess = _inout.ReadLine();

    if (guess.Length == 4)
    {
      return guess.ToUpper();
    }

    // Password guess was wrong size - Error Message
    _inout.WriteLine("Password length is 4.");
    return Guess();
  }

  private string CreateRandomPassword()
  {
    var password = new[] { 'A', 'A', 'A', 'A' };

    var j = 0;

    password_loop:
    password[j] = (char)(_random.Number(6) + 65);
    j = j + 1;

    if (j < 4) goto password_loop;

    return new string(password);
  }
}

最后,让我们修改黄金标准测试以使用随机密码生成。

在文件 GoldStandardTests.cs 中:

public class GoldStandardTests
{
  [Fact]
  public void StandardTestRun()
  {
    // Arrange
    var inout = new MockInputOutput();
    var rand = new MockRandomGenerator();
    var game = new Mastermind(inout, rand);

    // Arrange - Inputs
    rand.SetNumbers(0, 1, 2, 5);
    inout.InFeed.Enqueue("AAA");
    inout.InFeed.Enqueue("AAAA");
    inout.InFeed.Enqueue("ABBB");
    inout.InFeed.Enqueue("ABCC");
    inout.InFeed.Enqueue("ABCD");
    inout.InFeed.Enqueue("ABCF");
    inout.InFeed.Enqueue(" ");

    // Arrange - Outputs
    var expectedOutputs = new Queue<string>();
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("Password length is 4." + 
    Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+---" + Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("++--" + Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+++-" + Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("+++" + Environment.NewLine);
    expectedOutputs.Enqueue("Take a guess: ");
    expectedOutputs.Enqueue("++++" + Environment.NewLine);
    expectedOutputs.Enqueue("Congratulations you guessed the password 
    in 6 tries." + Environment.NewLine);

    // Act
    game.Play();

    // Assert
    inout.OutFeed.ForEach(text =>
    {
      Assert.Equal(expectedOutputs.Dequeue(), text);
    });
  }
}

现在我们准备重构密码生成方法,并扩展它以提供所需的变化。首先,有一个不是语言核心的循环结构。让我们专注于 CreateRandomPassword 方法并修复循环结构:

private string CreateRandomPassword()
{
  var password = new[] { 'A', 'A', 'A', 'A' };

  for(var j = 0; j < 4; j++)
  {
    password[j] = (char)(_random.Number(6) + 65);
  }

  return new string(password);
}

接下来,为了好玩,让我们看看我们能否泛化和压缩这个循环,因为我们 Check 方法中有一个非常相似的循环。虽然这不是必要的,但这是一个减少代码重复的好例子。以下是重构的样子:

private int Check(string guess, string password)
{
  var checkResult = "";

  Times(4, x => {
    if (guess[x] == password[x])
    {
      checkResult += "+";
    }
    else if (password.Contains(guess[x]))
    {
      checkResult += "-";
    }
  });

  _inout.WriteLine(checkResult);
  return checkResult.Count(c => c == '+');
}

private string CreateRandomPassword()
{
  var password = new[] { 'A', 'A', 'A', 'A' };

  Times(4, x => password[x] = (char)(_random.Number(6) + 65));

  return new string(password);
}

private static void Times(int count, Action<int> act)
{
  for (var index = 0; index < count; index++)
  {
    act(index);
  }
}

现在我们扩展应用程序之前,让我们再进行一次重构。观察字符的生成方式,并不明显。相反,我们希望代码尽可能简单直接。没有理由随机生成器类不能直接返回字母,所以让我们添加这个功能。

在文件 RandomLetterTests.cs 中:

public class RandomLetterTests
{
  private readonly MockRandomGenerator _rand;

  public RandomLetterTests()
  {
    _rand = new MockRandomGenerator();
  }

  [Fact]
  public void ItExists()
  {
    _rand.Letter();
  }

  [Fact]
  public void ItReturnsDefaultValue()
  {
    // Act
    var result = _rand.Letter();

    // Assert
    Assert.Equal('A', result);
  }

  [Fact]
  public void ItCanReturnPredeterminedLetters()
  {
    // Arrange
    _rand.SetLetters('A', 'B', 'C', 'D', 'E');

    // Act
    var a = _rand.Letter();
    var b = _rand.Letter();
    var c = _rand.Letter();
    var d = _rand.Letter();
    var e = _rand.Letter();

    // Assert
    Assert.Equal('A', a);
    Assert.Equal('B', b);
    Assert.Equal('C', c);
    Assert.Equal('D', d);
    Assert.Equal('E', e);
  }

  [Fact]
  public void ItCanHaveAMaxRange()
  {
    // Arrange
    const char maxRange = 'C';
    _rand.SetLetters('A', 'B', 'C', 'D', 'E');

    // Act
    var a = _rand.Letter(maxRange);
    var b = _rand.Letter(maxRange);
    var c = _rand.Letter(maxRange);
    var d = _rand.Letter(maxRange);
    var e = _rand.Letter(maxRange);

    // Arrange
    Assert.Equal('A', a);
    Assert.Equal('B', b);
    Assert.Equal('C', c);
    Assert.Equal('C', d);
    Assert.Equal('C', e);
  }

  [Fact]
  public void ItCanHaveAMinMaxRange()
  {
    // Arrange
    const char minRange = 'B';
    const char maxRange = 'C';
    _rand.SetLetters('A', 'B', 'C', 'D', 'E');

    // Act
    var a = _rand.Letter(minRange, maxRange);
    var b = _rand.Letter(minRange, maxRange);
    var c = _rand.Letter(minRange, maxRange);
    var d = _rand.Letter(minRange, maxRange);
    var e = _rand.Letter(minRange, maxRange);

    // Arrange
    Assert.Equal('B', a);
    Assert.Equal('B', b);
    Assert.Equal('C', c);
    Assert.Equal('C', d);
    Assert.Equal('C', e);
  }
}

在文件 MockRandomGenerator.cs 中:

public class MockRandomGenerator : IRandomGenerator
{
  private readonly List<int> _numbers;
  private List<int>.Enumerator _numbersEnumerator;

  private readonly List<char> _letters;
  private List<char>.Enumerator _lettersEnumerator;

  private const char NullChar = '\0';

  public MockRandomGenerator(List<int> numbers = null, List<char> 
  letters = null)
  {
    _numbers = numbers ?? new List<int>();
    _numbersEnumerator = _numbers.GetEnumerator();

    _letters = letters ?? new List<char>();
    _lettersEnumerator = _letters.GetEnumerator();
  }

  public int Number(int min, int max)
  {
    var result = Number(max);

    return result < min ? min : result;
  }

  public int Number(int max = 100)
  {
    _numbersEnumerator.MoveNext();
    var result = _numbersEnumerator.Current;

    return result > max ? max : result;
  }

  public void SetNumbers(params int[] args)
  {
    _numbers.AddRange(args);
    _numbersEnumerator = _numbers.GetEnumerator();
  }

  public int Letter(char min, char max)
  {
    var result = Letter(max);

    return result < min ? min : result;
  }

  public char Letter(char max = 'Z')
  {
    _lettersEnumerator.MoveNext();           
    var result = _lettersEnumerator.Current;
    result = result == NullChar ? 'A' : result;

    return result > max ? max : result;
  }

  public void SetLetters(params char[] args)
  {
    _letters.AddRange(args);
    _lettersEnumerator = _letters.GetEnumerator();
  }
}

在文件 IRandomGenerator.cs 中:

public interface IRandomGenerator
{
  int Number(int max = 100);
  int Number(int min, int max);
  char Letter(char max = 'Z');
  char Letter(char min, char max);
}

在文件 RandomGenerator.cs 中:

public class RandomGenerator : IRandomGenerator
{
  private readonly Random _rand;

  public RandomGenerator()
  {
    _rand = new Random();
  }

  public int Number(int max = 100)
  {
    return Number(0, max);
  }

  public int Number(int min, int max)
  {
    return _rand.Next(min, max);
  }

  public char Letter(char max = 'Z')
  {
    return Letter('A', max);
  }

  public char Letter(char min, char max)
  {
    return (char) _rand.Next(min, max);
  }
}

在文件 Mastermind.cs 中:

private string CreateRandomPassword()
{
  var password = new[] { 'A', 'A', 'A', 'A' };

  Times(4, x => password[x] = _random.Letter('F'));

  return new string(password);
}

在文件 GoldStandardTests.cs 中:

// Arrange - Inputs
rand.SetLetters('A', 'B', 'C', 'F');

那就是这个练习的最终重构。我们只有一件事要做,那就是扩展应用程序以使用整个英语字母表的范围生成密码。由于我们在测试和重构上付出的努力,这现在是一件微不足道的事情,实际上只需要在 Mastermind 类中删除三个字符。

在文件 Mastermind.cs 中:

private string CreateRandomPassword()
{
  var password = new[] { 'A', 'A', 'A', 'A' };

  Times(4, x => password[x] = _random.Letter());

  return new string(password);
}

现在创建一个更复杂的密码,包含整个字母表的范围。这导致了一个更难的密码,并且一个输出类似于以下的游戏:

Take a guess: AAAA
Take a guess: BBBB
Take a guess: CCCC
---+
Take a guess: DDDC
+
Take a guess: EEEC
+
Take a guess: FFFC
+
Take a guess: GGGC
+
Take a guess: HHHC
+
Take a guess: IIIC
+
Take a guess: JJJC
+
Take a guess: KKKC
+
Take a guess: LLLC
+
Take a guess: mmmc
+
Take a guess: nnnc
+
Take a guess: oooc
+--+
Take a guess: oppc
++-+
Take a guess: opqc
+++
Take a guess: oprc
+++
Take a guess: opsc
+++
Take a guess: optc
+++
Take a guess: opuc
+++
Take a guess: opvc
+++
Take a guess: opwc
++++
Congratulations you guessed the password in 23 tries.

Press any key to quit.

摘要

你现在有一个编写良好的示例,由测试覆盖。所涉及的努力可能令人畏惧,但对于任何非平凡的应用程序来说,这可能非常值得。

在 第十四章,《更好的起点》,我们将总结我们所学的知识,并给你一些如何作为 TDD 专家重新融入世界的建议。

第十四章:更好的起点

你已经到达了《使用 C# 7 进行实用测试驱动开发》的最后一章。我们感谢你。但是,你作为测试驱动开发TDD)实践者的旅程才刚刚开始。很快,你将有机会以 TDD 专家的身份重新加入世界。

在本章中,我们将总结前几章的主要主题,并给你一些指导,帮助你继续这次航行。在本章中,我们将了解:

  • 为什么 TDD 很重要

  • 通过测试来增长应用程序

  • 将 TDD 引入你的团队

  • 以 TDD 专家的身份重新加入世界

我们所涵盖的内容

你可能还不是专家。这没关系。有时你可能会遇到困难或怀疑 TDD 的好处。不用担心。阅读这本书只是成为 TDD 大师旅程中的一步。这条路很长,但值得你投入时间和精力。你是一位专业人士,一位致力于自己行业的工匠。

到现在为止,你应该对自己的开发环境设置感到自信。你可以配置你选择的 IDE 来运行你的单元测试套件。你应该对选择测试运行器和该选择所涉及的具体细微差别和功能感到舒适。当然,你知道如何组装一套全面的单元测试。

你可以通过测试来引导应用程序的生长。现在重构应该变得轻而易举,因为你有了信心在不引入破坏性更改的情况下移动代码。你可以向应用程序的利益相关者展示正确性,并且通过你的测试提供了对回归错误的信心。

在 TDD 领域,世界是完美的。你会 wonder 你是如何在没有你新获得的知识的情况下生活的。让我们花点时间回顾一下我们学到了什么。

前进之路

那么,接下来你该怎么做呢?希望你对 TDD 的热情和你第一次成功编译软件应用程序时一样。每一次成功的测试都是你辛勤工作和对当前问题理解的证明。为每一次小的胜利欢呼,因为这是你的成就。通过在测试中引入越来越多的功能来验证你的理解。

在你继续职业生涯的过程中,选择以 TDD 的方式操作取决于你。遵守这一哲学以及你保持 TDD 心态的好坏完全取决于你。如果你的老板不熟悉 TDD,不要气馁。你不需要许可。

继续通过测试来增长应用程序。如果别人询问 TDD,分享你的知识和热情。向你的团队引入这一实践,但不要强迫那些尚未准备好的人。

TDD 是一种个人实践

首先,TDD 是一种个人实践。它不应该是任何人预算上的一个条目。TDD 是那些深深关心自己工艺的人开发高质量软件的方式。

如果你怀疑你可能会从你的团队、经理或项目赞助者那里得到反对,那么没有必要让他们参与以这种方式开发软件的决定。

通常,让开发团队知道系统中存在测试以避免破坏它们是更好的,但并不一定需要寻求许可。

你不需要任何人的许可来做好的工作。

你不需要许可

如果你在一个原本不是这样开始的项中引入 TDD,你可能会被问及谁给了你这样做许可。你不需要任何人的许可来做好的工作。TDD 是一种个人实践,以确保你交付高质量的软件。你不需要任何人的许可来做这件事。

为你的工作感到自豪,尽你所能做到最好。随着你成长并对 TDD 更加舒适,它很可能会成为你交付软件的默认方法。

通过测试来增长应用

容易陷入大设计前期的陷阱。这些设计会议很重要,但会议产生的成果是探索性的想法,而不是逐字逐句的攻击计划。应用应该通过你,开发者所编写的测试来有机地增长。

在开发绿色字段应用时,使用 TDD 开始要容易得多。从一开始就考虑测试的设计要容易得多,而不是在以后尝试重新设计测试。如果你的应用从一开始就考虑测试,那么将受益匪浅。在测试的指导下,你的软件将更简单,更容易增长和维护。

假设需求和期望已经明确定义,增强功能可以通过测试轻松添加到系统中。如果一个用户故事已经很好地定义,那么需求可以轻松地转化为一系列新的测试。随后,可以通过使新测试通过来轻松地将新的生产代码添加到系统中。假设有一个现有的全面的单元测试套件,引入新缺陷或更改现有行为的恐惧应该是最小的。

缺陷可以在发现时解决。只需编写一个或多个测试来定义预期的行为,并在进行中修改生产代码。可能会发现更多缺陷,或者现有的行为,以及由此产生的现有测试可能需要更改。不用担心,测试套件在那里是为了防止错误,并给你,开发者,一种安全感。

向你的团队引入 TDD

到目前为止,你可能非常兴奋地想要与你的组织和你所在的团队成员分享 TDD 的奇妙世界。请注意,其他人可能没有同样的热情。对于从未尝试过的人来说,编写单元测试可能是一个令人畏惧的建议。对于你团队中的某些人来说,这个想法可能有一些负面含义。

作为开发者,我们被付薪水的目的是成为专家。我们被期望有答案。当引入新事物和未知事物时,这可能是一种令人焦虑的经历。努力减少团队在学习新事物时可能感到的焦虑。记住当你听到 TDD 这个术语而不知道从何开始时的感受。想想你在拿起这本书之前的感觉。

向团队介绍 TDD(测试驱动开发)有好的方法,也有不那么好的方法。如果你真的希望你的团队能够采纳这种实践,那么考虑一下如何让他们对这种前景感到兴奋。

不要强迫任何人使用 TDD

与向团队介绍 TDD 相关的无数悲惨故事。一个过于热情的成员试图把 TDD 的优点强加给其他团队成员。项目领导者可能会反对,并试图完全禁止这个过程。

如果一个团队决定整体采纳这种实践,确保每个团队成员都有发言权。每个人的意见都很重要。如果团队成员不支持,队友可能会退缩,甚至可能离开项目或公司。

TDD 的趣味化

通过将过程游戏化,让新团队接触 TDD 是一个很好的方法。慢慢地介绍这个主题,让人们对新技能的学习前景感到兴奋。为团队创造友好的竞争或挑战。

午餐和学习会是一个介绍主题的好方法。YouTube 和 Pluralsight 等网站上有很多视频教程,这对你的团队来说可以是一个很好的介绍和社交分享活动。

代码 Kata(编程练习)是让某人接触 TDD 的极好方式。这些 20 分钟的简短练习足够简单,可以让人们熟悉基本原理。随着成员对练习越来越熟悉,逐渐引入更多复杂性和不同的挑战。

代码挑战和/或家庭作业可能是让团队参与的好方法。你应该对谁在什么情况下工作得最好有所了解。有些人可能更喜欢家庭作业和独立工作,而其他人可能对个人挑战更有反应。

展示团队的好处

你最好的选择是慢慢地引入测试。如果你正在努力解决缺陷,考虑用测试包裹现有的方法或函数以验证缺陷。修正代码以允许新测试通过。与你的队友分享你的结果。解释这个过程有多容易,以及可能节省了多少时间和精力。不要施加压力,只需告知即可。

在现有应用程序中添加新功能是探索 TDD 与团队一起的好时机。如果你能向同事展示如何使用 TDD 在现有应用程序中开发新功能,这可能很有用。在开发者熟悉的应用程序中引入 TDD 这样的新概念可能更容易。在熟悉的应用程序中,未知因素被降至最低,焦点可以放在 TDD 上。

如果你开始了一个新的项目,为了使用 TDD 开发这个应用程序的一部分,可能有益于承担这个应用程序的一部分。如果至少有一部分应用程序是用 TDD 开发的,那么可以用它来向团队中的其他人展示基础知识。使用这个应用程序的这部分作为例子,你可能会说服其他人探索将 TDD 扩展到应用程序的其余部分。

一定要跟踪进度。你可能会发现,个人或应用程序的部分更适合 TDD。如果有人遇到困难,他们可能很难承认自己的问题。留心观察,并在可能的情况下提供帮助。

审查结果

当向团队介绍 TDD 时,要准备好解决与 TDD 新手经常相关的问题。会编写一些无用的测试,它们没有任何价值。寻找这些和其他问题在测试套件中。

如果你的团队目前没有使用拉取请求或代码审查,现在是一个引入这一实践的好时机。养成审查所编写测试的习惯。这将有助于发现任何潜在的问题点。这也带来了额外的益处,即更多地了解你可能不熟悉的系统部分。

如果你正在处理一个现有项目,或者任何你能够控制的新的功能,首先使用 TDD 来开发这个功能。如果你有代码审查流程,确保审查者知道,并向他们展示通过测试。

重新以 TDD 专家的身份加入世界

现在是时候以 TDD 专家的身份重新加入世界了。如果你还没有完全觉得自己像专家,不要担心。你很可能比至少一个同事或同行知道得多。对他们来说,你是拥有可以分享的知识的专业人士。去分享那些知识吧。但记住,总有更多东西可以学习。

寻找一个导师

寻找一个或多个导师可能是有益的。在你所在的社区中,可能有人了解 TDD,并愿意与你交谈。他们可能是你公司的员工,也可能是你所在城市的技术社区成员。找到他们,并提议请他们吃午餐或喝咖啡。你可能会发现,他们很高兴找到愿意并渴望讨论 TDD 作为一种实践的人。

用户组和聚会是建立人脉的好地方。寻找你所在地区可以参加的会议。近年来,TDD(测试驱动开发)已经成为一个热门话题,你很可能找到附近的会议。编程语言可能与你的日常使用不同(例如,Java 与 C#),但整体原则可能是有益的。此外,接触新的不同语言有助于你探索你可能不熟悉的范式。

Twitter 现在是另一个极好的资源。如今活跃在 Twitter 和其他社交媒体上的技术专业人士数量令人震惊。以前从未有过与行业巨头交谈的可能。不要害怕在 Twitter 和其他平台上联系技术专家,并表达你获取知识的兴趣。你可能会对你的回应感到惊讶。

成为导师

与寻找导师一样,你也可以考虑成为导师。成为导师的场所同样适用。通过教学你会学到更多。与他人分享你的知识,并从你所教授的人那里学到同样多的东西。

用户组和聚会总是希望有人来演讲。考虑准备一个演示文稿,分享你对 TDD 的兴趣。这可以是 5 到 15 分钟的闪电演讲,一个小时的演讲,或者一整天的研讨会。

练习,练习,再练习

医生和律师之所以将他们所做的事情定义为“实践”,是有原因的。像这些职业一样,计算机程序员完全沉浸在软件开发实践中。这是一种需要练习的实践。

养成每天花前 20 分钟做不同 Code Kata 的习惯。使用 TDD 解决一个新的谜题。用新的或不同的技术解决你以前解决的问题。尝试不同的方法来开发解决方案。在你的技艺上花时间,并训练自己寻找可测试和可验证的替代解决方案。

审查你的工作。确保你真正是通过测试来推动你的应用程序开发。核实你实际上是在测试你的应用程序,而不仅仅是走形式。

最重要的是,享受乐趣!

摘要

我们的旅程已经结束,但不要害怕。你现在已经准备好作为一个 TDD 大师进入这个世界。

你不仅理解了如何以 TDD 心态进行开发,你还知道为什么 TDD 对于开发可测试、可扩展和可维护的软件应用如此重要。你的 IDE 已经设置好以测试 C#和/或 JavaScript 应用程序,并且你对软件质量有一个持续的反馈循环。

你理解了定义和测试应用程序边界的的重要性,以及抽象化第三方代码(包括.NET 框架)的好处。间谍、模拟和伪造,以及如何最好地使用它们,现在已经被充分理解。

使用 TDD 来接近绿色田野应用程序,现在应该几乎是一件微不足道的事情。将整体应用程序的更广泛问题分解成可以独立开发的具有意义的块。你已经学习了不同的应用程序开发方法,例如:从前到后、从后到前和从内到外。选择最合适的方法。

使用 TDD 将需求和要求故事转换为工作软件应该是一件轻而易举的事情!利用你已掌握的所有技能来测试边界,测试小的、独立的单元。

抽象化第三方库,包括.NET 框架。移除对诸如DateTime和 Entity Framework 之类的依赖。你已经学会了如何将你的应用与特定的实现解耦,以便你的应用可测试,同时也更加灵活,并且在未来更容易修改。

当需求发生变化时会发生什么?如果发现了一个错误会发生什么?没问题,更改一个测试或编写一个新的测试来覆盖新的需求或防御已发现的错误。然后,编写一些新的代码或更改一些现有的代码,以确保所有新的/修改后的测试通过。如果你做得正确,你应该可以放心地做出这些更改,因为我们的现有测试套件将防止你引入新的错误。

现在市面上有很多应用缺乏足够的(任何?)测试覆盖率。甚至更少的应用是按照测试优先的原则编写的。你现在已经意识到,那些没有考虑到可测试性的遗留应用存在的一些主要问题,并且知道如何最好地纠正这些问题。

你知道如何安全地修改一个没有考虑到测试的遗留应用,并且知道如何添加测试以最小化在修改现有代码时引入新错误的可能性。

记住,TDD(测试驱动开发)是一个个人选择。你不需要任何人的许可来做好的工作。现在,以 TDD 专家的身份重新加入世界吧!

posted @ 2025-10-22 10:25  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报