PHP8-测试驱动开发-全-
PHP8 测试驱动开发(全)
原文:
zh.annas-archive.org/md5/9592768d28a1d7038ee4f9eacb240a07译者:飞龙
前言
在过去的几年里,我一直帮助朋友和同事更多地了解 PHP 和测试驱动开发(TDD)。我意识到,我发现自己在与不同的人讨论时重复了一些事情。我告诉自己,如果能把我与不同人讨论的内容整理成笔记集,那将非常棒,这样我就可以从项目的开始阶段到部署阶段,帮助他们开发 PHP 应用程序。不幸的是,我的笔记只有我自己能看懂。
2009 年,我是一名 C#开发者,与同样是编程爱好者的同事成为了朋友。不幸的是,我们已经很久没有联系了。11 年后,在 2020 年的 COVID 封锁期间,我与久未联系的朋友取得了联系,并开始讨论编程。他们告诉我,他们非常想学习使用 PHP 进行 TDD。然后我们在周末进行了一次屏幕共享教程会话,我意识到我迫切需要开始编写一些更有组织的材料,以帮助对这一主题感兴趣的其他人。
我大部分时间都在进行自我培训。我购买书籍,阅读它们,并观看关于我想自己学习的主题的教程。然后我决定写一本关于使用 PHP 进行 TDD 的书。由于我从 Packt 出版的书籍中学到了很多,我想我应该联系他们。
阅读这本书将帮助你开始组织你的想法和作为软件开发者需要为项目构建的事物。你将学习如何编写和使用自动化测试来帮助提高你生产的软件质量,你还将学习如何使用工具来自动执行你的测试,以及将你的代码部署到远程服务器上。我的目标是让读者理解 TDD 作为一个过程的价值,而不仅仅是学习编写自动化测试。这就是为什么我涵盖了从开始项目到将其部署到公共服务器上的所有主题。
本书面向的对象
如果你是一名对工作在不易测试或维护的应用程序感到厌倦的专业 PHP 开发者,这本书将帮助你成为一名更好的专业 PHP 开发者。你将学习如何利用测试驱动开发(TDD)和行为驱动开发(BDD)来帮助你生产出更有结构和可维护的软件。
本书涵盖的内容
第一章**,什么是测试驱动开发以及为什么在 PHP 中使用它?,介绍了 TDD 的定义,它试图解决的问题,PHP 的优势以及我们开发者从实施中能得到什么。这一章还将让你了解 TDD 在大项目中的价值以及它如何帮助减少回归。
第二章**,理解和组织我们项目的业务需求,解释了如何将业务需求解释为一个有组织的列表。该列表可用于帮助开发者确定我们需要构建哪些功能,以及哪些功能需要首先构建。
第三章**,使用 Docker 容器设置我们的开发环境,主要介绍了用于开发的 Docker 容器。使用容器可以帮助开发者在不同的服务器环境中获得更一致的应用程序设置。
第四章**,在 PHP 中使用面向对象编程,探讨了 PHP 中的面向对象编程(OOP)概念。理解 PHP 中的 OOP 对于实现 TDD 和 BDD 至关重要。
第五章**,单元测试,涵盖了单元测试的基础知识。本章学到的知识将是下一章讨论在应用 TDD 和 BDD 时的概念的基础。
第六章**,应用行为驱动开发,介绍了 BDD 的过程。BDD 过程将帮助软件开发者确保软件产品的预期行为得到实现。
第七章**,使用 BDD 和 TDD 构建解决方案代码,展示了如何结合使用 BDD 和 TDD。同时实现 BDD 和 TDD 将有助于确保预期的行为得到实现,同时也有助于提高所生产软件的可靠性。
第八章**,使用 SOLID 原则进行 TDD,涉及在项目中遵循 SOLID 原则。这将帮助开发者在更现实的使用场景中遵循 TDD 的同时实现 SOLID 原则。
第九章**,持续集成,详细介绍了用于测试执行的持续集成(CI)。CI 用于确保所有自动化测试通过后,将可靠的代码合并到项目的 master 分支。
第十章**,持续交付,讨论了使用持续交付(CD)来自动化发布过程。比 CI 更进一步,我们将自动化产品的部署过程。
第十一章**,监控,解释了如何使用监控工具来监控已部署的应用程序。在生产环境中运行的应用程序需要维护,而监控工具可以帮助开发者掌握生产中可能出现的问题。
为了充分利用本书
书中的说明基于 Unix。书中用作示例的主机开发机器运行在 MacOS 12.3 上,并且应该适用于后续版本。然而,Docker 容器也应该在 Windows 和 Linux 机器上运行。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Docker | macOS、Linux 或 Windows |
| PHP 8.1 | Linux |
| MySQL 8 | Linux |
| PHPStorm | macOS、Linux 或 Windows |
如果你使用这本书的数字版,我们建议你亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助你避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
你可以从 GitHub 下载这本书的示例代码文件github.com/PacktPublishing/Test-Driven-Development-with-PHP-8。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/获取。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表的彩色图像的 PDF 文件。你可以从这里下载:packt.link/BwjU3。
使用的约定
本书中使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“测试类的名称需要后缀为Test,并扩展PHPUnit\Framework\TestCase类。”
代码块按照以下方式设置:
<?php
namespace App\Validator;
use App\Model\ToyCar;
use App\Model\ValidationModel;
interface ToyCarValidatorInterface
{
public function validate(ToyCar $toyCar): ValidationModel;
任何命令行输入或输出都按照以下方式编写:
$ cd docker
$ docker-compose build && docker-compose up -d
粗体: 表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“将脚本粘贴到文本区域,然后单击提交****文件按钮。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈: 如果你对这本书的任何方面有疑问,请通过客户关怀@packtpub.com 给我们发邮件,并在邮件的主题中提及书名。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们非常感谢你能向我们报告。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供给我们地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《PHP 8 测试驱动开发》,我们很乐意听听您的想法!请点击此处直接进入此书的 Amazon 评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此停止,您还可以获得独家折扣、时事通讯和每日免费内容的访问权限
按照以下简单步骤获取福利:
- 扫描二维码或访问以下链接

packt.link/free-ebook/978-1-80323-075-7
-
提交您的购买证明
-
就这些!我们将直接将免费 PDF 和其他福利发送到您的邮箱
第一部分 – 技术背景和设置
在本书的这一部分,您将学习 PHP 中的测试驱动开发和面向对象编程的概念,将业务需求解释为可执行列表,并使用 Docker 容器设置开发环境。
本节包括以下章节:
-
第一章,什么是测试驱动开发,为什么在 PHP 中使用它?
-
第二章,理解和组织我们项目的业务需求
-
第三章,使用 Docker 容器设置我们的开发环境
-
第四章,在 PHP 中使用面向对象编程
第一章:什么是测试驱动开发以及为什么在 PHP 中使用它?
使用 PHP 编程语言开发 Web 应用程序既有趣又高效。开始学习 PHP 的曲线相对较浅,这是编程语言的一个重要特性。对于 PHP 开发者来说,有许多开源的学习材料、框架、包和完整的可扩展产品,这些都得到了一个非常庞大的开源社区的支撑。PHP 是一种企业级编程语言,被广泛用作基于 Web 的解决方案来解决不同的商业问题。企业和开发者可以快速使用 PHP 开发和部署 Web 应用程序。一旦这些企业开始成功并增长,他们就需要更多的功能、错误修复和改进来发布在原始解决方案之上。这正是事情变得有趣的地方。维护商业成功的软件可能是软件成本的最大贡献因素之一,尤其是当它不是从开始就易于维护或测试的时候。实施测试驱动开发(TDD)将提高软件的可维护性,并有助于减少功能上市的成本和时间。
我们中的大多数开发者可能已经经历过或观察到一个问题:一个功能或错误修复已经发布,但它导致了更多的问题、回归或意外的软件行为。如果你来自一个大多数或所有质量保证(QA)测试都是在开发后、前或甚至在开发过程中手动完成的开发环境,那么你可能已经体验到了我提到的问题。这就是实施 TDD 真正能帮助的地方。TDD 不仅有助于实施自动化测试,而且以某种方式引导或甚至强迫我们开发更干净、更松散耦合的代码。TDD 帮助开发者甚至在编写单个功能代码之前就编写和构建测试——这有助于确保每当编写功能或解决方案代码时,已经为其编写了相应的测试。它还帮助我们开发者停止说“我稍后会添加我的单元 测试。”
在编写任何代码之前,理解 TDD 是什么以及它不是什么非常重要。关于 TDD 存在一些常见的误解,我们需要澄清这些误解,以便我们能够专注于 TDD 的真正含义。在本章中,我们还将尝试使用一个非常简单的类比,并强调为什么我们想要将 TDD 作为软件开发项目的一部分来实施。
在本章中,我们将涵盖以下内容:
-
什么是 TDD?
-
关于 TDD 的常见误解
-
我们为什么要考虑 TDD?
-
我们在这本书中计划实现什么?
什么是 TDD?
TDD 是一种简单的软件开发方式,我们在开始编写解决问题的实际代码之前,先思考并定义程序需要达到的结果。
TDD 是一种软件开发过程,其中在编写解决实际问题的实际代码之前,先开发测试用例。这些测试用例将以 PHP 代码的形式编写,将使用或调用开发者将要构建的解决方案代码。你构建的测试用例代码将触发你将要编写的解决实际问题的解决方案代码的开发。
从我所见到的来看,这种字面上的描述让很多开发者失去了应用这一过程的动力。TDD 是一个过程,它是一种思维方式。它不仅仅是关于编写单元测试。
你编写的测试程序在第一次运行时应该总是失败,因为你还没有构建测试需要通过的程序。然后,你基本上必须构建测试程序将使用的解决方案代码,直到测试程序本身从你的解决方案代码中获得预期的结果。实际上,失败的测试将驱使你编写通过测试的代码——这就是 TDD(测试驱动开发)这个术语的由来。甚至你可以称之为“失败的 TDD”。这就像说“我编写了一个测试来让我的代码失败,现在我需要修复它。”
在 TDD 中,我可以看到四个主要的原因说明为什么先编写一个失败的测试很重要。首先,你会编写一个失败的测试并确保你的测试框架应用能够识别它。这确保了你的开发环境配置得当,你可以运行你的测试程序。其次,你的失败的测试将帮助你定义你想要编写的解决方案或功能代码,以及该测试通过时预期的内容。这将帮助你作为开发者,在设置或集中你的思维在你要编写的功能代码的目的上。第三,你编写的失败的测试将作为提醒,让你知道还需要完成哪些其他程序。第四,先编写测试将有助于确保你的解决方案代码被自动化测试覆盖。
通过尝试使你的解决方案代码可单元测试,你有时无意中使你的代码耦合度降低——就像一个循环。随着你继续编写松散耦合的代码,你会发现你的代码开始看起来更有组织,不那么混乱。随着你继续按照 TDD 过程编写解决方案代码,它将不断地帮助你发现产品中紧密耦合的地方,有时甚至鼓励你重构和解除耦合代码,以便使其可单元测试。有一些软件开发原则可以帮助你进一步提高你的代码,例如单一职责原则,这将在第八章“使用 TDD 与 SOLID 原则”中进一步讨论。
现在我们已经定义了 TDD,并对它有一个简要的了解,让我们来看看与 TDD 相关的常见误解。
关于 TDD 的常见误解
在本节中,我们将探讨一些我亲自观察到的开发者对 TDD 的误解。一次又一次,我遇到了对 TDD 理解很差的人。当我与他们谈论为什么不喜欢 TDD 时,他们有时会告诉我一些甚至与 TDD 无关的原因。
测试软件不是我的工作,因此我不需要 TDD
我自己也曾说过这句话。我曾经认为,我只需要尽可能快地生成解决方案代码,手动测试一点,然后让测试部门确保一切构建正确。这可能是我对 TDD 最大的误解。作为软件开发者,我们开发软件是为了解决问题。如果我们开发者是造成更多问题的原因,那么我们就没有做好我们的工作。
使用 TDD 进行开发是不必要的缓慢
如果这是你第一次听到这个观点,我会感到惊讶。我第一次是从一个有技术背景的客户那里听到的,而不是从开发者那里。我自己也不是 TDD 的粉丝,当时很乐意同意客户的观点。当然,编写测试代码和解决方案代码确实会更慢;毕竟,我需要在键盘上多打一些字符!
在处理企业项目时,根据我的经验,TDD 是我们避免了几个月的时间来修复错误和回归问题的关键。编写测试并拥有良好的测试覆盖率,如第 第五章 中讨论的 单元测试,将有助于确保下次有人触摸代码或添加新功能时,不会引入回归。TDD 将帮助你构建大量的自动化测试,运行这些测试比将未经测试的解决方案代码交给测试团队或测试公司进行手动测试更便宜、更快。
编写自动化或单元测试是 TDD
TDD 并非关于为现有功能编写自动化测试或单元测试。TDD 也不是指让 QA 部门或第三方公司为现有软件编写自动化测试。这与 TDD 完全相反。
我观察到的最常见的误解是,一些开发者和测试人员认为 TDD 与测试人员为开发者构建的代码编写自动化测试有关。我认为这是一个非常错误的误解。这和开发一个程序然后将其发送到 QA 部门进行手动测试没有区别。
让测试人员编写自动化功能测试是非常好的事情,特别是对于没有自动化测试的现有功能来说,但这应该只被视为软件的补充测试覆盖率,不应与 TDD 混淆。
TDD 是一个银弹
我遇到的最后一个误解是,如果我们开发者通过遵循 TDD 构建了优秀的测试覆盖率,我们就不再需要来自软件开发部门和 QA 部门或团队的输入。一次又一次,我证明了自己是错的,认为通过 TDD 方法编写的代码是坚不可摧的。我很幸运能与知识渊博和技能娴熟的软件工程师和测试工程师一起工作。代码审查是至关重要的;始终让你的代码和测试场景得到同行评审。开发者可能忽略的边缘情况测试和功能场景会导致问题——在我的经验中,它们已经导致了大问题。
对于开发和测试团队来说,正确理解功能测试和验收测试用例非常重要,以便涵盖所有可想象的情况:不同类型的测试将在第五章,单元测试中涵盖。这就是行为驱动开发(BDD)开始变得有意义的地方;BDD 将在第六章,应用行为驱动开发中更详细地讨论。我和能够想出我无法想象的边缘情况的测试工程师和 QA 人员一起工作过。
我们已经讨论了一些关于 TDD 的常见误解。现在让我们尝试说明为什么我们想要考虑在我们的开发过程中使用 TDD。
我们为什么要考虑 TDD 呢?
我为什么要让我的代码由测试驱动呢?我希望我的代码是由需求和满意的客户驱动的!你可能听说过 TDD 这个术语,并且对此感到不舒服。当我第一次听到 TDD 这个术语时,我也有些不舒服。为什么要浪费时间编写测试代码来测试尚未存在的解决方案代码呢?说真的,我需要编写解决业务问题的实际代码,而你却想让我先写测试代码?实际上,我培训过的一些开发人员和我一起工作时也有过同样的疑问——这正是阻止他们对 TDD 产生兴趣的问题!
当我开始我的软件开发生涯时,我在一家小公司工作,我们被要求尽快交付结果,在非常少的迭代中完成。仅仅想到为我的快速编写的代码编写自动化测试就是一大浪费!因此,当我第一次读到 TDD 时,我没有兴趣。我忽视了那些肉丸意大利面式的代码;我所关心的是确保客户在最短的时间内得到预期的业务结果。将由糟糕的代码引起的回归问题留到以后解决。我需要尽快让客户满意——那就是,现在。这可能是我在职业生涯中犯过的最短视的错误之一。大多数时候,我和我的同事不得不添加功能并维护我们自己的意大利面式混乱。一次又一次,当我们看到我们造成的混乱时,我们都会讨厌过去的自己。作为软件开发者,我们职业生涯的早期有很多错误、低效和短视。幸运的是,我们不是第一个遇到这些问题的。有一些流程我们可以遵循,以帮助我们提高我们生产的软件质量,其中之一就是 TDD。
现在,经过这么多错误、这么多失败,以及参与数百个业务关键软件项目后,我甚至无法想象没有遵循 TDD(测试驱动开发)来编写测试就能过一天。当我在一个项目上工作时,我不知道我的自动化测试是否通过或失败,我就无法好好睡觉;至少我有测试!
想象一下在你的手机上创建一个“清洁我的家”待办事项清单:上面只有一个项目,那就是“清洁咖啡机”。你写下这个项目,然后分心忘记了,继续你的一天。当你再次查看你的清单时,你会意识到你还没有清洁咖啡机!然后你继续清洁机器,并将该项目标记为已完成。
好吧,这有点像 TDD 的工作方式。你先写一个失败的测试,然后编写代码来通过这个测试——然后,在你的待办事项清单上,你写下“清洁咖啡机”;然后在你实际清洁咖啡机后,你从清单上划掉它。
重要提示
在所有其他事情之前,我现在要告诉你,测试一开始就失败是很正常的,你需要非常舒适地接受它。这就像在你的手机上写下咖啡机清单项一样。一旦你把它添加到待办事项清单中,待办事项清单就会失败,直到你通过标记待办事项为完成来通过它。在编写任何通过测试的程序之前,你需要先编写失败的测试。这是红、绿、重构(RGR)概念的一部分,这将在第七章“使用 BDD 和 TDD 构建解决方案代码”中进一步讨论。
回到你的手机上,你向那个清单上添加更多项目:清洁厨房,清洁卧室,清洁浴室…然后你去了健身房,分心了。你记得你的清单,想知道在你出去之前你是否真的清洁了你的家,所以你查看你的待办事项清单。你意识到你只完成了清单上的一个项目;你必须回去完成其他任务,以完全满足“清洁我的家”待办事项清单。当你回家后,你可以继续清洁你的家,勾选你的待办事项清单:

图 1.1 – 未完成的待办事项清单
你可以将你待办事项清单上的未完成项目视为失败的测试。清洁某物的行为是编写代码以满足失败的待办事项清单项目。你完成清洁卧室或浴室的任务,就像通过了一个测试。现在想象你已经完成了所有的清洁工作,等等,你在手机上的“清洁我的家”清单上勾选了所有项目:你完成了!

图 1.2 – 完成的待办事项清单
现在,你可以想象你的“清洁我的家”清单也是一个测试。你的测试通过构建满足你的较小单元和集成测试(测试类型将在第七章“使用 BDD 和 TDD 构建解决方案代码”)的代码的整体完整性来满足。
我们可以将“清洁我的家”清单视为一个测试。这个测试涵盖了清洁一个家的所有过程。清单中的一些项目涉及清洁浴室,一些涉及厨房,等等。就像我们编写待办事项清单时做的那样,你首先编写代表更大图景的失败测试,而不是更小、更详细的测试:
// Test Method
public function testCanCleanMyHome()
{
$isMyHomeClean = $this->getCleaner()->clean();
$this->assertTrue($isMyHomeClean);
}
在编写了只能通过构建清洁房屋每个部分的程序来满足的失败的“清洁我的家”测试之后,我们可以开始编写针对解决方案较小部分的失败测试:
// Test Method
public function testCanCleanCoffeeMachine()
{
$isMyCoffeeMachineClean = $this->getCleaner()->
clean();
$this->assertTrue($isMyCoffeeMachineClean);
}
现在想象一下,在你清洁完你的家之后,你最终把卧室搞得一团糟,你清单上的“清洁我的卧室”项目没有勾选。从技术上来说,你的“清洁我的家”待办事项清单现在又是不完整的了。当你通过了所有的测试,而你的团队中的某个人或者你自己修改了代码并改变了预期行为时,也会发生同样的事情。如果你然后运行你的testCanCleanMyHome()测试,它将会失败。如果我们然后在将代码部署到生产之前运行这些自动化测试,我们就能及早捕捉到回归问题!这将更容易捕捉到破坏预期行为的代码更改!
这是一个过于简化的说法,但当你继续阅读时,你会发现这就是 TDD(测试驱动开发)的样子。这并不是一个糟糕的、浪费时间的行为!
我们是人类,我们倾向于犯错误——至少我认为是这样。尽管如果你认为你没有犯错误,你不妨把键盘上的删除键撬下来,因为你不需要它。我犯过很多错误,为了增强我对代码的信心,我确保通过所有测试并获得代码同行评审。
通过为软件实现 TDD 和拥有大量的测试覆盖率,是帮助你和你的团队在它们在生产中造成损害之前发现错误的好方法。在部署前运行所有这些不同类型的测试,有助于我晚上睡得更香。
我们在这本书中计划实现什么?
好吧,显然,我们想要更好地理解 TDD——不仅仅是理论上的,而是实际可用和可应用的理解。我们想要帮助自己编写更好的代码,这将使其他开发者在编写你的代码时也能受益。我们想要能够为编写健壮、坚固、自我诊断和更具可扩展性的软件打下基础。
我们之前使用了一个非常简单的类比,即使用“清洁我的家”待办事项列表来尝试解释 TDD 是什么以及它是如何进行的——但如果只是理论的话,这不会很有趣。在这本书中,我们将尝试使用一个示例项目来真正实现 TDD!
我们将构建一个示例项目,这将帮助我们做到以下几件事:
-
确定客户或企业想要实现的目标
-
将那些需求翻译成实际的工单
-
学习如何实现 TDD 和 BDD(行为驱动开发)
-
按照设计模式和最佳实践编写干净的代码
-
使用持续集成自动运行所有测试
-
使用持续部署自动部署我们的代码
摘要
在本章中,我们定义了 TDD 是什么以及它不是什么。我们试图将 TDD 与简单的日常任务,如清洁你家的某些部分联系起来。通过尝试澄清关于 TDD 的常见误解,我们希望对 TDD 有一个更清晰的理解。TDD 是一个过程;它不仅仅是编写单元测试和自动化测试。
我们还讨论了为什么在开发 PHP 应用程序时我们会想要使用 TDD(测试驱动开发)。TDD 帮助我们开发更干净、解耦、可维护的代码,并且它使我们更有信心,在发布代码时不会引入回归,这要归功于遵循 TDD 时固有的自动化测试覆盖率。
在下一章中,我们将通过提出一个简单的假设商业挑战来开始构建示例项目,并弄清楚需要构建什么来解决该问题。
第二章:理解和组织我们项目的业务需求
在编写任何代码之前,我们首先需要了解项目的目标和我们要解决的问题。我们构建软件是为了解决问题,如果我们没有充分理解客户或商业试图实现的目标,我们将难以提出理想的解决方案——或者更糟糕的是,我们可能会花费数月时间构建根本不满足业务需求的软件。
作为软件开发者,有一个清晰的待建列表是非常好的。这就像有一个简单的购物清单。这个列表将帮助我们确定哪些功能需要首先开发或发布。因此,在我们通过编写软件来构建解决问题的解决方案之前,我们将尝试提出一个简单的例子,在这个例子中,我们将尝试将商业问题和目标解释为需要编写代码的软件功能列表。
在本章中,我们将提出一个示例商业问题。我们将尝试分析这个示例商业试图实现的目标,以及阻止它实现这一目标的原因。我们将提出一个解决方案,帮助我们定义和组织业务需求,形成一个可操作的用户故事列表。这些用户故事反过来将被用来表示我们的行为驱动测试。这些行为驱动测试将帮助我们构建我们的测试驱动代码。行为驱动开发(BDD)将在第六章 应用行为驱动开发中更详细地讨论。

图 2.1 – 使用 TDD 和 BDD 进行构建
与测试驱动开发(TDD)和 BDD 相比,开发者也可以直接为用户故事或需求编写解决方案代码。这是一个未来灾难的配方。事实上,这就是我过去开发软件的方式:

图 2.2 – 不进行测试的开发解决方案
在这本书中,我们将专注于基于业务需求构建解决方案代码,这些需求由测试程序表示。那么,让我们开始了解示例商业问题和需求。
在本章中,我们将涵盖以下内容:
-
示例商业项目
-
将业务需求分解为软件功能
-
将 Jira 与 Bitbucket 集成
技术需求
本章要求读者具备 Git 版本控制的基本知识。
示例商业项目
我们将使用一个简单的例子——汽车博物馆——来帮助我们通过定义目标和问题,并将它们组织成可操作的单元。
业务场景
几个月前,我和我在珀斯的几个朋友一起喝啤酒,他们告诉我,他们为了乐趣在周末志愿帮助一个汽车博物馆整理收到的玩具汽车模型的库存。博物馆收到了装满玩具汽车的巨大箱子。
在喝酒的时候,他们告诉我,这既有趣又有时具有挑战性,因为没有特定的工具或流程来列出博物馆收到的玩具汽车。有时,他们打开一个装满玩具汽车的整个箱子,没有任何关于玩具的信息,有时整个箱子都装着包装好的玩具汽车,每个玩具汽车的箱子里都有所有信息,比如制造年份、驾驶它的赛车手、颜色等等。他们必须列出这些玩具并将它们放入电子表格中,这就是事情变得有趣的地方。
他们说他们使用了一个电子表格文件,他们会将其共享并转交给下一个志愿者。记录玩具汽车的信息是一项任务——例如,有人将玩具汽车的颜色记录为蓝色,但另一个人可能只是写下“火焰”,这显然不是一种颜色。有时颜色会被拼写错误。所以,现在,如果他们想要搜索所有蓝色汽车,可能会变得复杂,因为有时人们在保存信息时可能会将“blue”拼写错误。他们还希望能够搜索特定赛车手驾驶的模型汽车,并希望发现玩具汽车模型在博物馆的哪个部分。
在聊天过程中,我不禁开始想象数据被输入、提交、验证、处理,然后持久化。我暂时忘记了饮料,我的思绪开始飘忽不定,开始想象代码。如果你是软件开发者,你大概知道我的意思!
我想:我可以利用这个现实生活中的挑战,并将其作为一个业务项目的例子。这很简单,听起来很有趣,但也将作为一个很好的例子来展示如何实现 TDD 和 BDD。
我问我的朋友们是否可以让我使用他们的例子来完成这个项目,他们对此表示很高兴。所以,从现在开始,我们将把他们称为我们的客户或商家。
理解场景和问题
通过与商家或客户交谈,我们可以获取一些关于他们所遇到问题的非常重要的信息。我们可以尝试列出一个非常粗略和简单的关于问题、约束和可能解决方案的清单。
商家试图 实现的目标:
-
库存管理员或志愿者能够列出并记录玩具汽车模型信息
-
用户能够搜索记录,并找到博物馆中可以查看汽车模型的哪个部分。
问题/挑战:
-
没有系统允许不同用户同时记录汽车模型数据
-
没有系统验证存储的数据——例如,对于颜色拼写
-
没有简单的方法让游客找到或搜索博物馆中特定车型展示的位置
约束:
-
该项目仅关注玩具车模型的库存
-
只有注册的博物馆工作人员才能将数据输入到库存中
解决方案:
-
为玩具车模型构建一个简单的库存系统
-
为访客构建一个带有表格的简单页面以查看库存
我们能够列出关于业务场景的非常重要的几点。最终,我们所列出的所有项目都将满足一件事,那就是解决业务试图实现的目标。这是最重要的理解。我们通过首先正确理解问题来构建解决方案。在解决问题之后,我们必须确保我们已经解决了业务试图实现的目标。

图 2.3 – 解决方案
我有过一次经历,我看到开发者花费大量时间构建软件,当项目交给我时,我意识到他们所走的方向是完全错误的。所以,不管软件有多好——如果它不能解决实际问题,如果它不能帮助实现业务目标,那么它就没有价值。
现在我们已经定义了业务试图实现的目标,并确定了业务面临的问题和挑战,我们现在可以开始组织一个我们需要做的事情的列表来解决这些问题。
将业务需求分解为软件功能
我们需要一种解决方案来组织我们所有需要工作的任务。我们可以通过使用笔记本、便签纸、便利贴,甚至一个简单的看板来实现这一点。但鉴于我们计划开发可维护的软件,并且我们希望对开发过程的进度有可见性,拥有比笔记本更强大的解决方案会更好。
在这个项目中,我们将使用Atlassian Jira。Jira 是我们可以用来自组织所有我们需要构建和工作的项目的软件。我们将将其用作一个增强型笔记本,以包含我们的软件功能待办事项列表。
我们不会深入关注方法论;相反,我们将使用 Jira 来帮助我们组织项目,以便我们可以有组织地开始编写软件。
我们希望有能力将我们的 Git 分支、拉取请求和提交链接到我们正在工作的一个问题。我们希望有能力将我们的持续集成(CI)(第九章**, 持续集成)解决方案集成到我们的问题中。你可以将Jira 问题视为代表业务问题或软件功能的列表项。
我们将开始将业务需求分解为软件功能列表,这些功能由用户故事表示,这是 Jira 中的一个问题类型。但为此,我们首先需要创建一个 Jira 账户并初始化我们的项目。
创建 Jira Cloud 账户并初始化 Jira Cloud 项目
要创建 Jira Cloud 账户并初始化 Jira Cloud 项目,请按照以下步骤操作:
- 要开始,我们首先需要创建一个免费的 Jira 账户。你可以通过访问以下网址来完成:
www.atlassian.com/try/cloud/signup?bundle=jira-software&edition=free。然后,注册一个账户:

图 2.4 – Jira 注册
- 注册账户后,你将被提示在您的站点字段中输入一个子域名名称。使用你想要的任何子域名。完成此步骤后,你可以跳过所有其他弹出窗口,直到你到达项目 模板页面:

图 2.5 – 输入子域名名称
- 在项目模板页面,选择Scrum:

图 2.6 – 选择 Scrum
- 在Scrum页面,点击屏幕右上角的使用模板按钮:

图 2.7 – 使用模板按钮
- 在选择项目类型页面,点击选择一个 团队管理项目:

图 2.8 – 选择团队管理项目
- 将项目名称设为
toycarmodels,并在键字段中输入TOYC:

图 2.9 – 添加项目详情
- 经过这些步骤后,你应该会看到一个空的TOYC 看板页面:

图 2.10 – 空的 TOYC 看板
现在我们有一个可以操作的 Jira 项目,我们可以开始创建代表我们需要完成的任务以帮助餐厅业务进行在线预订系统项目的票据。我们将使用Jira 史诗来分组相关的 Jira 问题,这些问题代表我们想要构建的软件功能。
创建 Jira 史诗
如果我们回顾一下之前定义的问题和解决方案,我们可以列出一些简单的事项,我们可以将其作为功能来构建。
我们已经确定,我们想要为库存管理员志愿者提供一个记录捐赠玩具车模型到库存的方法,我们也确定了博物馆参观者需要通过库存搜索并定位玩具车模型在博物馆的展示位置的需求。现在,我们已经确定我们将有两种类型的用户:
-
需要输入和存储数据(库存管理员)
-
想要查看数据(博物馆参观者)
这意味着我们可以将我们需要构建的程序分成两个独立的小项目:一个是为存货管理员的小项目,另一个是为访客的小项目。有时,将一个大项目的不同部分分割成各自的小项目是有帮助的;这可以帮助为项目的不同区域分配不同的开发者,也可以帮助开发者集中精力。
面向存货管理员的解决方案可能包含许多小任务。我们可以开始将这些任务定义为实际的 Jira 票据,但在考虑更小的票据之前,让我们先创建一个史诗。
让我们回到上一组指令中的 Jira TOYC 板页面,并开始创建票据:
- 在左侧菜单中,点击路线图菜单项:

图 2.11 – Jira 路线图
- 在表格中,点击
需要做什么?:

图 2.12 – 史诗标题
- 在文本框中输入
Inventory Clerk Solution,然后按 Enter:

图 2.13 – 第一个史诗
- 点击您刚刚创建的史诗,将弹出一个窗口。在描述文本区域,您可以输入任何描述,详细说明我们需要做的事情。在这个例子中,我将只使用以下文本:
使存货管理员能够输入和存储玩具车模型数据,然后在分配给字段下,点击分配给我链接:

图 2.14 – 史诗描述

图 2.15 – 分配者
- 关闭弹出窗口。
现在我们已经创建了一个 Jira 史诗,我们可以向这个史诗添加 项目。我们将添加到史诗中的项目将是用户故事。
编写用户故事
一个 Jira 用户故事 是从最终用户的角度以非正式的、描述性和表达性的方式编写的软件功能。最终用户可以是非技术客户或将成为即将生产的软件消费者的软件开发者。
在上一节中,我们创建了一个名为存货管理员解决方案的史诗,它将包含存货管理员记录所有捐赠玩具车型号信息的所有所需内容。
但存货管理员需要什么才能实现这个目标呢?首先,他们需要一个包含表单的窗口或网页。然后,这个表单将被用来记录特定玩具车型号的数据。
但存货管理员如何向系统标识自己呢?看起来我们需要身份验证和授权功能,有了这些功能,我们就能将系统数据输入部分仅限于授权用户。
在存货管理员完成身份验证后,他们应该能够开始将玩具车型号数据输入和记录到系统中。
现在,让我们坚持这些简单的功能并开始编写故事。
仅通过思考库存管理员需要能够做什么来输入和记录玩具车模型到系统中,我们就已经确定了至少三个用户故事。
我们可以使用以下模板来编写我们自己的用户故事:
作为[参与者],[我想要],[以便]
-
作为库存管理员,我希望能够登录到系统,以便我可以访问库存系统
-
作为库存管理员,我希望能够输入玩具车模型数据,以便我可以保存数据
-
作为库存管理员,我希望能够看到包含记录的表格,以便我可以审查所有存储的玩具车型号数据
现在我们已经列出了所需的客户用户故事,让我们为他们创建工单。
创建 Jira 用户故事
按照以下步骤创建 Jira 用户故事:
- 鼠标悬停在
需要做什么?上:

图 2.16 – 需要做什么?文本:用户故事标题
- 在文本字段左侧的下拉菜单中,确保故事被选中:

图 2.17 – 从下拉菜单中选择故事
- 在文本字段中,为我们的第一个用户故事输入标题,
作为库存管理员,我希望能够登录到系统,以便我可以访问库存系统,然后按Enter键:

图 2.18 – 第一个用户故事
- 重复步骤以创建我们列出的三个故事:

图 2.19 – 带有用户故事的史诗
这些用户故事将在我们即将编写的第六章中发挥非常重要的作用,即应用 行为驱动开发。
现在我们已经为库存管理员解决方案史诗创建了第一个史诗和用户故事,重复这些步骤来创建访客史诗和用户故事。您可以使用以下史诗名称和用户故事:
管理员史诗名称:访客页面
用户故事:
-
作为访客,我希望看到包含汽车型号信息的公共页面,以便我可以浏览库存
-
作为访客,我希望能够过滤表格,以便我可以缩小结果:

图 2.20 – 史诗和用户故事
在这些步骤之后,我们现在将有一些作为客户和作为管理员想要实现的事情的列表。我们想要实现的事情的列表被称为用户故事,我们通过史诗对其进行分组。在编写代码之前,我们希望能够在某个地方存储和版本控制我们的代码。我们将使用 Bitbucket 作为我们的版本控制解决方案。
将 Jira 与 Bitbucket 集成
我们需要一个 Git 仓库来存放我们的代码,如果能将其与我们需要构建的任务列表(Jira 工单)集成那就太好了。Jira 提供了许多软件集成解决方案,这真是太好了。Jira 可以与我们的代码仓库集成,以及与 CI 解决方案集成,这些将在第九章 持续集成中讨论。
现在有许多 Git 版本控制托管软件,但在这个项目中,我们将使用 Bitbucket,以便我们可以快速将其与 Jira 集成,并将更多宝贵的时间用于实际的编码。
首先,您需要为这个示例项目创建一个 Bitbucket 账户。访问bitbucket.org/product 并注册一个账户。如果您已经有了现有的 Bitbucket 账户,那也可以使用。
集成 Bitbucket 与 Jira Cloud 有一套详细的说明;请遵循 https://support.atlassian.com/jira-software-cloud/docs/enable-code/ 或 support.atlassian.com/jira-cloud-administration/docs/connect-jira-cloud-to-bitbucket/上的说明。
您也可以直接点击左侧菜单上的代码链接:

图 2.21 – 代码页面
点击主面板上的连接到 Bitbucket按钮。然后您可以按照说明将您的 Jira 项目连接到您选择的 Bitbucket 账户。
接下来,点击左侧菜单上的项目设置链接:

图 2.22 – 项目设置菜单项
在项目设置页面内,点击功能菜单项。一旦进入功能页面,在开发组中,点击代码部分内的配置…按钮:

图 2.23 – 代码配置…按钮
点击配置…按钮后,您将被重定向到一个工具链页面;在构建部分,点击+ 添加 仓库按钮:

图 2.24 – + 添加仓库按钮
将会出现一个弹出窗口—选择您想与项目一起使用的任何 Git 解决方案。在我的情况下,我正在使用一个免费的 Bitbucket 账户:

图 2.25 – 选择 Git 产品
点击Bitbucket后,您现在可以输入一个您想与 Jira 项目集成的新的仓库名称。或者,您也可以选择您在自己的账户中创建的现有仓库。在这个示例中,我将只创建一个新的仓库:

图 2.26 – 为 Jira 项目创建新仓库
点击 + 创建 按钮,现在你的 Jira 项目应该已经与你的 Git 仓库集成:

图 2.27 – 与 Jira 集成的仓库
现在,你可以通过点击 返回项目 链接回到项目窗口;然后,点击 路线图 链接。点击我们之前创建的 库存管理员解决方案史诗,你应该在我们的票据中看到一个新的 开发 部分:

图 2.28 – 票据:带有创建分支链接的新开发部分
现在一切配置就绪,你应该能够从你的 Jira 票据中创建 Bitbucket 分支。在 库存管理员解决方案 弹出窗口中,点击 创建分支 链接。你将被重定向到你的 Git 仓库页面,在那里你可以继续创建分支:

图 2.29 – 为史诗创建新分支
返回 路线图 页面,刷新页面。再次打开 库存管理员解决方案史诗,这次,在 开发 部分中,你会看到现在有一个分支连接到我们的 Jira 史诗:

图 2.30 – 一个连接到 Jira 史诗的分支
现在,我们有了使用 Jira 创建 Git 仓库的能力。随着项目的进行,这将非常有用,因为这意味着我们的 Git 仓库现在已正确集成到我们的票据解决方案中。
我们将在这个示例项目中使用 BitBucket,但你完全可以选择你自己的首选版本控制系统并将其与 Jira 集成。
摘要
在本章中,我们能够基于朋友在汽车博物馆遇到的真实问题创建了一个示例项目。通过试图理解业务或客户想要实现的目标,我们能够确定阻碍业务或客户实现该目标的问题或挑战。通过分析我们针对问题的解决方案,我们能够制定解决方案的计划。我们将解决方案列成用户故事,并将它们分组在一个史诗下。
这些用户故事告诉我们关于行为的信息——确切地说,是系统行为。为什么这些很重要呢?好吧,通过理解我们需要构建什么,我们开发者可以更好地规划如何构建解决方案。我见过软件被构建了数周或数月,完成后,开发者和业务分析师才意识到该解决方案甚至没有解决阻碍业务或客户实现其目标的根本问题,仅仅是因为业务分析师和开发者没有理解业务试图实现或解决的问题。
通过拥有一份清晰的待建项目列表,我们将能够制定出自动测试,以检查我们是否满足了预期的行为。我们将在第六章中构建这些自动化的行为驱动测试,即应用行为驱动开发。
在下一章中,我们将介绍Docker 容器并构建我们自己的容器。这个容器将被用来包含并运行我们将构建以实现业务目标的 PHP 软件解决方案。
第三章:使用 Docker 容器设置我们的开发环境
“在我的机器上它运行正常”这句话你可能作为软件开发者已经听过了;你可能甚至自己也说过。我确信我也有过!公平地说,我的同事本·汉森,当时是我们的测试自动化工程师,也指出他也可以对我们开发者说,“在我的机器上它不运行正常”。作为一名开发者,我自己也经历过由于在不同环境中运行相同的 PHP 应用程序,服务器设置不一致而导致的许多令人沮丧的经历。在我的职业生涯中,我有时很难在我的本地开发机器上复制一些生产环境中的错误。我们会发现,我们正在开发的 PHP 应用程序将被部署在不同的操作系统上,安装了各种库,这些库与开发者的开发环境设置不同步。这是一场噩梦!
当我们开发 PHP 应用程序时,我们需要确保我们开发的程序在不同部署环境(如预发布或生产环境)中表现一致。此外,当一位新开发者加入您的团队时,如果他们能够轻松快速地在他们的开发机器上设置本地开发环境,那就太好了。容器可以帮助解决这些挑战。
在我们的示例项目中,我们需要一个开发环境来一致地运行我们的 PHP 应用程序,无论我们在哪里部署它。如果我们的 PHP 测试和应用程序在我们的本地机器上通过并正确运行,那么当我们部署它们时,它们也应该通过并正确运行。
我们将介绍容器概念,并定义它们是什么。我们将学习如何创建 Docker 镜像并运行 Docker 容器。Docker 容器将帮助我们轻松打包我们的应用程序,使其在其他服务器环境中更一致地运行。
在本章中,我们将定义并涵盖以下内容:
-
什么是容器?
-
设置我们的 PHP 开发容器
-
运行 Docker 容器
技术要求
本章要求您能够访问Bash。如果您使用的是 Mac 或 Linux 操作系统,您可以直接使用终端。如果您使用的是 Windows 操作系统,您将需要安装第三方 Bash 程序。
对于本章中定义的说明,您可以在第三章 Git 仓库中找到的完整 Docker 设置进行实验,该仓库位于github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%203。
快速设置
要使用 Docker 容器运行本章的完整开发设置,在 macOS 开发机器上,请遵循本章中的说明安装 Docker Desktop,然后只需从您的终端运行以下命令:
curl -Lo phptdd.zip "https://github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%203.zip" && unzip -o phptdd.zip && cd complete && ./demoSetup.sh
什么是容器?
容器是一种打包解决方案,它将包含应用程序运行正确所需的全部软件依赖项。不同的容器平台可供选择,但在这本书中我们将使用 Docker。我们将使用 Docker 为我们的示例项目构建和运行容器。
Docker 是一种开源的容器化解决方案,它将使我们能够打包我们的 PHP 解决方案,并在不同的开发机器和部署环境中运行,包括我们的 持续集成 (CI) 解决方案,这将在 第九章 持续集成 中进一步讨论。
现在我们已经定义了容器是什么以及我们将为我们的项目使用的容器化解决方案,让我们开始设置我们的开发容器。
设置我们的 PHP 开发容器
我们需要一个 PHP 应用程序的开发环境。我们将把我们的开发环境结构分为两个主要部分:Docker 容器(服务器)和 PHP 应用程序。
docker 目录将包含以下内容:
-
Dockerfile -
docker-compose.yml -
服务器配置
codebase 目录将作为以下用途:
-
PHP 应用程序的根目录
-
composer 包的供应商目录
现在,让我们设置我们的 PHP 开发容器:
- 在您的机器上创建以下目录结构:

图 3.1 – 基础树
-
运行以下命令:
$ mkdir -p ~/phptdd/docker$ mkdir -p ~/phptdd/codebase -
安装 Docker Desktop。在我们创建所需的 Docker 文件之前,我们需要下载并安装 Docker Desktop。
对于 macOS 用户,可以从 docs.docker.com/desktop/mac/install/ 下载 Docker。
对于 Windows 用户,下载链接是 https://docs.docker.com/desktop/windows/install/。
一旦 Docker Desktop 已安装在您的 macOS 机器上,我们现在可以组织我们的开发目录。
-
我们需要在
phptdd/docker/目录内创建一个Dockerfile。创建一个phptdd/docker/Dockerfile文件,如下所示:FROM php:8.1.3RC1-apache-busterRUN docker-php-ext-install mysqli pdo pdo_mysql
我们使用 FROM 关键字在第一行声明基本 Docker 镜像。每个 Dockerfile 都必须以这个关键字开始。第二行是运行在当前镜像之上的指令。由于我们还需要为我们的项目安装数据库,我们可以安装所需的 MySQL 扩展。
对于我们的示例项目,我们需要的不仅仅是 PHP:我们还需要数据库、Web 服务器和其他工具。因此,我们需要一种方式来运行和组织多个容器。让我们看看如何做到这一点。
创建多个容器
要运行多个容器,我们可以使用在安装 Docker Desktop 时预先安装的 Compose 工具。
创建一个 phptdd/docker/docker-compose.yml 文件,如下所示:
version: "3.7"
services:
# Web Server
server-web:
build:
dockerfile: ./Dockerfile
context: .
restart: always
volumes:
- "../codebase/:/var/www/html/"
ports:
- "8081:80"
# MySQL Database
server-mysql:
image: mysql:8.0.19
restart: always
environment:
MYSQL_ROOT_PASSWORD: mypassword
volumes:
- mysql-data:/var/lib/mysql
# Optional MySQL Management Tool
phpmyadmin:
image: phpmyadmin/phpmyadmin:5.0.1
restart: always
environment:
PMA_HOST: server-mysql
PMA_USER: root
PMA_PASSWORD: mypassword
ports:
- "3333:80"
volumes:
mysql-data:
在我们的 docker-compose.yml 文件中,我们声明并配置了三个主要容器,它们都可以通过不同的端口访问:
-
server-web -
server-mysql -
app-phpmyadmin
我们能够安装 Docker Desktop 并创建所需的 Dockerfile 和 docker-compose.yml 文件,其中包含构建我们容器的模板。让我们尝试运行所有容器,并确保它们配置正确且可以相互通信。
运行 Docker 容器
现在我们已经有了所需的两个基础 Docker 文件,让我们创建一个示例 PHP 程序,以确保我们的容器按预期工作。
创建一个 phptdd/codebase/index.php PHP 文件,如下所示:
<?php
$dbHost = "server-mysql";
$dbUsername = "root";
$dbPassword = "mypassword";
$dbName = "mysql";
try {
$conn = new PDO("mysql:host=$dbHost;dbname=$dbName",
$dbUsername, $dbPassword);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::
ERRMODE_EXCEPTION);
echo "MySQL: Connected successfully";
} catch(PDOException $e) {
echo "Connection failed: " . $e->getMessage();
}
// Show PHP info:
phpinfo();
?>
在这个阶段,我们已经创建了三个文件,并具有以下目录结构:

图 3.2 – 基础文件
让我们逐层查看这个目录结构:
-
Dockerfile:声明我们使用哪个基础 Docker 镜像的文件,并添加了安装 MySQL 扩展的指令 -
docker-compose.yml:添加了配置的文件,我们希望运行的三个基础容器将作为我们的 Linux Apache MySQL PHP (LAMP)堆栈 -
index.php:我们创建的测试 PHP 文件,用于测试 PHP 应用程序是否能够连接到 MySQL 容器并显示一些关于 PHP 服务器的详细信息
在能够运行我们的容器之前,我们需要首先构建运行容器所需的主要镜像。
构建 Docker 镜像和运行容器
运行以下命令以下载基础镜像并构建我们的 Docker 镜像:
$ cd ~/phptdd/docker
$ docker-compose build
第一次运行可能需要几分钟。该命令将从 Docker 仓库中拉取我们在 Dockerfile 内声明的基镜像,并执行我们在 Dockerfile 第二行添加的 RUN 命令。
构建完成后,我们可以使用 docker-compose.yml 文件运行我们配置的三个容器:
$ docker-compose up -d
运行命令后,你应该会看到所有三个容器被创建:

图 3.3 – 创建的容器
现在,让我们看看容器是否正常运行;运行以下命令:
$ docker ps -a
你应该会看到我们创建的三个容器,状态应指示它们正在运行:

图 3.4 – 容器正在运行
从图 3.4 的列表中我们可以看到容器正在运行。
现在,让我们尝试运行我们编写的 PHP 测试程序:打开一个网页浏览器并尝试访问 127.0.0.1:8081。如果你检查 docker-compose.yml 文件,你会看到我们已声明希望将主机机的端口 8081 路由到容器的端口 80。你还可以使用 docker ps -a 命令查看运行容器的端口路由。
你应该会看到一个 server-web 容器连接到 server-mysql 容器:

图 3.5 – 测试脚本:成功的 PHP 到 MySQL 连接和 PHP 信息
我们还配置了第三个容器来为我们提供 phpMyAdmin 应用程序;这完全是可选的。使用您的网络浏览器,访问 http://127.0.0.1:3333:

图 3.6 – phpMyAdmin 屏幕
你现在应该能看到 phpMyAdmin 的主仪表板,默认数据库列在左侧列中。
目前看起来一切都很顺利。我们配置的三个容器正在正确运行,并且能够相互通信和链接。我们现在已经拥有了一个基本的 PHP 应用程序的开发环境。接下来,让我们尝试停止这些容器。
停止容器
有时候你需要停止容器,比如当你想要重新加载新的配置,或者只是想要停止不再需要的容器。
要停止正在运行的容器以及删除它们的配置,请运行以下命令:
$ docker-compose down
现在,如果你尝试通过运行 docker ps 来检查容器的状态,你会发现我们的三个容器已经从列表中消失了。你也可以使用 docker kill <container_name> 来停止特定的容器。
摘要
在本章中,我们介绍了 Docker 并使用它来创建和运行容器。容器是打包应用程序的实例。虽然我们已经看到容器是打包和隔离的,但它们也可以相互通信。例如,我们的示例 PHP 程序正在 server-web 容器中运行,然后连接到运行我们的 MySQL 服务器的 server-mysql 容器。
我们已经看到了启动容器是多么简单。我们有两个正在运行的 PHP 容器(server-web 和 app-phpmyadmin)和一个数据库容器(server-mysql),如果需要的话,我们还可以运行更多的容器。我们将使用我们创建的基础容器来构建我们的 PHP 项目;我们将继续修改我们的容器,并在过程中安装更多的库和工具。
在下一章中,我们将开始构建我们的基础 PHP 应用程序。我们将尝试解释为什么我们最初选择使用 PHP 的功能和原因。我们将安装 Laravel 框架来简化编写解决方案的过程,这样我们就可以更多地关注 测试驱动开发(TDD)本身,而不是编写大量的引导代码。
第四章:在 PHP 中使用面向对象编程
PHP 是一种自 PHP 5 以来就支持面向对象编程(OOP)功能的开源脚本编程语言。PHP 易于学习,非常灵活。难怪它如此普遍。网上有大量的开源 PHP 学习资料,以及开源库和框架。如果你计划构建一个 Web 应用程序,那么很可能会有一些可以下载的 PHP 库和框架,它们将基本满足你的需求。如果你需要 PHP 开发者来参与你的项目,你可能会很高兴地知道,实际上有很多 PHP 开发者。
作为一名 PHP 开发者,我参与了许多 PHP 项目,从小型 Web 应用程序到企业应用程序都有。我见过许多不同公司和团队开发的 PHP 应用程序。我亲眼所见并参与的一些应用程序是按照最佳实践和行业标准正确构建的,但也有一些是由胶带粘合在一起的意大利面式的混乱。尽管如此,有一点是共同的;软件写得是否好并不重要;成功的软件将需要更新。新的特性和错误修复将需要。软件越成功,用户使用软件的越多,提交的功能请求就越多,发现的错误就越多。这是一个循环,但一开始就有一个很好的问题。
没有人希望看到由新特性和错误修复引入的回归,但这种情况确实会发生。有时,如果没有适当的发展或发布流程,回归就会频繁发生。发布后可能会引入新的错误和问题,这非常令人沮丧。企业也会失去发布更多错误修复或新特性的信心。对于开发者和企业来说,发布代码应该是一种积极的体验,而不是焦虑的原因。
将 OOP 和测试驱动开发(TDD)结合使用,通过确保大多数函数和对象是可测试的、可维护的、可重用的和可模拟的,有助于提高代码质量。模拟将在第八章中更详细地讨论,即使用 TDD 与 SOLID 原则。
在本章中,我们将探讨 PHP 中面向对象编程的定义和意义。我们将介绍面向对象编程的四大支柱:抽象、封装、继承和多态。我们将尝试使用示例代码来解释面向对象的概念,这些概念将作为我们稍后为示例项目编写的测试驱动开发代码的基础。
在本章中,我们将介绍以下内容:
-
理解 PHP 中的面向对象编程
-
类与对象
-
面向对象编程中的抽象
-
面向对象编程中的封装
-
面向对象编程中的继承
-
面向对象编程中的多态
-
PHP 标准 建议(PSRs)
技术要求
用户应至少具备 PHP 或其他面向对象编程语言(如 Java 或 C#)的基本知识。我在整本书中都在使用 PHPStorm 集成开发环境(IDE),这可以在截图里看到。
在 PHP 中理解面向对象编程
我可以使用 PHP 进行测试驱动开发(TDD)吗?当然可以——而且得益于 PHP 的面向对象能力,它与 TDD 配合得很好。我们之前已经解释过,TDD 是一个过程;它不是你可以安装的软件。你可以安装或下载工具来实现 TDD,而且对于其他编程语言也有许多不同的工具可用。
自从 2000 年代初 PHP 5 发布以来,类和对象得到了支持,使得 OOP 可以在 PHP 中使用。如果读者对 OOP 有很好的理解并且参与过 PHP 面向对象编程项目,这将是一个优势;如果没有,我会尽我所能向您介绍 OOP,因为它是一种编写软件的有效且高效的方法。我们还将使用 OOP 在我们的示例项目中,因此我们需要确保读者理解 OOP。
面向对象编程(OOP)究竟是什么呢?
面向对象编程(OOP)是一种基于类和对象概念的编程风格。其目标之一是让软件开发者能够编写可重用和可扩展的代码。
面向对象编程(OOP)侧重于将行为、逻辑和属性打包成可重用的对象。这些对象是类的实例,而这些类是我们软件开发者必须编写的文件,用于包含逻辑、例程和属性。由于对象基于类,我们可以使用单个类创建许多对象的实例。我们还可以使用面向对象编程的继承特性来创建一个具有其父类能力的对象。在本章的后面部分,我们将讨论面向对象编程中类和对象之间的区别。
回到我还是初级开发者的时候,我的一个朋友,他是 JavaScript 开发者,曾经告诉我,他很难理解面向对象编程(OOP),因为他读过一些使用车辆和动物比喻的 OOP 解释。他说他不知道这些如何与他自己正在编写的实际软件相关联,所以为了我们年轻的自己,如果我有机会回到过去,我会尝试像从未使用过 OOP 一样向像我朋友和我这样的初级开发者解释 OOP。我意识到,至少对我来说,如果我理解了问题和解决方案的目的,我就能更容易地理解概念。
我看到许多开发者在这个概念上挣扎,并未能充分利用其惊人的力量。我在大学时使用 Java 和 C++学习 OOP,并在毕业后作为 C#开发者专业工作。当时,我认为 OOP 无处不在,是专业软件开发世界中编写代码的通常方式,但当我开始我的第一个专业 PHP 职位时,我在一个没有 OOP 代码的 PHP 应用程序上工作。使用 PHP,开发者可以创建一个 PHP 文件,并直接从 CLI 或网页浏览器中执行函数和代码。这太容易了!然而,这是一个滑梯。
我记得自己对自己说:“我讨厌 OOP,它太复杂了;为什么我要在 Java 和 C#中浪费时间学习 OOP,而只是为了在网页上返回文本就要写这么多代码?”果然,我最终写了很多文件,里面有很多数据库查询和很多交织在一起的业务逻辑。我基本上在一个文件中写了用户界面、业务逻辑和持久化代码。这听起来熟悉吗?如果你是那些可能从我十多年前继承下来的糟糕代码的人之一,我真诚地道歉。我用 OOP 和没有 OOP 在不同的语言中写了意大利面式的代码。OOP 并不是停止混乱的意大利面式代码的万能子弹,但 OOP 确实有帮助。它不是工具;而是你如何使用它。
我感到的兴奋并没有持续很长时间。当时作为一个初级 PHP 开发者,我写了如此多的 PHP 文件,里面充满了随机的函数。维护起来是一场噩梦,我甚至无法在其他地方重用我自己的代码!我告诉自己,如果我当时只使用了 OOP,至少可以轻松地借用或重用一些现有的代码,即使我有意大利面式的代码。现在,想象一下在一个团队中共同构建软件,你无法轻松地重用彼此的代码。我当时讨厌 PHP,但结果证明,我没有正确地使用 PHP。我不知道我可以用 PHP 来实现 OOP。我认为任何其他编程语言也是如此。语言的好坏并不重要;项目的成功取决于软件工程师、开发人员和架构师自己。你可以在任何语言中使用 OOP,仍然会产生意大利面式的代码。在这本书中,我们将使用 TDD 和软件开发最佳实践来帮助我们写出更好、更不混乱的代码。我们将讨论一些软件开发原则,这些原则将极大地帮助我们写出更好的代码,在第八章中,我们将讨论使用 TDD 与 SOLID 原则。
在真正理解面向对象编程(OOP)的真正好处之前,我花了一些时间。在网上阅读有关车辆和动物的 OOP 解释是有帮助的,但真正帮助我明白其中的奥妙的是在实际项目中工作,并体验不使用 OOP 的痛苦。
使用 OOP,开发者可以写出其他开发者(包括你自己)可以轻松借用或使用的代码。
在接下来的章节中,我们将介绍面向对象编程的基本概念 – 抽象、封装、继承和多态 – 但在那之前,我们需要开始定义类和对象是什么,以及它们的区别以及如何区分它们。
在以下示例中,我们不会使用 TDD。我们将只关注面向对象编程本身。我们将关注 PHP 面向对象编程的具体实现。
类与对象
PHP 类是一个包含代码的文件。你的类是一个实际存在于你的驱动器上的文件。如果你关闭计算机并重新启动它,类文件仍然在那里。它包含的代码是在执行期间创建对象的模板。类文件可以包含属性和行为。一旦类被实例化,属性将能够在内存中保存数据。行为将由方法或函数处理。你可以使用访问器和修改器方法来更改类的属性值。
Dog.php 类文件
<?php
namespace Animal;
class Dog
{
public function returnSound(): string
{
return "Bark";
}
}
上述示例类是一个包含命名空间声明、类名和单个方法或函数 returnSound() 的 PHP 文件,该方法返回一个 "Bark" 字符串。
另一方面,对象是类文件的实例。对象在计算机的 RAM 中物理存在,RAM 是易失性内存。这意味着如果你关闭计算机或停止程序,你会丢失对象。只有当你运行程序时,对象才会被创建。
在 PHP 中,当你执行程序时,类文件将从你的硬盘加载到 PHP,以创建一个临时存在于你的 RAM 中的对象实例。类实际上是 PHP 在程序运行时创建对象的模板。
我们将使用一个消费者类来利用或消费 Dog.php 类,并使用一个变量来保存类的实例,即一个对象:
Display.php 类文件
<?php
namespace Animal;
class Display
{
public function outputSound()
{
$dog = new Dog();
echo $dog->returnSound();
}
}
Display 类是另一个类;你可以将其视为消费者类或示例程序的起点。在这个类中,我们有一个 outputSound() 方法,它回显对象 returnSound() 方法的值。在 outputSound() 方法内部,我们为 PHP 编写了使用 new 关键字创建 Dog 类实例的指令:

图 4.1 – 将 Dog 对象分配给 $dog 变量
当 PHP 执行 outputSound() 方法时,它将在您的计算机驱动器中创建一个基于存储在您的计算机驱动器中的 Dog.php 类文件的对象,然后它将在您的计算机内存中临时存储该实例或对象。$dog 变量将与 Dog 类实例或对象映射。每次您使用对象的方法或属性时,您实际上是在从您的计算机内存中访问对象,而不是从 Dog.php 类文件中。为了进一步理解这一点,我们需要讨论 PHP 中的 引用,这将在下一个子节中介绍。
现在,由于我们已经创建了一个新的 Dog.php 类实例并将其赋值给 $dog 变量,我们将能够访问 Dog 对象的方法或函数,或者属性,这取决于它们的 可见性。我们将在本章的 面向对象编程中的封装 部分讨论可见性。由于在我们的示例中,我们将 Dog.php 类的 returnSound() 方法定义为 public,我们现在可以通过以下方式从 Display.php 类的 outputSound() 方法访问此方法:$dog->returnSound();。
PHP 中的引用和对象
那么,引用究竟是什么呢?好吧,在 PHP 中,它是一个别名或一种方式,允许一个或多个变量指向特定的内容。从之前的 Dog 对象示例中,我们已经创建了一个 Dog.php 类的实例并将其赋值给 $dog 变量。$dog 变量本身并不真正包含 Dog 对象或实例的内存地址;它仅仅包含一个标识符,以便它可以指向存储在内存中的 Dog 对象。这意味着您可以有指向同一对象的 $dog1 和 $dog2 变量。让我们修改 Dog.php 和 Display.php 类来演示这个概念。
我们将按如下方式修改 Dog.php 类:
<?php
namespace Animal;
class Dog
{
private string $sound;
public function __construct()
{
$this->setSound("Bark");
}
public function returnSound(): string
{
return $this->getSound();
}
/**
* @return string
*/
public function getSound(): string
{
return $this->sound;
}
/**
* @param string $sound
*/
public function setSound(string $sound): void
{
$this->sound = $sound;
}
}
我们将按如下方式修改 Display.php 类:
<?php
namespace Animal;
class Display
{
public function outputSound()
{
$dog1 = new Dog();
$dog2 = $dog1;
$dog1->setSound("Barky Bark");
// Will return "Barky Bark" which was set to $dog1.
echo $dog2->returnSound();
}
}
我们已经修改了 Dog.php 类,以便我们可以在实例化后更改它返回的声音。在 Display.php 类中,您会注意到我们引入了一个新的变量 $dog2,并将其赋值给 $dog1。我们只有一个 Dog 对象的实例,而 $dog1 和 $dog2 变量具有相同的标识符,并且它们引用的是同一事物。以下是表示这个概念的图示:

图 4.2 – $dog1 的属性发生的变化也会影响到 $dog2
因此,如果我们运行 $dog2->returnSound(),即使在我们将 $dog1 赋值给 $dog2 之后修改了 $sound 属性,它也会返回我们在 $dog1 中设置的更新后的字符串。
好吧,如果您不希望 $dog2 受 $dog1 属性变化的影响,但仍然想创建该对象的副本或复制品,您可以使用 PHP 的 clone 关键字,如下所示:
Display.php 类
<?php
namespace Animal;
class Display
{
public function outputSound()
{
$dog1 = new Dog();
$dog2 = clone $dog1;
$dog1->setSound("Barky Bark");
// Will return "Bark".
echo $dog2->returnSound();
}
}
这次,$dog2 将返回原始的 Bark 字符串值,这是由其构造函数分配给 $dog1 的 $sound 属性。以下是一个图表,供你参考以理解这一点:

图 4.3 – 克隆对象
由于在演员修改 $dog1 的 $sound 属性之前,$dog2 已经被克隆,所以 $dog2 将保留旧值。无论 $dog1 发生什么变化,都不会自动发生在 $dog2 上,因为它们不再引用内存中的同一对象:

图 4.4 – 类与对象
总结来说,PHP 类是一个包含 PHP 能够创建对象的模板的文件。当使用 new 关键字并执行时,PHP 会取用类文件,生成类文件的实例,并将其存储在计算机的内存中,这就是我们所说的对象。
现在我们已经澄清并解释了 PHP 中对象与类之间的区别,我们可以现在讨论面向对象的四个支柱,从抽象开始。
面向对象编程中的抽象
面向对象编程中的抽象是隐藏复杂性的概念,从应用程序、用户或开发者那里隐藏。你可以将一组复杂的代码或指令包装在一个函数中。该函数的名称应使用动词,这将使其更容易理解函数内部复杂指令的确切作用。
例如,你可以有一个名为 computeTotal($a, $b, $c) 的函数,它包含根据需求计算总和的逻辑或步骤。作为一个开发者,你只需使用 computeTotal 方法,无需考虑实际计算总和所涉及的复杂操作,但如果需要修复错误或理解发生了什么,那么你可以检查 computeTotal 函数内部的执行情况:
public function computeTotal(int $a, int $b, int $c): int
{
if ($c > 1) {
$total = $a + $b;
} else if ($c < 1) {
$total = $a - $b;
} else {
$total = 0;
}
return $total;
}
使用这个概念有什么好处?使用前面的例子,开发者无需担心函数内部获取总和的确切步骤顺序。开发者只需要知道有一个可用的 computeTotal 函数可以用来,以及数百或数千个其他函数,每个函数内部都有复杂的步骤指令。开发者可以专注于解决方案,无需担心每个函数内部的细节。
抽象类是实现类抽象的一种方式,是一种不能实例化的类,需要至少声明一个抽象方法。抽象类旨在被其他相关类扩展:
<?php
abstract class AbstractPrinter
{
abstract protected function print(string $message):
bool;
}
class ConsolePrinter extends AbstractPrinter
{
protected function print(string $message): bool
{
// TODO: Implement print() method.
}
}
class PdfPrinter extends AbstractPrinter
{
protected function print(string $message): bool
{
// TODO: Implement print() method.
}
}
在 AbstractPrinter 中声明的 abstract 方法也必须在扩展此方法的类中存在。现在,每个扩展 AbstractPrinter 抽象类的类都可以有自己的 print 方法的特定操作。在抽象类中声明的 abstract 方法只能声明方法的可见性、参数和返回值。它不能有自己的实现。
面向对象的封装
封装是一个概念,其中访问和修改此数据的数据和方法被封装在一个单一的单位,如胶囊中。在 PHP 中,这个胶囊是对象或类。
胶囊或对象将能够使用可见性概念来保护其数据不被读取或操作。
PHP 中的可见性
要能够在 PHP 中控制开发者可以访问或使用对象中的哪些数据或函数,可以在函数声明或属性名声明之前使用public、protected和private关键字:
-
private– 只有对象内部的代码可以访问此函数或属性 -
protected– 任何扩展此类的对象都将允许访问该函数或属性 -
public– 任何对象用户都可以访问属性或方法
那么,我们开发者能从这得到什么好处呢?我们将在稍后了解这一点。
让我们修改前面示例中的Dog.php类,如下所示:
<?php
namespace Animal;
class Dog
{
private string $sound;
private string $color;
public function __construct()
{
$this->setSound("Bark");
$this->setColor("Black");
}
public function makeSound(): string
{
$prefix = "Hello ";
$suffix = " World";
return $prefix . $this->getSound() . $suffix;
}
/**
* @return string
*/
private function getSound(): string
{
return $this->sound;
}
/**
* @param string $sound
*/
public function setSound(string $sound): void
{
$this->sound = $sound . ", my color is: " .
$this->getColor();
}
/**
* @return string
*/
protected function getColor(): string
{
return $this->color;
}
/**
* @param string $color
*/
protected function setColor(string $color): void
{
$this->color = $color;
}
}
创建一个Cavoodle.php类:
<?php
namespace Animal\Dogs;
use Animal\Dog;
class Cavoodle extends Dog
{
public function __construct()
{
parent::__construct();
// Using the protected method from the Dog class.
$this->setColor("Chocolate");
}
}
按如下方式修改Consumer.php类:
<?php
namespace Animal;
use Animal\Dogs\Cavoodle;
class Consumer
{
public function sayHello()
{
$dog = new Dog();
$dog->setSound("Wooooof!");
// Will output Hello Wooooof!, my color is: Black
$dog->makeSound();
}
public function sayHelloCavoodle()
{
$cavoodle = new Cavoodle();
$cavoodle->setSound("Bark Bark!");
// Will output Hello Bark Bark!!, my color is:
Chocolate
$cavoodle->makeSound();
}
}
在这个Dog.php示例类中,我们声明了以下内容:
-
私有:
-
$``sound -
$``color
-
-
受保护的:
-
getColor() -
setColor()
-
-
公共:
-
makeSound() -
setSound($sound)
-
通过这样做,我们已经保护了Dog对象$sound和$color属性值不被对象消费者直接修改。只有Dog对象可以直接修改这些值。存储在$sound属性中的值可以通过使用$dog->setSound($sound)方法从对象消费者端进行修改,但无论对象消费者在$dog->setSound($sound)方法中设置什么,存储在Dog对象$sound属性中的数据都将始终附加$color属性的值。对象消费者无法做任何事情来改变这一点;只有对象本身可以改变其自身属性值。
以下是对Consumer.php类的截图,当我修改它时,我的 PHPStorm IDE 会自动建议Cavoodle对象可用的方法:

图 4.5 – Dog 可用的公共函数
你会注意到在Consumer类中,我们只有两个可用的函数。setSound()和makeSound()函数是我们声明为公开可见的函数。我们已经成功限制了或保护了Cavoodle(Dog类的一个实例)对象的其它函数和属性。
以下截图显示,当我们处于Cavoodle.php类内部时,我的 IDE 会通过使用$``this键自动建议Cavoodle类本身可用的方法:

图 4.6 – Cavoodle 本身可以访问比 Consumer 类更多的函数
在 Cavoodle.php 类内部,你会注意到这个 Cavoodle 对象可以访问 getColor() 和 setColor() 方法。为什么是这样?这是因为 Cavoodle 类扩展了 Dog.php 类,继承了 Dog.php 类的非私有方法——并且由于我们已将 getColor 和 setColor 函数声明为具有受保护的可见性,这些方法对 Cavoodle 类是可用的。
访问器和修改器
由于我们已经将 $sound 和 $color 属性设置为 private,我们如何让消费者访问这些属性?对于读取数据,我们可以编写名为访问器的函数,返回存储在属性中的数据。要更改属性的值,我们可以创建名为修改器的函数来修改属性中的数据。
要访问 Dog.php 类中的 $sound 和 $color 属性,我们需要以下 访问器:
-
getSound -
getColor
要更改 Dog.php 类中 $sound 和 $color 属性的值,我们需要以下 修改器:
-
setSound -
setColor
这些函数在 Dog.php 类中声明——因为这些是函数,你可以在将值存储到属性或返回给用户之前添加额外的验证或逻辑更改。
在编写属性或函数时,一个好的做法是将它们的可见性声明得尽可能严格。从 private 开始,然后如果你认为子对象需要访问函数或属性,则将可见性设置为 protected。这样,你最终会有更少的公开方法属性。
这将只允许你和其他使用你的类的开发者看到应该对消费者可用的函数。我曾编写并看到过包含许多方法的类,但后来发现它们并不是打算由除了主对象本身之外的其他对象使用。这也有助于通过防止消费者直接修改属性来保护对象的数据完整性。如果你需要让消费者操作对象属性中存储的数据,用户可以使用修改器方法。要让他们从属性中读取数据,他们可以使用访问器。
面向对象编程中的继承
面向对象编程中的继承是一个概念,其中一个对象可以获取另一个对象的方法和属性。
继承可以帮助开发者为非常相关的对象重用函数。你可能听说过 不要重复自己 (DRY)原则;继承也有助于编写更少的代码和更少的重复,以帮助你重用代码。
Cavoodle和Dog这样的对象是相关的——我们知道Cavoodle是Dog的一种类型。Dog和Cavoodle可用的函数应该关注Dog和Cavoodle应该能够做什么。例如,如果您有一个Dog对象并且它有一个computeTax函数,那么这个函数与Dog对象无关,您可能做错了什么——它具有低内聚性。具有高内聚性意味着您的类专注于做它真正应该做的事情。通过具有高内聚性,更容易决定一个对象是否应该是一个可以继承的对象,就像Dog和Cavoodle对象一样。如果Cavoodle类扩展了JetEngine类,这就不合理了,但Cavoodle类扩展Dog类是完全合理的:
Cavoodle.php
<?php
namespace Animal\Dogs;
use Animal\Dog;
class Cavoodle extends Dog
{
public function __construct()
{
parent::__construct();
// Using the protected method from the Dog class.
$this->setColor("Chocolate");
}
}
要在消费者类中使用Cavoodle类的方法,创建一个新的Cavoodle类实例:
public function sayHelloCavoodle()
{
$cavoodle = new Cavoodle();
$cavoodle->setSound("Bark Bark!");
// Will output Hello Bark Bark!!, my color is:
Chocolate
$cavoodle->makeSound();
}
Cavoodle对象使用了extends关键字继承了Dog对象。这意味着Dog中的任何public或protected函数现在都对Cavoodle对象可用。您会注意到在Cavoodle.php类中没有声明makeSound函数,但我们仍然能够使用$cavoodle->makeSound();方法,仅仅是因为Cavoodle对象从Dog对象继承了makeSound函数。
面向对象中的多态
多态意味着许多形状或许多形式。多态是通过继承PHP 抽象类以及实现PHP 接口来实现的。
多态帮助您创建一个格式或标准,以编程方式解决特定问题,而不是仅仅关注解决方案的单个实现。
我们如何在 PHP 中应用这个功能以及使用这个功能我们能获得什么好处?让我们以下面的子节中的示例代码为例,从 PHP 抽象类开始。
PHP 抽象类中的多态
在 PHP 中使用抽象类时,我们可以通过使用抽象函数来实现多态。以下是一个 PHP 抽象类的示例:
AbstractAnimal.php
<?php
namespace Animals\Polymorphism\AbstractExample;
abstract class AbstractAnimal
{
abstract public function makeSound();
}
每个 PHP 抽象类理想情况下都应该以Abstract前缀开始,后面跟着根据 PSR 标准建议的抽象类名称。PSR 标准将在本章后面的PHP 标准建议(PSR)部分进行讨论。
抽象类至少需要声明一个函数作为抽象。这可以通过在函数的访问修饰符或可见性声明之前添加abstract关键字来实现,例如abstract public function makeSound()。现在,我们可能会注意到抽象类中的makeSound()方法没有实际的动作或逻辑,正如我们之前解释的,我们不能实例化抽象类。我们需要子类来扩展抽象类,在那里我们可以声明子类要执行的具体动作或逻辑。
以下是一个子Cat.php类的示例:
Cat.php
<?php
namespace Animals\Polymorphism\AbstractExample;
class Cat extends AbstractAnimal
{
public function makeSound(): string
{
return "meow!";
}
}
以下是一个子Cow.php类的示例:
Cow.php
<?php
namespace Animals\Polymorphism\AbstractExample;
class Cow extends AbstractAnimal
{
public function makeSound(): string
{
return "mooo!";
}
}
这两个类都继承了AbstractAnimal.php类,由于我们已经将makeSound()函数声明为abstract方法,因此Cat.php和Cow.php类也必须具有这些相同的方法,但不需要abstract关键字。你会注意到Cat对象的makeSound函数返回一个meow字符串,而Cow对象类似的makeSound函数返回一个moo字符串。在这里,我们通过一个函数签名实现多态,并且该函数签名由子类独特地实现。
接下来,我们将探讨使用 PHP 接口实现多态性。
PHP 接口的多态
PHP 接口是 PHP 抽象类的一个简化版本。接口不能像普通类那样有属性,它只能包含公开可见的函数。接口中的每个方法都必须由使用该接口的任何类实现,但不需要添加abstract关键字。因此,我们在向接口声明函数时必须非常小心。很容易最终得到一个包含太多函数的接口,这些函数对于每个实现类来说都没有意义。这就是接口分离原则发挥作用的地方,这将在第八章,使用 SOLID 原则进行 TDD中进一步讨论。
想象一下,你需要一个程序以不同的格式返回结果,并且你还希望能够隔离逻辑和依赖关系,以得出所需的结果。你可以使用接口来设置一个合约,该合约将由你的对象遵循。例如,有不同方式和格式可以返回输出,在以下示例中,我们将返回 XML 和 JSON。
我们将创建一个 PHP 接口,该接口将作为 JSON 和 XML 类都将实现的合约。该接口将有一个单一的通用打印函数,该函数接受一个字符串参数,并返回一个字符串:
PrinterInterface.php
<?php
namespace Polymorphism\InterfaceExample;
interface PrinterInterface
{
public function print(string $message): string;
}
然后,我们将创建PrinterInterface的第一个具体实现,它必须有一个具体的print函数实现,以返回一个 JSON 格式的字符串:
Json.php
<?php
namespace Polymorphism\InterfaceExample;
class Json implements PrinterInterface
{
public function print(string $message): string
{
return json_encode(['hello' => $message]);
}
}
PrinterInterface的第二个具体实现是Xml类——它也必须包含一个print函数,该函数返回一个字符串,但这次字符串将被格式化为 XML:
Xml.php
<?php
namespace Polymorphism\InterfaceExample;
class Xml implements PrinterInterface
{
public function print(string $message): string
{
return "<message>Hello " . $message . "</message>";
}
}
我们在PrinterInterface中声明了一个public print(string $message): string方法签名,并且由于Xml.php和Json.php类在类名声明后使用了implements关键字实现了PrinterInterface,因此现在Xml.php和Json.php都必须遵守这个契约。它们必须拥有public print(string $message): string函数。每个实现类都将有其自己独特的返回输出的方式。一个返回 XML,另一个返回 JSON——同一个方法,但不同的形式或形状。这就是使用 PHP 接口实现多态的方法。
然而,使用多态的优势在哪里呢?让我们以这个消费者类为例:
Display.php
<?php
namespace Polymorphism\InterfaceExample;
class Display
{
/**
* @var PrinterInterface
*/
private $printer;
public function __construct(PrinterInterface $printer)
{
$this->setPrinter($printer);
}
/**
* @param string $message
* @return string
*/
public function displayOutput(string $message): string
{
// Do some additional logic if needed:
$printerOutput = $this->getPrinter()->print
($message);
$displayOutput = "My Output is: " . $printerOutput;
return $displayOutput;
}
/**
* @return PrinterInterface
*/
public function getPrinter(): PrinterInterface
{
return $this->printer;
}
/**
* @param PrinterInterface $printer
*/
public function setPrinter(PrinterInterface $printer):
void
{
$this->printer = $printer;
}
}
在Display.php类中,我们有一个displayOutput方法,它使用必须实现PrinterInterface的对象。displayOutput方法从实现PrinterInterface的对象(我们不知道是哪个对象)获取结果,并将其作为后缀附加到一个字符串之前返回。
现在,这是重要的一点——Display.php类并不知道实现PrinterInterface的对象是如何生成实际的 XML 或 JSON 格式的。Display.php类并不关心也不担心这一点。我们已经将责任交给了实现PrinterInterface的对象。因此,我们不是使用一个包含所有返回 JSON 或 XML 输出逻辑的上帝类,从而造成一团糟,而是使用其他实现我们需要的接口的对象。Display.php类甚至不知道正在使用哪个类——它只知道它正在使用一个实现了PrinterInterface的对象。我们现在已经成功地将Display.php类从格式化 XML、JSON 或其他格式的任务中解耦到其他对象。
既然我们已经了解了 PHP 中 OOP 的基础知识,让我们来看看一些关于如何编写 PHP 代码的指南或标准。这些指南不是构建或运行 PHP 程序所必需的,但它们将帮助开发者编写更好、更易读、更易于共享的代码。以下关于如何编写 PHP 代码的标准在构建企业级应用程序时非常重要,尤其是当你需要与其他许多开发者一起开发代码时,你的代码可能会被期望使用多年。最终接管你项目的未来开发者应该能够轻松理解、阅读和重用你的代码。以下标准将帮助你和你团队实现这一点。
PHP 标准建议(PSRs)
如前所述,有很多为 PHP 构建的开源库和框架。每个开发者都会有自己的代码编写风格偏好,每个框架或库都可以有自己的标准或做事方式。这可能会对 PHP 开发者造成问题,因为我们倾向于使用很多不同的库和框架。
例如,从一个框架切换到另一个框架,结果却需要使用不同类型的服务容器,这会迫使你改变组织应用程序依赖项的方式,因此引入了 PSR-11。服务容器是管理对象实例化及其依赖项的应用程序——在实现依赖注入或 DI 时非常方便,这在第八章**,使用 SOLID 原则进行 TDD中有所讨论。这是为什么遵循某些特定指南或标准很重要,尽管不是必需的,以及 PSR 的作用所在。
什么是 PSR?
PSR 由PHP 框架互操作性小组(PHP-FIG)推荐。他们是一群非常友好的开发者,帮助我们使 PHP 编码生活更加有序。你可以在www.php-fig.org/了解更多关于 PHP-FIG 的信息。
以下是目前接受的 PSR:
-
PSR-1: 基本编码标准
-
PSR-3: 记录器接口
-
PSR-4: 自动加载标准
-
PSR-6: 缓存接口
-
PSR-7: HTTP 消息接口
-
PSR-11: 容器接口
-
PSR-12: 扩展编码风格指南(已弃用 PSR-2)
-
PSR-13: 超媒体链接
-
PSR-14: 事件调度器
-
PSR-15: HTTP 处理器
-
PSR-16: 简单缓存
-
PSR-17: HTTP 工厂
-
PSR18: HTTP 客户端
你可以在www.php-fig.org/psr/找到所有目前接受的 PSR。最初,需要熟悉的最重要 PSR 是 PSR-1、PSR-12 和 PSR-4。这有助于我们以更一致的风格编写代码,尤其是在从一个框架切换到另一个框架时。我曾经有一个“最喜欢的”PHP MVC 框架,认为我会一直使用这个框架直到我变老——就像往常一样,我错了。我最终使用了这么多不同的框架,以至于我不再关心我使用的是哪个框架。现在,我对每个具体的工作都有“最喜欢的”。
PSR 只是“建议”。它们不像需要遵循的实际法律,但如果你认真对待编写 PHP 和提升你自己的代码质量,那么我强烈建议你遵循它们。很多人已经体验过不遵循标准的痛苦。我曾经自己编写了一个依赖注入容器,结果导致我们团队的其他开发者后来在使用它时感到困惑。我只是以错误的方式重新发明了轮子。我多么希望有一个我可以遵循的标准啊!哦,对了,现在有 PSR-11 了。
摘要
在本章中,我们定义了什么是面向对象编程(OOP),以及为什么我们要利用它。然后,我们明确地定义了 PHP 中的类和对象。然后,我们针对面向对象编程的四个支柱(抽象、封装、继承和多态)中的每一个都进行了示例讲解。我们学习了抽象、封装、继承和多态是什么,以及它们在 PHP 中的工作原理。我们还简要地介绍了 PSR,因为我们不希望只是继续发明标准并开始编写代码——我们希望编写易于理解和维护的干净 PHP 代码,尤其是在企业环境中,您可能会与许多其他开发者一起工作,并且您的代码将需要在未来多年内非常易于阅读和维护。
本章应该已经为您准备好开始编写实际的面向对象 PHP 代码——在我们的 TDD 示例项目中,我们将利用 PHP 的面向对象功能。
在下一章中,我们将讨论单元测试。我们将定义什么是单元测试,以及单元测试在 TDD 中的应用。我们还将介绍不同类型的自动化测试。在了解单元测试的定义后,我们将开始编写我们的第一个单元测试,并开始执行这些单元测试。
第二部分 - 在 PHP 项目中实现测试驱动开发
在本书的这一部分,您将获得利用测试驱动开发、行为开发和自动化测试构建 PHP 应用程序所需的知识。
本节包括以下章节:
-
第五章, 单元测试
-
第六章, 应用行为驱动开发
-
第七章, 使用 BDD 和 TDD 构建解决方案代码
-
第八章, 使用 SOLID 原则进行 TDD
第五章:单元测试
想象一下,在没有自动化测试的情况下与几位其他开发者一起工作在一个项目上,一切似乎在生产中都运行得很好。然后,发现了一个错误,其中一位开发者修复了这个错误。质量保证部门批准了修复,然后推送到生产。几天后,生产中报告了另一个错误。经过调查,开发者发现新的错误是由修复其他错误的修复引起的。这听起来熟悉吗?
代码库中的一个小改动可以轻易地改变软件的行为。一个小数点的改动可能导致数百万美元的错误计算。想象一下,将这些计算检查全部推给质量保证部门进行手动测试——他们每次代码库更新时都必须运行这些检查。这太低效、太有压力,而且不可持续。
解决这个反复出现的问题的一种方法是单元测试。编写单元测试程序将帮助我们开发者验证我们的程序是否正确。通过反复运行单元测试,如果我们破坏了其他现有的测试,我们也能在开发早期就捕捉到问题。如果我们意外地改变了函数的预期行为,并且如果我们在这个函数上已经正确地编写了单元测试,那么我们可以有信心这些测试会被破坏。对我来说,这真是太神奇了。我想知道,如果我破坏了什么,我不会在确信我没有破坏任何现有的单元测试集之前,将我的代码推送到最终验证给质量保证或测试部门。对于大型产品,这将节省质量保证或测试部门大量的工时,即使有自动化端到端和用户界面到后端测试。
在本章中,我们还将讨论不同类型的测试,但单元测试是其他自动化测试类型的基础。
在本章中,我们将涵盖以下主题:
-
定义单元测试
-
编写和运行单元测试
-
设置测试覆盖率监控
-
有哪些不同类型的测试?
-
在集成测试中利用依赖注入和模拟
技术要求
本章要求您拥有我们之前构建的所有容器,以及第三章中定义的 PHPStorm IDE 配置,使用 Docker 容器设置我们的开发环境。您可以简单地从 GitHub 下载开发环境设置,并遵循第三章中提到的说明,使用 Docker 容器设置我们的开发环境:github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%203。
在本章中,还要求您了解如何使用对象关系映射器(ORMs),并且作者假设您有与 MySQL 数据库一起工作的经验。
您还需要熟悉 PSR-11,以及服务容器的使用。有关 PSR-11 的更多信息,请参阅www.php-fig.org/psr/psr-11/。
本章相关的所有代码文件都可以在github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%203找到。
准备本章的开发环境
首先,获取第五章的基础代码,该代码位于github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%203或简单地运行以下命令:
curl -Lo phptdd.zip "https://github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/raw/main/Chapter%205/base.zip" && unzip -o phptdd.zip && cd base && ./demoSetup.sh
要运行容器并执行本章中的命令,读者应该处于
docker-server-web-1 container.
要运行容器并执行本章中的命令,您应该处于docker-server-web-1容器内。
运行以下命令以确认我们的 Web 服务器的容器名称:
docker ps
要运行容器,从您的宿主机中的/phptdd/docker目录运行以下命令:
docker-compose build && docker-compose up -d
docker exec -it docker_server-web_1 /bin/bash
一旦进入容器,运行以下命令通过composer安装所需的库:
/var/www/html/symfony# ./setup.sh
定义单元测试
单元测试是一个专门测试您解决方案代码中单元的程序。只需将其视为一个测试函数且不依赖于项目中其他对象的程序即可。
例如,如果您有一个名为calculateTotal($a, $b, $c)的函数,那么您可以为其编写一个名为testCanCalculateTotal()的单元测试函数。这个单元测试的目的是验证calculateTotal($a, $b, $c)函数是否根据项目规范中定义的业务规则返回预期的结果。
在这个例子中,让我们假设calculateTotal函数的预期行为是获取三个参数$a、$b和$c的和。
让我们创建一个示例单元测试和解决方案代码。在我们的开发容器内创建以下文件:
codebase/symfony/tests/Unit/CalculationTest.php
<?php
namespace App\Tests\Unit;
use App\Example\Calculator;
use PHPUnit\Framework\TestCase;
class CalculationTest extends TestCase
{
public function testCanCalculateTotal()
{
// Expected result:
$expectedTotal = 6;
// Test data:
$a = 1;
$b = 2;
$c = 3;
$calculator = new Calculator();
$total = $calculator->calculateTotal($a, $b,
$c);
$this->assertEquals($expectedTotal, $total);
}
}
测试类名称需要以Test结尾,并扩展PHPUnit\Framework\TestCase类。通过这样做,我们现在正在使用 PHPUnit 库。
接下来,让我们尝试运行这个单元测试并看看会发生什么。在容器内运行以下命令。如何完成所有这些操作的说明在第三章,使用 Docker 容器设置我们的开发环境:
/var/www/html/symfony# php bin/phpunit –filter testCanCalculateTotal
结果将是一个错误:

图 5.1 – 失败 1(找不到类)
我们的单元测试正如预期那样失败了——这是好事!你会注意到我们尝试实例化一个不存在的类,所以现在让我们创建那个类并编写执行计算的函数。
在我们之前创建的codebase/symfony/src/Example/目录内创建以下解决方案类:
codebase/symfony/src/Example/Calculator.php
<?php
namespace App\Example;
class Calculator
{
public function calculateTotal(int $a, int $b, int $c)
: int
{
return $a + $b - $c;
}
}
在创建带有calculateTotal函数的解决方案类后,让我们再次尝试运行测试,通过运行以下命令:
/var/www/html/symfony# php bin/phpunit –filter testCanCalculateTotal
我们将得到以下失败的结果:

图 5.2 – 失败 2(结果不正确)
PHPUnit 会告诉我们测试失败的原因。你会注意到它说:断言失败:预期 0 个匹配,但找到 6 个。为什么是这样呢?好吧,这就是发生了什么。
在testCanCalculateTotal单元测试中,我们声明$expectedTotal为6。然后我们调用calculateTotal函数并传递以下参数:$a = 1,$b = 2,和$c = 3。如果你收到的规范指示你在calculateTotal函数内对三个参数求和,那么预期的结果是6。
然后,我们使用了assertEquals PHPUnit 函数,其中我们告诉 PHPUnit 我们期望的值应该等于以下行中的计算值:
$this->assertEquals($expectedTotal, $total);
断言是方法或函数,用于断言或检查测试中的条件是否得到满足。就像在我们的例子中,我们使用了assertEquals方法,尝试比较$expectedTotal与从解决方案代码中实际收到的$total。有很多不同类型的 PHPUnit 断言,文档可以在这里找到:phpunit.readthedocs.io/en/9.5/assertions.html。
单元测试正确地期望预期结果为6——问题是我们在解决方案函数中没有遵循预期的行为。我们没有将$c加到$a和$b的总和中,而是减去了它。如果我们把函数修正如下,我们的测试最终应该通过:
codebase/symfony/src/Example/Calculator.php
public function calculateTotal(int $a, int $b, int $c) : int
{
return $a + $b + $c;
}
要得到总数,我们只需要得到三个参数的总和。一旦你更新了Calculator.php文件,运行以下命令:
php bin/phpunit --filter testCanCalculateTotal
我们现在应该看到以下结果:

图 5.3 – 正确结果
太好了!我们终于通过了单元测试!assertEquals函数已确认$expectedTotal现在等于解决方案代码返回的$total金额!
现在,想象一下有成千上万的这些测试。解决方案代码行为的一个意外更改将导致一个或多个单元测试失败。这是非常有价值的。这将帮助开发者验证他们实施的任何代码更改的稳定性。
要了解更多关于 PHPUnit 的信息,您可以访问他们的文档页面phpunit.readthedocs.io/。
这只是使用单元测试的最基本示例之一,但我们将随着项目的继续编写更多的单元测试并利用更多的 PHPUnit 功能。
我们为解决方案代码编写的测试越多,对解决方案的稳定性就越好。因此,接下来,我们将探讨 PHPUnit 的代码覆盖率分析解决方案。这将帮助我们获取一些关于我们解决方案测试覆盖率的指标。
测试覆盖率
有单元测试是很好的,但如果我们只测试解决方案的几个部分,那么无意中破坏代码库的机会就更大。尽管如此,有一些单元测试比没有单元测试要好。我不了解一个明确的行业标准数字或理想的测试代码覆盖率百分比。有人说 80%到 95%的测试覆盖率是好的,但这取决于项目。我仍然认为 50%的测试覆盖率比 0%的测试覆盖率要好,每个项目都可以非常不同。测试覆盖率还可以配置为排除代码库的一些部分,所以 100%的测试覆盖率并不字面意义上意味着代码库中所有代码的 100%都由自动化测试覆盖。尽管如此,了解我们解决方案的测试覆盖率仍然是有益的。对于刚开始进行单元测试的开发者来说,指出有一些测试比完全不写单元测试要好是很重要的。如果你的代码覆盖率报告给你一个低数字,不要害怕或失去动力;了解这一点至少会给你关于测试覆盖率的数据或真相。
为了让 PHPUnit 知道某个测试函数测试了特定的解决方案代码,我们将使用@covers注解。PHP 中的注解是一种添加到类、函数、属性等处的元数据类型。在 PHP 中,我们在文档块中声明注解。
声明注解
PHP 注解就像注释一样——它们被 PHP 库用来从 PHP 中的函数、属性或类中获取元数据。
打开CalculationTest.php文件,在testCanCalculateTotal函数上方添加以下@covers注解:
codebase/symfony/tests/Unit/CalculationTest.php
/**
* @covers \App\Example\Calculator::calculateTotal
*/
public function testCanCalculateTotal()
您会注意到,我们在@covers注解之后声明了\App\Example\Calculator类和calculateTotal方法。我们基本上是在告诉 PHPUnit,这个特定的testCanCalculateTotal测试函数将覆盖\App\Example\Calculator类内部的该方法或函数。
现在,运行以下 CLI 命令以运行带有测试覆盖率的 PHPUnit:
/var/www/html/symfony# export XDEBUG_MODE=coverage
/var/www/html/symfony# php bin/phpunit --coverage-text --filter CalculationTest
这次,我们添加了--coverage-text选项。我们正在告诉 PHPUnit 将覆盖率分析报告输出到终端窗口。你现在将收到以下结果:

图 5.4 – 第一次测试覆盖率
恭喜!您刚刚收到了您的第一个测试覆盖率报告!这意味着 Calculation.php 类的 calculate 方法被一个单元测试覆盖了。然而,在现实生活中,我们会在一个类中添加更多函数。如果我们开始向 Calculation.php 类添加函数会发生什么?让我们来看看。
向解决方案类添加更多函数
我们之前创建的 CalculationTest 类有一个测试覆盖了 calculateTotal 函数。当我们运行覆盖率测试时,我们得到了 100% 的测试覆盖率结果。如果我们向解决方案类添加更多函数,我们就不会再得到 100% 的覆盖率测试结果。那么这意味着什么呢?在实践中,这意味着我们的解决方案类的一些部分没有被我们的自动化测试覆盖。这并不是世界末日,但这将帮助公司的开发者识别系统中有多少部分被自动化测试覆盖。这将影响公司对代码库更新的信心水平,从而也会影响需要进行的手动测试的数量,或者公司对发布新代码的信心程度。
打开 Calculation.php 类并添加以下方法:
codebase/symfony/src/Example/Calculator.php
<?php
namespace App\Example;
class Calculator
{
public function calculateTotal(int $a, int $b, int $c)
: int
{
return $a + $b + $c;
}
public function add(int $a, int $b): int
{
return $a + $b;
}
}
正如您在前面的代码块中所见,我们添加了一个名为 add 的新函数。这个函数简单地返回 $a 和 $b 的和。由于我们没有为这个新函数编写单元测试,让我们看看再次运行测试会发生什么。运行以下命令:
/var/www/html/symfony# php bin/phpunit --coverage-text --filter CalculationTest
在运行前面的命令之后,我们会注意到我们的测试覆盖率结果中有些变化:

图 5.5 – 测试覆盖率已降低
您会注意到,在将 add 函数添加到 Calculator.php 类之前,我们有 100% 的测试覆盖率。现在,我们只有 50% 的测试覆盖率。显然,这是因为我们没有为 add 函数编写单元测试。为了提高我们的测试覆盖率,让我们为 add 函数添加一个单元测试:
codebase/symfony/tests/Unit/CalculationTest.php
<?php
namespace App\Tests\Unit;
use App\Example\Calculator;
use PHPUnit\Framework\TestCase;
class CalculationTest extends TestCase
{
/**
* @covers \App\Example\Calculator::calculateTotal
*/
public function testCanCalculateTotal()
{
// Expected result:
$expectedTotal = 6;
// Test data:
$a = 1;
$b = 2;
$c = 3;
$calculator = new Calculator();
$total = $calculator->calculateTotal($a, $b,
$c);
$this->assertEquals($expectedTotal, $total);
}
/**
* @covers \App\Example\Calculator::add
*/
public function testCanAddIntegers()
{
// Expected Result
$expectedSum = 7;
// Test Data
$a = 2;
$b = 5;
$calculator = new Calculator();
$sum = $calculator->add($a, $b);
$this->assertEquals($expectedSum, $sum);
}
}
在前面的代码块中,我们添加了 testCanAddIntegers 测试函数。通过使用 @covers 注解,我们还声明了这个函数测试了 Calculation.php 类中的 add 函数。
让我们再次运行测试,看看我们是否提高了测试覆盖率结果。再次运行以下命令:
/var/www/html/symfony# php bin/phpunit --coverage-text --filter CalculationTest
现在,我们应该看到以下结果:

图 5.6 – 回到 100% 测试覆盖率
太好了!现在,我们又有了 100% 的测试覆盖率。在 Calculation.php 类中,我们有两个函数,并且我们也有两个单元测试,分别测试这些函数。
现在,想象一下你正在与其他开发者合作一个项目,这是非常常见的。如果其他开发者开始重构一个单元测试过的类,并开始向该类添加函数而不添加测试来覆盖它们,当你的团队运行覆盖率测试时,你的团队将很容易且迅速地识别出该类中有新的函数或功能没有被自动化测试覆盖。
如果你在一个Calculation.php类中创建了一个private函数?如果你需要测试一个private方法,那么你可以通过测试调用它的方法间接测试private方法,或者使用 PHP 的反射功能。
使用 PHP 的反射功能直接测试私有方法
私有方法不应该被外部对象访问,但它们可以间接地被测试,如下一节所述。如果你真的想尝试直接测试private方法,你可以使用这个方法。打开Calculator.php类并添加private的getDifference方法:
codebase/symfony/src/Example/Calculator.php
<?php
namespace App\Example;
class Calculator
{
public function calculateTotal(int $a, int $b, int $c)
: int
{
return $a + $b + $c;
}
public function add(int $a, int $b): int
{
return $a + $b;
}
private function getDifference(int $a, int $b): int
{
return $a - $b;
}
}
如果你再次运行测试,你会看到你的测试覆盖率再次降低,即使你只是添加了一个private方法:

图 5.7 - 未测试私有方法
现在,我们有未测试的代码,这也是一个棘手的问题,因为它是一个private方法。为了测试这个,打开CalculationTest.php测试类并添加testCanGetDifference方法:
codebase/symfony/tests/Unit/CalculationTest.php
/**
* @covers \App\Example\Calculator::getDifference
*/
public function testCanGetDifference()
{
// Expected Result
$expectedDifference = 4;
// Test Data
$a = 10;
$b = 6;
// Reflection
$calculatorClass = new \ReflectionClass
(Calculator::class);
$privateMethod = $calculatorClass->getMethod
("getDifference");
$privateMethod->setAccessible(true);
// Instance
$calculatorInstance = new Calculator();
// Call the private method
$difference = $privateMethod->invokeArgs
($calculatorInstance, [$a, $b]);
$this->assertEquals($expectedDifference, $difference);
}
与早期的测试方法一样,我们也将这个测试进行了注释,以表明它测试的是Calculator.php类内部的getDifference方法。由于我们试图测试一个private方法,而这个方法显然在仅实例化一个Calculator对象时是不可访问的,因此我们需要使用 PHP 的ReflectionClass。我们已经手动指定了getDifference类的可见性,并间接调用了private的getDifference方法。如果我们再次运行测试,现在我们会看到以下内容:

图 5.8 - 测试了私有方法
现在,我们已经回到了 100%的测试覆盖率,测试了两个public方法和一个private方法——但这有必要或实用吗?我个人认为这并不太实用。如果我有一个private方法,我显然会在另一个公开可访问的方法中使用那个private方法。我会做的是测试那个公开可访问的方法。如果private方法内部的指令非常复杂,我认为它根本不应该是一个类内部的private方法。它可能需要一个自己的类,或者可能需要被进一步分解。我见过很多很好的类(可以做一切事情的类)具有非常复杂的private方法,维护这些类型的类是个头疼的问题。
间接测试私有方法
如果我有一个 private 方法,我会测试使用 private 方法的公共方法,而不是通过反射路径。如果变得过于复杂,我会考虑将这个测试完全从单元测试套件中移除。你可以在本章后面阅读有关集成测试的内容,以了解更多信息。
打开 Calculator.php 类,将内容替换为以下内容:
codebase/symfony/src/Example/Calculator.php
<?php
namespace App\Example;
class Calculator
{
public function calculateTotal(int $a, int $b, int $c)
: int
{
return $a + $b + $c;
}
public function add(int $a, int $b): int
{
return $a + $b;
}
public function subtract(int $a, int $b): int
{
return $this->getDifference($a, $b);
}
private function getDifference(int $a, int $b): int
{
return $a - $b;
}
}
我们保留了私有的 getDifference 方法,但同时也添加了一个新的公开可访问的方法 subtract,它反过来又使用了 getDifference 方法。
打开 CalculationTest.php 文件,将反射测试替换为以下内容:
codebase/symfony/tests/Unit/CalculationTest.php
/**
* @covers \App\Example\Calculator::subtract
* @covers \App\Example\Calculator::getDifference
*/
public function testCanSubtractIntegers()
{
// Expected Result
$expectedDifference = 4;
// Test Data
$a = 10;
$b = 6;
$calculator = new Calculator();
$difference = $calculator->subtract($a, $b);
$this->assertEquals($expectedDifference, $difference);
}
在前面的代码块中,我们删除了使用 PHP 的 ReflectionClass 方法的 testCanGetDifference 测试。是否手动和单独测试你的私有或受保护方法使用 reflection 取决于你。
在这个新的 testCanSubtractIntegers 方法中,你会注意到现在有两个 @covers 注解。我们明确声明这个特定的测试方法将覆盖公共的 subtract 方法和私有的 getDifference 方法。
运行以下命令再次执行覆盖率测试,看看我们是否仍然通过测试:
/var/www/html/symfony# php bin/phpunit --coverage-text --filter CalculationTest
你应该看到以下 100% 的覆盖率结果:

图 5.9 – 一个测试覆盖的两个方法
你会注意到覆盖率报告指出我们已测试了四个方法。从技术上讲,我们只在 CalculationTest.php 测试类中有三个测试被测试结果所报告:
OK (3 tests, 3 assertions)
由于我们已经声明 testCanSubtractIntegers 测试将覆盖 subtract 和 getDifference 方法,因此我们能够为 Calculator.php 类获得完整的测试覆盖率:
方法: 100.00% (4/4)
我们现在能够运行单元测试,使用 Xdebug 通过断点进行调试并获得测试覆盖率结果。接下来,我们将构建自己的小工具来简化测试的运行,这样我们就不必总是编写长命令。
使用 shell 脚本运行测试
我们可以使用 shell 脚本来运行我们的测试,通过这样做,我们可以为每个脚本添加额外的配置。在运行 PHPUnit 测试时,有不同的配置和命令,在运行单元测试时,有不同的目标或意图。到目前为止,我们运行测试以触发 Xdebug 并遍历代码,我们还使用了 PHPUnit 来获取测试覆盖率报告。为了更好地简化这些测试的执行,我们可以构建一些 shell 脚本来帮助我们封装运行测试的命令和配置。
如果你回到你的终端并尝试使用带有断点的 Xdebug,你可能会感到失望。在 PHPStorm 中,在某个测试中设置断点如下:

图 5.10 – 添加断点
在 CalculationTest.php 类的第 16 行内设置断点后,运行以下命令:
/var/www/html/symfony# php bin/phpunit --filter CalculationTest
你注意到什么了吗?嗯,断点根本没有被调用。这是因为之前,我们指定了我们要通过运行 export XDEBUG_MODE=coverage 来使用 Xdebug 的覆盖率模式。另一方面,如果我们以调试模式运行测试并希望获取覆盖率报告,那么我们可能需要再次运行不同的命令。这实际上并没有什么问题,但如果我们要开发大量代码并重复使用不同的配置来运行测试,使用 shell 脚本可能会有所帮助。
我们将创建两个脚本以触发 PHPUnit 并配置我们的环境:
-
runDebug.sh– 我们将使用这个来调试 -
runCoverage.sh– 我们将使用这个来生成测试覆盖率报告
在 symfony 的根目录中创建以下文件:
codebase/symfony/runDebug.sh
#!/bin/bash
export XDEBUG_CONFIG="idekey=PHPSTORM"
export PHP_IDE_CONFIG="serverName=phptdd"
export XDEBUG_MODE="debug"
XDEBUGOPT=
if [ "x$NODEBUG" = "x" ]; then
XDEBUGOPT="-d xdebug.start_with_request=yes"
fi
php $XDEBUGOPT bin/phpunit --color=always --debug $@
在前面的脚本中,我们正在配置我们的环境以使用 Xdebug 运行测试。在开发过程中这很重要,因为它将允许我们使用断点而无需总是考虑配置。
确保你创建的文件是可执行的;运行以下命令来执行此操作:
/var/www/html/symfony# chmod u+x runDebug.sh
现在,我们可以尝试使用这个脚本执行我们的 CalculationTest.php 类,并查看第 16 行的断点是否被调用:
/var/www/html/symfony# ./runDebug.sh
在运行前面的命令后,回到 PHPStorm 并确保断点可以工作:

图 5.11 – 使用 runDebug.sh 与 Xdebug
太好了!通过使用 ./runDebug.sh 脚本,我们可以动态地配置我们的容器并触发 PHPStorm 中的 Xdebug 断点。现在,如果我们想获取测试覆盖率报告,我们需要运行不同的脚本以简化操作。
创建一个名为 runCoverage.sh 的新文件:
codebase/symfony/runCoverage.sh
#!/bin/bash
export XDEBUG_CONFIG="idekey=PHPSTORM"
export PHP_IDE_CONFIG="serverName=phptdd"
export XDEBUG_MODE="coverage"
php bin/phpunit --color=always --coverage-text $@
前面的脚本将配置我们的环境并附加 --coverage-text 选项,这样我们就可以在运行此脚本时轻松地获取测试覆盖率报告。
运行以下命令:
/var/www/html/symfony# ./runCoverage.sh
运行 ./runCoverage 脚本现在应该生成相应的 代码覆盖率报告:

图 5.12 – 使用 runCoverage.sh 脚本
太好了!现在,我们可以使用不同的配置来运行 PHPUnit。上一次的测试执行失败是因为我们之前创建的 ExampleTest.php 测试类,我们故意让它失败的。
根据你的需求,你可以添加自己的脚本 – 毕竟,我们是软件开发者。我们可以构建工具让自己更轻松。当运行 持续集成(CI)时,我们不需要总是调试或运行代码覆盖率报告,因此我们还可以在项目下方创建一个用于 CI 的脚本。持续集成将在 第九章,持续集成 中更详细地讨论。
我们现在已经学会了如何编写单元测试,这些测试主要关注测试我们解决方案代码中的小程序 – 但如果我们需要测试一个使用多个类的更复杂的功能呢?如果能将这些依赖于外部对象或资源的复杂测试分离出来,那就太好了。在下一节中,我们将快速介绍不同类型的自动化测试。
测试类型
运行 PHPUnit 测试可以非常快。根据我的经验,即使有成千上万的单元测试,也只需几分钟就能完全运行。这是因为它们只测试解决方案的小部分或单元。
我们还可以添加将调用与外部 Web 服务 API 或数据库交互的程序测试。现在,想象一下这些测试有多么复杂,执行它们需要多长时间。如果我们把所有使用多个对象和单元测试的复杂测试组合成一个单一组,运行整个测试组将花费很多时间。我经历过与一家公司合作,那里有成千上万的测试都被组合成一个单一的套件 – 你运行套件,等了一个小时,结果发现只有一个单元测试出了问题。这非常耗时且不切实际。

图 5.13 – 分组测试
识别测试的功能并将其放入正确的测试组或篮子中可以帮助组织测试。我们可以使用不同类型的测试作为“篮子”或测试的组。我们可以简化这些篮子并将它们分为两种主要类型。在 PHPUnit 中,这些篮子被称为测试套件。
篮子 1 – 单元测试
我们在本章前面已经编写了单元测试。如果你还记得,我们在 codebase/symfony/tests 目录中创建了一个名为 Unit 的目录。这将是我们单元测试的篮子。所有专门测试解决方案的小部分或单元的测试都将放入这个目录中,并且相应的命名空间是:App\Tests\Unit。
打开 codebase/symfony/phpunit.xml,你会注意到我们在 tests/Unit/ 目录中声明了一个名为 Unit 的 testsuite。我们将使用测试套件来帮助我们分组和分离测试。当我们想要隔离想要运行的测试组时,这会很有用:
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
<testsuite name="Unit">
<directory>tests/Unit/</directory>
</testsuite>
</testsuites>
这意味着,如果我们想运行 Unit 测试套件,PHPUnit 将在 tests/Unit/ 目录中找到所有的测试。
要运行该单元篮子或测试套件中的所有测试,请运行以下命令:
/var/www/html/symfony# ./runDebug.sh --testsuite Unit
你将得到以下结果:

图 5.14 – 单元测试套件
通过添加 --testsuite 单元选项,我们确保我们只运行 App\Tests\Unit 命名空间内的测试。这将帮助我们将测试执行集中在特定的篮子或测试套件上。
我们已经覆盖了第一个测试组或篮子。我们创建了一个名为 Unit 的目录,这就是我们将放置所有未来单元或简单测试的地方。接下来,我们需要创建一个单独的组或篮子来放置更复杂的测试。
篮子 2 – 集成测试
集成测试旨在测试解决方案的更大部分。而不是测试应用程序的小单元,集成测试旨在通过单个测试覆盖解决方案的不同部分。
想象测试一个使用其他对象的方法的对象。测试的成功可能取决于外部因素,如数据库连接、API 调用或依赖于其他类,而这些类也依赖于其他类。这就像是在稍微大一点的规模上进行单元测试。
例如,如果你有一个计算某些总和并将其持久化到数据库中的类,你将希望有一个测试来检查持久化在数据库中的计算结果。这就是集成测试非常有用的地方。
我们之前为单元测试创建了一个目录——现在,让我们创建一个目录来存放我们的集成测试。
在 tests 目录内创建一个 Integration 目录:
/var/www/html/symfony# mkdir tests/Integration
在创建 Integration 目录后,我们需要让 PHPUnit 了解这个目录。我们需要添加一个 Integration 测试套件并声明目录路径。打开 codebase/symfony/phpunit.xml 并使用以下配置:
codebase/symfony/phpunit.xml <php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
<env name="SYMFONY_DEPRECATIONS_HELPER"
value="disabled" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
<testsuite name="Unit">
<directory>tests/Unit/</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration/</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<listeners>
<listener class="Symfony\Bridge\PhpUnit
\SymfonyTestsListener" />
</listeners>
现在,Integration 测试套件已经注册。有了这个,我们仍然可以通过在运行测试时将 Unit 参数传递给 --testsuite 选项来安全地运行我们的单元测试。要运行集成测试,我们只需使用 --testsuite Integration:
/var/www/html/symfony# ./runDebug.sh --testsuite Integration
由于我们没有测试,运行前面的命令将返回以下结果:

图 5.15 – 集成测试套件
现在我们已经有一个篮子来存放所有的集成测试,让我们开始编写我们的第一个集成测试!
我们已经有一个经过单元测试的类,即 Calculate.php 类。现在,如果我们能将其用作集成测试示例的一部分那就太好了。
集成测试示例
在本节中,我们将尝试进行一些计算,然后尝试将结果存储到数据库中。我们将创建一个名为 coffee 的数据库,并尝试创建一个简单的程序来计算我们一天中喝了多少咖啡杯,并将其持久化。在持久化后,我们需要能够验证持久化的总和是否正确。
使用 Symfony 6 安装 Doctrine
由于我们使用的是与 Doctrine 兼容良好的 Symfony 框架,让我们只使用 Doctrine 来持久化和从数据库中检索数据。有很多人从数据库中持久化和检索数据的方法,但在这个项目中,我们将只关注使用 Doctrine 来简化我们的示例,以便我们可以专注于测试而不是重新发明轮子。Doctrine 是一个 ORM。您可以在 www.doctrine-project.org 上了解更多关于 Doctrine 的信息。
让我们通过运行以下命令来安装 Doctrine:
/var/www/html/symfony# composer require symfony/orm-pack
/var/www/html/symfony# composer require symfony/maker-bundle --dev
在运行前面的命令后,这可能需要几分钟,创建一个本地环境文件,并保存以下内容:
codebase/symfony/.env.local
DATABASE_URL="mysql://root:mypassword@server-mysql/mydb?serverVersion=8&charset=utf8mb4"
在上一行中,我们正在告诉 Symfony,在我们的本地环境中,我们希望使用我们在 第三章,使用 Docker 容器设置我们的开发环境 中创建的 MySQL 容器。
您可以打开 docker-compose.yml 文件来查看 MySQL 容器的详细信息。您可以在那里进行任何进一步的配置更改以满足您的需求。

图 5.16 – Doctrine 的 MySQL 容器设置
您可以在这里更改数据库密码,甚至可以将 MySQL 版本更改为所需的任何版本。在 .env.local 文件中,我们指定了我们要使用 MySQL8,我们还指定了我们要使用 server-mysql 容器,而不是使用数据库服务器的 IP 地址。我们还使用了 coffee 作为我们的数据库名称。
接下来,我们将使用 Doctrine ORM 为我们创建一个 MySQL 数据库。然后我们将使用这个新数据库进行我们的示例集成测试。
Doctrine 和数据库
我们已经配置了环境,使其能够连接到我们创建的 MySQL 服务器容器,并指定了我们想要用于示例的数据库名称。现在,在这个阶段,我们已准备好为我们的集成测试示例创建数据库。运行以下命令:
/var/www/html/symfony# php bin/console doctrine:database:create
通过运行前面的命令,Doctrine 将使用我们在 .env.local 文件中提供的参数为我们创建一个名为 coffee 的数据库。

图 5.17 – 创建新数据库
现在,我们有了自己的数据库可以玩耍。如果您有桌面 MySQL 客户端,您可以通过连接到 server-mysql 容器来查看我们刚刚创建的数据库。如果没有,为了使我们的自动化测试的终端窗口看起来更美观一些,我们在 第三章,使用 Docker 容器设置我们的开发环境 中添加了一个 PHPMyAdmin 容器,以便快速轻松地访问数据库。
打开您的网络浏览器并访问以下 URL:http://127.0.0.1:3333/index.php。您将看到以下内容:

图 5.18 – coffee 数据库
在我们编写任何将利用我们刚刚创建的数据库的代码之前,首先,我们需要了解我们想要用它做什么,并为其创建一个集成测试。接下来,我们将创建我们的第一个失败的集成测试。
第一次集成测试失败
我们有一个持久化信息的解决方案,即 Doctrine 和 MySQL。我们还有一个计算一些随机整数之和的方法。现在,让我们来使用它们。我们希望能够传递一个字符串名称和三个整数来表示我们消费的咖啡杯数,得到总和,并持久化它。
创建以下集成测试文件:
codebase/symfony/tests/Integration/ConsumptionTest.php
<?php
namespace App\Tests\Integration\Service;
use PHPUnit\Framework\TestCase;
class ConsumptionServiceTest extends TestCase
{
public function testCanComputeAndSave()
{
$this->fail("--- RED --");
}
}
我们在 App\Tests\Integration 命名空间内创建了我们第一个集成测试,这反过来又将成为我们集成测试套件的一部分。运行以下命令以确保一切正常,并且我们的测试按预期失败:
/var/www/html/symfony# .runDebug.sh --testsuite Integration --filter ConsumptionServiceTest
您应该看到由于我们创建的 $this->fail("--- RED --"); 行而导致的失败测试:

图 5.19 – 第一个失败的集成测试
太好了!我们现在有一个失败的 Integration 测试套件测试。现在,我们唯一要做的就是让它通过。
让我们尝试具体分解我们想要做什么,以及我们想要测试什么:
-
我们希望能够追踪一个人一天中喝咖啡的杯数
-
我们希望记录一个人一天中早上、下午和晚上消费的咖啡杯数
-
我们希望得到总和,然后持久化总数,以及人的姓名
-
我们希望能够检索持久化的记录并检查它是否正确。
根据前面的列表,然后我们可以更新我们的测试如下:
codebase/symfony/tests/Integration/Service/ConsumptionTest.php
<?php
namespace App\Tests\Integration\Service;
use PHPUnit\Framework\TestCase;
class ConsumptionServiceTest extends TestCase
{
public function testCanComputeAndSave()
{
// Given
$name = "Damo";
$morningCoffee = 2;
$afternoonCoffee = 3;
$eveningCoffee = 1;
// Expected Total:
$expectedTotal = 6;
// Persist the data
$service = new ConsumptionService();
$persistedId = $service->computeAndSave($name,
$morningCoffee, $afternoonCoffee, $eveningCoffee);
// Verify if the data persisted is correct:
// TODO:
}
}
如您所见,我们有一个不完整的测试 – 但对我来说,这是好的。我编写一个失败的测试并确保它失败,但我还尝试编写我想要测试的确切内容。
运行以下命令以查看会发生什么:
/var/www/html/symfony# ./runDebug.sh --testsuite Integration --filter ConsumptionServiceTest
测试尝试实例化一个不存在的 ConsumptionService.php 类。因此,您将得到以下结果:

图 5.20 – ConsumptionService 未找到
我们故意尝试从一个不存在的类中实例化一个对象,因此导致测试失败。这告诉我们什么?记住 ConsumptionService.php 类和它需要的其他程序。我们应该始终先让我们的测试失败。
然而,在我们编写 ConsumptionService.php 类之前,让我们创建一个 Doctrine 实体,它是 ConsumptionService.php 类在示例中需要的。
创建 Doctrine 实体
让我们创建一个实体类来表示我们的数据。在基本使用中,Doctrine 实体只是一个简单的 普通旧 PHP 对象(POPO),它带有一些 Doctrine 特定的注解,这些注解可以映射到数据库表。
运行以下命令来创建一个 Consumption.php 类:
/var/www/html/symfony# php bin/console make:entity
在运行前面的命令后,输入你想要创建的字段。在我们的例子中,使用以下内容:
New property name: name
Field type: string
Field length: 50
Can this field be null in the database? no
New property name: total
Field type: integer
Can this field be null in the database? no
在命令提示符后,你现在应该能在 codebase/symfony/src/Entity/Consumption.php 中看到一个新实体类文件:

图 5.21 – 消费实体
如果你打开文件,你会看到自动生成的 Doctrine 实体代码:
<?php
namespace App\Entity;
use App\Repository\ConsumptionRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ConsumptionRepository::class)]
class Consumption
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private $id;
#[ORM\Column(type: 'string', length: 50)]
private $name;
#[ORM\Column(type: 'integer')]
private $total;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getTotal(): ?int
{
return $this->total;
}
public function setTotal(int $total): self
{
$this->total = $total;
return $this;
}
}
总结来说,我们只有两个字段可以操作,即名称和总字段。这对我们的集成测试示例来说将非常完美。
接下来,我们需要实际的数据库表,我们的 Doctrine 实体将要代表。我们将使用 Doctrine ORM 运行迁移工具,以便我们可以生成我们需要的数据库表。
为实体创建 Doctrine 表
现在我们有一个实体,让我们也创建一个数据库表,该表代表 Consumption 实体。
运行以下命令:
/var/www/html/symfony# php bin/console make:migration
/var/www/html/symfony# php bin/console doctrine:migrations:migrate
在运行前面的命令后,应该为你创建一个新的数据库表。如果你回到 Consumption.php 实体类:

图 5.22 – 消费数据库表
现在我们有一个数据库表,它将由我们的 Consumption.php 实体类表示。这个表将用于持久化咖啡饮用者的咖啡消费记录!
然而,在实际项目中工作时,我们不想使用主数据库来运行我们的测试;否则,我们的测试最终会将测试数据插入到生产数据库中。接下来,我们将创建测试数据库。这个数据库将专门用于我们的集成测试,并将镜像主数据库的结构。
创建测试数据库
就像在先前的指令集中一样,我们也将基于一些环境配置创建数据库——但这次,这是专门为了我们的测试而设计的。
打开 .env.test 文件,并在文件末尾添加以下行:
DATABASE_URL="mysql://root:mypassword@server-mysql/coffee?serverVersion=8&charset=utf8mb4"
你会注意到它与我们在 .env.local 文件中使用的值相同。注意我们重用了 coffee 作为数据库名。
现在,运行以下命令来创建测试数据库:
/var/www/html/symfony# php bin/console doctrine:database:create --env=test
将创建一个新的名为 coffee_test 的数据库。_test 被添加到我们指定的 coffee 数据库名后面。我们运行的每个使用数据库的集成测试都将使用 coffee_test 数据库来持久化和读取数据。
接下来,运行以下命令,以便我们还可以将 Consumption 表迁移到我们新的 coffee_test 数据库中:
/var/www/html/symfony# php bin/console doctrine:migrations:migrate -n --env=test
在这个阶段,我们将有两个几乎相同的数据库。一个是要用于解决方案的 coffee 数据库,另一个是要用于我们的测试的 coffee_test 数据库。

图 5.23 – 咖啡数据库
现在我们已经创建了数据库,并且我们也拥有 Doctrine ORM,它将作为从 PHP 代码库与数据库通信的主要工具,我们现在将开始构建通过失败的集成测试的解决方案代码。
整合事物
在这个阶段,我们现在准备好开始构建 ComputationServiceTest.php 集成测试持续抱怨的缺失解决方案代码。记住我们失败的测试中的这条消息?
错误:类 "App\Tests\Integration\Service\ConsumptionService" 未找到
让我们按照以下步骤开始修复这个错误:
- 首先,打开
services.yaml文件,并更新以下内容:
codebase/symfony/config/services.yaml
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects
dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as
services
# this creates a service per class whose id is the
fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
App\Service\ConsumptionService:
public: true
# add more service definitions when explicit
configuration is needed
# please note that last definitions always
*replace* previous ones
由于我们在这个例子中使用 Symfony,我们将使用其符合 PSR-11 规范的服务容器来创建所需对象的实例。在创建我们即将编写的 ConsumptionService.php 类的实例时,我们将使用服务容器而不是使用 *new* PHP 关键字。
- 创建以下类,并包含以下内容:
codebase/symfony/src/Service/ConsumptionService.php
<?php
namespace App\Service;
use App\Entity\Consumption;
use App\Example\Calculator;
use Doctrine\Persistence\ManagerRegistry;
class ConsumptionService
{
/**
* @var Calculator
*/
private Calculator $calculator;
/**
* @var ManagerRegistry
*/
private $managerRegistry;
/**
* @param ManagerRegistry $doctrine
* @param Calculator $calculator
*/
public function __construct(ManagerRegistry $doctrine, Calculator $calculator)
{
$this->setManagerRegistry($doctrine);
$this->setCalculator($calculator);
}
/**
* @param string $name
* @param int $morning
* @param int $afternoon
* @param int $evening
* @return int
*/
public function calculateAndSave(string $name, int
$morning, int $afternoon, int $evening): int
{
$entityManager = $this->getManagerRegistry()->
getManager();
// Calculate total:
$sum = $this->getCalculator()->calculateTotal
($morning, $afternoon, $evening);
// Consumption model or entity:
$consumption = new Consumption();
$consumption->setName($name);
$consumption->setTotal($sum);
// Persist using the Entity Manager:
$entityManager->persist($consumption);
$entityManager->flush();
return $consumption->getId();
}
/**
* @return Calculator
*/
public function getCalculator(): Calculator
{
return $this->calculator;
}
/**
* @param Calculator $calculator
*/
public function setCalculator(Calculator
$calculator): void
{
$this->calculator = $calculator;
}
/**
* @return ManagerRegistry
*/
public function getManagerRegistry(): ManagerRegistry
{
return $this->managerRegistry;
}
/**
* @param ManagerRegistry $managerRegistry
*/
public function setManagerRegistry(ManagerRegistry
$managerRegistry): void
{
$this->managerRegistry = $managerRegistry;
}
}
在我们回到集成测试类之前,让我们快速回顾一下这个类中我们做了什么。ConsumptionService 类依赖于两个对象,ManagerRegistry 和 CalculationService。然后,calculateAndSave 方法将使用这两个对象来实现其目标。
- 现在,让我们回到
ConsumptionServiceTest.php类,并用以下内容替换其内容:
codebase/symfony/tests/Integration/Service/ConsumptionTest.php
<?php
namespace App\Tests\Integration\Service;
use App\Entity\Consumption;
use App\Service\ConsumptionService;
use Symfony\Bundle\FrameworkBundle\Test\
KernelTestCase;
class ConsumptionServiceTest extends KernelTestCase
{
public function testCanComputeAndSave()
{
self::bootKernel();
// Given
$name = "Damo";
$morningCoffee = 2;
$afternoonCoffee = 3;
$eveningCoffee = 1;
// Expected Total:
$expectedTotal = 6;
// Test Step 1: Get the Symfony's service
container:
$container = static::getContainer();
// Test Step 2: Use PSR-11 standards to get an
instance of our service, pre-injected with the
EntityManager:
/** @var ConsumptionService $service */
$service = $container->get
(ConsumptionService::class);
// Test Step 3: Run the method we want to test for:
$persistedId = $service->calculateAndSave
($name, $morningCoffee, $afternoonCoffee,
$eveningCoffee);
// Test Step 4: Verify if the data persisted
data is correct:
$em = $service->
getManagerRegistry()->getManager();
$recordFromDb = $em->find
(Consumption::class, $persistedId);
$this->assertEquals($expectedTotal,
$recordFromDb->getTotal());
$this->assertEquals($name, $recordFromDb->
getName());
}
}
我在代码中留下了注释,清楚地说明了我们在测试中做了什么。让我们更详细地理解它:
-
KernelTestCase类,我们可以使用static::getContainer()方法来获取 Symfony 的服务容器实例。我们将使用这个方法来创建我们的ConsumptionService实例,而不是手动使用newPHP 关键字来实例化它。 -
ConsumptionService类在其构造函数中期望两个对象。由于服务容器已配置为自动装配,容器将自动实例化在ConsumptionService构造函数中声明的依赖项。自动装配配置在我们在本章前面修改过的codebase/symfony/config/services.yaml中声明。 -
calculateAndSave方法。我们期望在这个步骤中,我们将提供的三个整数的总和计算出来,并将其持久化到数据库中。 -
如果
calculateAndSave方法成功完成其工作,那么我们可以对其进行实际测试。我们将通过ConsumptionService中的实体管理器对象检索一个填充的Consumption实体。我们将读取数据库中存储的数据,并使用assertEquals方法将其与我们在测试中声明的$expectedTotal和$name值进行比较。如果一切顺利,那么我们现在应该能够通过测试。
-
现在,通过运行以下命令再次执行集成测试:
/var/www/html/symfony# ./runDebug.sh --testsuite Integration --filter ConsumptionServiceTest
这次,我们现在应该能够通过测试!
root@0cb77fcadb5f:/var/www/html/symfony# ./runDebug.sh --testsuite Integration --filter ConsumptionServiceTest
PHPUnit 9.5.5 by Sebastian Bergmann and contributors.
Testing
. 1 / 1 (100%)
Time: 00:00.580, Memory: 18.00 MB
OK (1 test, 2 assertions)
root@0cb77fcadb5f:/var/www/html/symfony#
-
太好了!我们终于通过了第一个集成测试!要查看我们刚刚在数据库中创建的记录,请运行以下命令:
/var/www/html/symfony# php bin/console dbal:run-sql 'SELECT * FROM consumption' --env=test
你应该得到以下结果:

图 5.24 – 数据库结果
成功!我们能够创建集成测试,创建解决方案类,并最终通过集成测试!
为什么我们最初使用集成测试?
打开ConsumptionService.php类并检查constructor方法。
在构造函数中,我们指定了两个必需的参数。我们需要一个ManagerRegistry实例和一个我们自己在本章早期开发的Calculator实例。这两个对象是我们ComputationService.php类所依赖的。现在,这正是我们需要集成测试而不是单元测试的原因。
当我们执行calculateAndSave方法时,我们将使用ConsumptionService没有的业务逻辑。相反,它依赖于其他对象来实现其目标。与为我们构建单元测试的方法相比,这些方法不依赖于其他对象来完成它们的工作。这就是单元测试和集成测试之间的主要区别。
摘要
在本章中,我们通过编写自己的示例来定义单元测试是什么。我们经历了构建和通过单元测试,以及编写我们自己的 shell 脚本来帮助我们执行不同的自动化测试配置,以便我们更容易进行调试或运行测试覆盖率。我们了解了测试覆盖率报告是什么,以及我们如何使用它。
我们已经编写了第一个集成测试,并配置了我们的开发环境,以便我们也可以使用 MySQL 数据库。我们创建了一个将执行我们需要通过测试的业务逻辑的解决方案类,并且我们也能够验证我们保存在数据库中的内容是我们所期望的。
在本章中,我们试图清楚地定义单元测试和集成测试是什么,它们如何不同,以及为什么我们必须将它们分开到各自的篮子或测试套件中。
在下一章中,我们将讨论行为驱动开发(BDD)。我们将了解它被用于什么,为什么我们需要它,以及它与 TDD 的关系。
第六章:应用行为驱动开发
在上一章中,我们学习了如何创建和使用单元测试来发挥我们的优势。我们使用单元测试和集成测试来帮助确保解决方案代码的稳定性。这可能就像学习如何在沙袋上打基本拳一样。如果你去拳击课,他们可能会教你如何更有效地使用你的基本拳,并教你如何使用基本拳进行组合拳,以及如何防御。这与行为驱动开发(BDD)和测试驱动开发(TDD)类似。我们首先需要从基础开始:单元和集成测试。现在我们有了这些基础知识,我们可以开始应用流程或技术,以便我们可以更有效地使用它们。
许多开发者都知道如何编写单元测试和集成测试——毕竟,这些测试只是我们开发者编写的程序。根据我的个人经验,我注意到许多开发者不知道如何在他们的项目中有效地利用他们的单元测试和集成测试。有些开发者知道如何编写单元测试,但甚至无法为他们的项目编写一个。所以,仅仅拥有编写单元测试的基本或技术知识是不够的,以帮助改进项目。应用并有效地使用它才是关键。
使用 BDD 和 TDD,这正是我们想要尝试的。我们将通过遵循一个流程来利用这些测试技能,帮助我们构建我们的示例项目。
在本章中,我们将讨论以下主题:
-
BDD 是什么?
-
使用 Behat 和 Gherkin 应用 BDD
-
基于 Gherkin 编写 PHP 代码
-
浏览器模拟器和 Mink
-
那为什么还要使用 Gherkin 呢?
到本章结束时,你将能够编写行为驱动的特性、场景和解决方案代码。
技术要求
本章要求你拥有我们在第五章、“单元测试”中使用的所有容器、配置和测试工具。你可以从以下 URL 下载源文件并运行容器:github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%205。
准备本章的开发环境
首先,获取第六章的基础代码,位于github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%206/base/phptdd,或者简单地运行以下命令:
curl -Lo phptdd.zip "https://github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/raw/main/Chapter%206/base.zip" && unzip -o phptdd.zip && cd base && ./demoSetup.sh
要运行容器并执行本章中的命令,你应该在docker-server-web-1容器内部。
运行以下命令以确认我们的 Web 服务器的容器名称:
docker ps
要运行容器,请从你的主机机器上的/phptdd/docker目录运行以下命令:
docker-compose build && docker-compose up -d
docker exec -it docker_server-web_1 /bin/bash
一旦进入容器,运行以下命令通过 Composer 安装所需的库:
/var/www/html/symfony# ./setup.sh
什么是 BDD?
BDD 是一种开发软件的方式或过程,其中解决方案的预期行为首先由商业定义,然后将其翻译成自动化测试场景,在开始实际开发解决方案代码之前。
这种开发流程促进了软件公司不同部门或团队之间的协作。这可能听起来很荒谬,但根据我的经验,在项目开始时,确切地了解“需要构建什么”以及项目的实际目标,是我看到的最难以捉摸的部分。
有时候,商业或客户甚至不知道他们想要什么,或者可能商业或客户不知道如何有效地表达或传达这些目标。现在,想象一下你是一家软件公司的软件开发者——你的工作是开发解决方案来解决问题以实现目标。如果这个目标没有明确定义呢?或者,如果目标是由商业定义的,但没有正确传达给软件开发者呢?基本上,开发者最终会开发出错误解决方案,更糟糕的是,针对错误的问题!这就是为什么软件公司不同部门之间的协作非常重要。
商业分析师、项目经理、架构师和开发者可以定义项目的目标,以及为什么项目最初是必要的。然后测试工程师和软件开发者可以与商业分析师协调、辩论和讨论,以确定解决方案的预期或期望行为,然后将它们分解成更小的部分。这是 BDD(行为驱动开发)的第一个步骤:确切地知道目标是什么,需要构建什么,以及期望从解决方案中获得哪些行为。
现在我们已经知道了 BDD 是什么,我们可以开始将其应用到我们的示例项目中。
使用 Behat 和 Gherkin 应用 BDD
为了帮助我们更容易地理解 BDD 是什么以及如何使用它,我们将尝试将其应用到项目中的示例场景中。
回到第二章,理解和组织我们项目的业务需求,我们已经创建了 Jira 票据来帮助我们分解需要构建的功能。在库存管理员解决方案史诗中,我们创建了一个具有以下标题的用户故事:
作为一名库存管理员,我希望能够登录到系统中,以便我可以访问库存系统。
通过这个用户故事,我们可以推断出我们需要一个应用程序的用户,并且这个用户需要能够进行身份验证,以便他们可以访问受限制的功能。
所以,通常,作为软件开发者,我们接下来会开始构建解决方案代码——编写代码,然后检查我们刚刚创建的控制器或网页的结果。我们不会这样做。
使用 BDD,我们首先从一个失败的场景开始。听起来熟悉吗?是的——从上一章来看,这就像在我们编写解决方案代码通过那个失败的测试之前,先编写我们的失败的单元测试一样。
在我们开始编写 BDD 测试之前,我们需要使用一些 PHP 包来做到这一点。我们将使用 Behat 测试框架来帮助我们构建和组织我们的 BDD 测试。
Behat 是什么?
Behat 是一个为在 PHP 中实现行为驱动开发(BDD)而构建的 PHP 框架。它是一个框架,帮助我们 PHP 开发者编写行为驱动测试和程序。这将帮助我们更快地编写更好的行为驱动程序,这样我们就不必在编写这些行为驱动测试时重新发明轮子,即编写引导代码或骨架代码。Behat 使用 Gherkin 来描述我们想要测试的实际功能。
Gherkin 是什么?
Gherkin 是一种用于定义业务场景和目标的语言。它使用纯英文文本,因此公司中的任何人——甚至非技术人员——都能理解所描述的业务场景。
Mink 是什么?
这对于像我这样的 PHP 开发者来说非常有趣。Mink 是一个 PHP 库,将作为网络浏览器的模拟器。我们这些网络应用开发者开发 PHP 应用程序;我们的用户将通过网络浏览器使用我们的网络应用程序。因此,如果我们能够通过网络浏览器自动化测试应用程序的过程,这将对我们非常有好处。我们不必手动点击按钮或填写表单等,我们可以使用一些程序来为我们完成这些工作。所以,想象一下,一个机器人版本的你在通过网络浏览器为你测试程序。
在下一节中,我们将安装 Behat。
安装 Behat 和 Mink
在我们开始编写第一个行为驱动测试程序之前,我们首先需要安装所有需要的库和依赖项。让我们开始吧:
- 在
codebase目录下创建一个名为behat的新目录,这样我们就可以有一个与我们的 Symfony 应用程序分开的安装目录:

图 6.1 – Behat 根目录
在创建新目录后,我们可以继续通过 Composer 安装 Behat PHP 包。
-
使用你的终端,在
behat目录中,运行以下命令:/var/www/html/behat# composer require --dev behat/behat -
安装完成后,你可以通过运行以下命令来验证 Behat 是否成功安装:
/var/www/html/behat# ./vendor/bin/behat -V
完成后,你应该能看到你刚刚安装的 Behat 版本:

图 6.2 – Behat 已安装
现在,我们还需要安装 Mink,这样我们才能进行一些前端测试。
运行以下命令来安装 Mink:
/var/www/html/behat# composer require --dev behat/mink-extension -W
/var/www/html/symfony# composer require --dev behat/mink-goutte-driver
太好了!现在,我们需要让 Behat 创建一些骨架文件来帮助我们开始。运行以下命令:
/var/www/html/behat# ./vendor/bin/behat --init
现在,你应该会看到一个名为 features 的新目录。这就是我们将放置我们的业务功能场景的地方。
创建一个 Behat 功能
现在我们已经安装了 Behat 并初始化了它,我们可以创建我们的第一个示例特性文件。我们将使用 Gherkin 语言来定义特性的故事。这实际上就像讲故事一样。
在 features 目录中,创建一个名为 home.feature 的新文件,并添加以下内容:
/var/www/html/behat/features/home.feature
Feature: Home page
In order to welcome visitors
As a visitor
I need to be able to see the Symfony logo
Scenario: See the Symfony logo
Given I have access to the home page URL
When I visit the home page
Then I should see the Symfony Logo
我们已经使用 Gherkin 语言创建了 feature 和 scenario 文件。它们是描述性的,并且非常容易理解。你不需要程序员就能理解它们的意思。所以,向你的同事,如业务分析师或测试工程师展示它们不会成为问题;他们甚至能帮助你微调或改进你的特性和场景。
接下来,我们将重点关注 Feature 关键字及其内容。
特性关键字
如前述代码片段所示,我们在 Feature 关键字下方写了三个部分。下面的三个部分如下所示:
-
In order to -
Asa/an -
I need to be able to
在 In order to 部分中,我们定义了在这个特性中业务想要实现的目标。在我们的例子中,我们希望当访客到达我们的主页时,能感受到欢迎。
在 As a/an 部分中,我们定义了谁在执行动作。在我们的例子中,这是访问主页的网站访客。
最后,在 I need to be able to 部分中,我们定义了演员应该能够做什么或达到什么目标,以便业务能够实现其最终目标。在我们的例子中,我们只想让演员或访客看到 Symfony 标志和欢迎信息。
接下来,作为特性的一部分,我们需要使用 Scenario 关键字添加场景。
场景关键字
在一个特性中,我们可以有一个或多个场景。在我们创建的 home.feature 文件中,你会注意到 Scenario 标签相对于上面的 Feature 标签是缩进的。场景是用纯英文书写的;它只是事件序列的书面概述。在 Gherkin 中,我们将场景分为三个部分:
-
Given: 用于声明系统现有的或当前的状态或值 -
When: 用于定义在系统上执行的动作或动词 -
Then: 在When部分声明的动词或动作执行后的预期结果
现在我们已经定义了一个示例特性和场景,让我们尝试运行 Behat 看看我们会得到什么。
执行以下命令:
/var/www/html/behat# ./vendor/bin/behat
你应该看到以下结果:

图 6.3 – 缺失的片段
你会注意到 Behat 尝试寻找一些代表我们之前声明的场景的 PHP 代码,但我们没有这样做。所以,这意味着我们还需要为 Behat 编写一些 PHP 代码,以便在执行实际的 Given、When 和 Then 定义时使用。
接下来,我们将编写支持我们刚刚创建的特性场景所需的 PHP 代码。
根据 Gherkin 编写 PHP 代码
我们需要 PHP 程序来表示我们使用 Gherkin 创建的功能和场景。Behat 框架将遵循我们在上一节中创建的功能和场景,但它也会寻找表示每个功能和场景的 PHP 代码。在这段 PHP 代码中,我们可以添加任何自定义逻辑,以将功能和场景解释为程序。创建 Behat 框架运行我们的功能和场景所需的以下文件:
- 首先,我们需要创建一个新的上下文类。上下文类是 Behat 用于将 Gherkin 功能表示为 PHP 程序的东西。创建以下文件,并包含所示内容:
codebase/behat/features/bootstrap/HomeContext.php
<?php
use Behat\Behat\Tester\Exception\PendingException;
class HomeContext implements \Behat\Behat\Context\Context
{
}
- 然后,在创建
HomeContext.php类之后,我们还需要告诉 Behat 我们有一个新的上下文类。我们可以通过创建一个配置文件来完成此操作。创建以下文件,并包含所示内容:
codebase/behat/behat.yml
default:
suites:
default:
contexts:
- FeatureContext
- HomeContext
在这里,我们可以声明更多的 PHP 上下文类。默认情况下,您可以使用本章前面自动创建的 FeatureContext.php 文件,但如果我们在 FeatureContext.php 类中继续添加不同的步骤,最终会变得混乱。
-
现在,让我们再次尝试运行 Behat,但这次,我们将使用以下命令来自动生成我们
Given、When和Then步骤缺失的片段:/var/www/html/behat# ./vendor/bin/behat features/home.feature --append-snippets
然后,系统会提示您输入想要使用的特定上下文类:

图 6.4 – 选择上下文类
- 在 CLI 中输入
2,然后按 Enter。现在,您应该得到以下结果:

图 6.5 – 自动生成的片段
Behat 已经自动生成了表示我们在 home.feature 文件中定义的 Given、When 和 Then 步骤所需的 PHP 片段。
-
打开我们之前创建的
HomeContext.php类,在那里您应该看到新自动生成的函数:<?phpuse Behat\Behat\Tester\Exception\PendingException;class HomeContext implements \Behat\Behat\Context\Context{/*** @Given I have access to the home page URL*/public function iHaveAccessToTheHomePageUrl(){throw new PendingException();}/*** @When I visit the home page*/public function iVisitTheHomePage(){throw new PendingException();}/*** @Then I should see the Symfony Logo*/public function iShouldSeeTheSymfonyLogo(){throw new Exception();}} -
在
iShouldSeeTheSymfonyLogo()方法中,将PendingException类替换为仅有的Exception类。 -
太好了!现在,让我们再次运行 Behat,看看我们会得到什么:
/var/www/html/behat# ./vendor/bin/behat features/home.feature
由于自动生成的片段返回一个 PendingException 对象,我们将从 Behat 获得以下结果:

图 6.6 – 使用自动生成的 PHP 片段的 Behat
现在,我们应该能看到温暖而舒适的失败测试消息。到目前为止,我们已经能够使用 Gherkin 定义我们的功能。然后,我们创建了一个单独的上下文类来存放 Behat 将执行的与每个 Given、When 和 Then 步骤相关的方法。然后,我们使用 Behat 自动生成这些方法。现在,我们如何让所有这些测试通过呢?嗯,我们可以从 iShouldSeeTheSymfonyLogo() 方法中移除我们抛出的异常!如您所见,这一切都在 PHP 领域内发生。但为了真正通过测试,我们必须让 Behat 启动一个浏览器,访问主页 URL,并验证它是否可以看到 Symfony 标志。
那么,我们该如何做呢?记得我们之前安装 Mink 的时候吗?现在,我们将使用 Mink 和浏览器模拟器来为我们完成浏览器的工作。
浏览器模拟器和 Mink
浏览器模拟器是模拟或模仿网页浏览器功能和行为的程序。这些模拟器可以被其他程序,如 Behat 或 Codeception,用来模拟真实用户在使用您的应用程序时在网页浏览器上的操作。
浏览器模拟器有两种类型:
-
无头:这类模拟器发送 HTTP 请求,并简单地监听来自 web 应用程序的返回 DOM 或响应。它们最适合轻量级测试,不需要进行复杂的检查,例如在鼠标悬停事件后检查 AJAX 响应。
-
控制器:这类模拟器使用真实浏览器,它们基本上就像一个控制真实浏览器的人。根据我的经验,使用这类模拟器的好处是我们可以设置我们想要测试的浏览器类型。我们还可以检查页面上的 JavaScript 和 AJAX 结果。
在我们的例子中,我们将使用无头浏览器,因为我们不需要执行任何 JavaScript/AJAX 操作。如果您需要在项目中使用真实的浏览器模拟器,我强烈推荐使用 Selenium2。您可以在 Selenium 网站上了解更多关于 Selenium 的信息:www.selenium.dev。
接下来,为了让我们的 Behat 应用程序能够开始与浏览器模拟器交互,而不是真实用户,创建以下程序文件:
- 打开我们之前创建的
HomeContext.php类,并将其替换为以下内容:
codebase/behat/features/bootstrap/HomeContext.php
<?php
use Behat\Mink\Mink;
use Behat\Mink\Session;
use Behat\Mink\Driver\GoutteDriver;
use Behat\MinkExtension\Context\MinkContext;
use Behat\MinkExtension\Context\MinkAwareContext;
class HomeContext extends MinkContext implements MinkAwareContext
{
public function __construct()
{
$mink = new Mink([
'goutte' => new Session(new
GoutteDriver()), // Headless browser
]);
$this->setMink($mink);
$this->getMink()->getSession('goutte')->start
();
}
}
在构造函数中,我们实例化了一个 Mink 对象,并将其注入了一个 Session 对象。我们将一个 Goutte 无头模拟器的实例注入到会话中。Mink 支持不同类型的浏览器模拟器;您可以在mink.behat.org/en/latest/at-a-glance.html了解更多相关信息。
接下来,在同一个类中添加以下函数。这些方法代表您在场景中定义的每个步骤:
/**
* @Given I have access to the home page URL
*/
public function iHaveAccessToTheHomePageUrl()
{
return true;
}
/**
* @When I visit the home page
*/
public function iVisitTheHomePage()
{
// Using the Goutte Headless emulator
$sessionHeadless = $this->getMink()->getSession
('goutte');
$sessionHeadless->visit("symfony/public");
$sessionHeadless->getPage()->clickLink('Create your
first page');
}
/**
* @Then I should see the Symfony Logo
*/
public function iShouldSeeTheSymfonyLogo()
{
// Headless emulator test:
$assertHeadless = $this->assertSession('goutte');
$assertHeadless->elementExists('css', '.logo');
$assertHeadless->pageTextContains('Welcome To
Symfony 6');
}
在iVisitTheHomePage()方法中,我们检索了我们刚刚创建的 Goutte 注入的会话,然后我们让模拟器访问 URL 并点击一个链接。
-
现在,让我们运行测试看看它是否工作!运行以下命令:
/var/www/html/behat# ./vendor/bin/behat
然后,你应该看到以下结果:

图 6.7 – 失败的无头浏览器断言
我们再次失败了测试,但为什么是那样呢?注意在iVisitTheHomePage()方法内部,我们有这样一行:
$sessionHeadless->getPage()->clickLink('Create your first page');
这行代码告诉模拟器点击主页上的教程选项,它使用的是创建你的第一个页面锚文本:

图 6.8 – 创建你的第一个页面链接
发生的事情是模拟器成功加载了 Symfony 主页,然后点击了教程链接,因此浏览器被重定向到了另一个页面!这就是我们测试失败的原因。所以,如果我们更新iVisitTheHomePage()方法并删除有问题的行,我们现在应该能够通过测试!
-
再次运行测试,运行以下命令:
/var/www/html/behat# ./vendor/bin/behat
我们应该看到以下结果:

图 6.9 – 第一次 Behat 测试通过
太好了!我们终于通过了第一次 Behat 测试!在iShouldSeeTheSymfonyLogo()方法中,你会注意到我们有两个断言。在第一个断言中,我们想要检查返回的 DOM 中是否存在一个元素,即标志元素。然后我们添加了另一个断言来检查欢迎使用 Symfony 6文本。
-
打开你的网络浏览器并访问以下页面:
http://127.0.0.1:8081/symfony/public/。 -
打开你的元素检查器;你应该能看到标志元素。这是我们告诉 Mink 要查找的内容:

图 6.10 – 标志元素
由于当浏览器模拟器访问主页时,标志元素和欢迎使用 Symfony 6文本都存在,它最终通过了测试!
现在,我想你会有一个想法,这些工具是多么有用和强大。这可以为你和你的团队节省数小时的手动测试时间。
在这个阶段,你可以开始使用 Gherkin 语言编写由特性表示的行为和场景,然后使用 Behat 执行这些测试,然后使用 PHP 开发特性以满足这些测试。如果你遵循这个流程,你的开发将受到在编写任何代码之前定义的行为的驱动。现在,你的开发是行为驱动的!
那为什么还要使用 Gherkin 呢?
本章中我们使用的例子非常简单,但你可能会想,我们完全可以跳过用 Gherkin 语言编写的功能。好吧,我也这么做了。我想:这并不那么有用。但当开始参与更大规模的项目,更大的团队,不同公司合作在同一项目和目标上时,我想:如果有一个我们可以共享的通用格式,那么我们都能够理解企业试图实现的目标,那该多好。我正在与一家第三方公司合作,我想问他们是否可以借用或获取他们的测试用例,但问题是,他们直接将测试用例写入他们的应用程序中,而这个应用程序不是用 PHP 编写的。然后我意识到,拥有某种通用语言是多么重要,我们可以使用这种语言来理解编程语言无关的系统预期行为!
以下图表展示了 Gherkin 语言作为平台无关的中介语言,在表示预期软件解决方案行为方面的有用性:

图 6.11 – Gherkin 特性和场景
通过使用一种共同的语言来定义项目中预期功能和场景,我们可以轻松地与完全不进行任何软件编程的不同团队进行协调。这对企业来说非常重要。来自不同团队的人将能够更容易、更快地协作和理解彼此,通过这样做,开发者也可以更有信心和确定性,他们所构建的是正确的。这听起来可能有些荒谬,但我看到很多项目因为企业不同团队之间沟通的破裂而失败。
摘要
在本章中,我们定义并解释了 BDD 是什么以及为什么我们需要它。通过实施 BDD,我们将能够更好地开发解决方案,以正确地解决实际业务目标。我们可以开始使用 Gherkin 语言编写的功能和场景来定义这些业务目标,Gherkin 语言只是简单的英语。通过这样做,公司来自不同团队的不同人员将能够在定义系统的预期行为方面更好地协调和理解彼此。这将有助于弥合不同团队之间的差距和语言障碍。
我们创建了一个功能和场景,然后使用 Behat、Mink 和 Goutte 定义预期系统行为,打开无头浏览器,访问网络应用程序,并验证主页的内容。
这只是 BDD 冰山一角。在下一章中,我们将开始编写解决方案代码,同时确保我们的代码通过使用 BDD 和 TDD 一起,是可维护和可测试的。
第七章:使用 BDD 和 TDD 构建解决方案代码
现在我们已经了解了使用测试驱动开发(TDD)和行为驱动开发(BDD)编写测试程序的基础,我们可以开始在开发我们的示例应用程序时使用这两个过程。当处理商业成功的大型应用程序时,有一件事是共同的:它们都需要维护。在产品的功能方面,总会有改进的空间。可能会有一些被遗漏的 bug,而且更常见的是,为了改进产品,将不断向应用程序中添加更多功能。这就是编写糟糕的代码变得更糟的通常方式。一个编写得很好的类最终可能成为一个神级类:一个可以用几千行代码做所有事情的类。一个开发者可以在另一个开发者使用该类的同时在神级类中编写额外的函数,因此改变类的行为。你可以猜到接下来会发生什么!引入了一个新的 bug。
在开发过程中,开发者可能会开始开发一个将依赖于尚未编写的其他功能的功能。那么,我们如何为这类功能编写测试呢?我们需要开始模拟这些依赖项。在本章中,我们将学习如何使用模拟对象,并且我们将开始编写代码,以便遵循 SOLID 原则使其更干净、更容易维护。我们还将使用红-绿-重构模式来帮助我们构建所需的测试和功能。但在所有这些之前,我们首先创建一个 Behat 功能来启动我们将要编写的所有测试和代码。
在本章中,我们将探讨以下主题:
-
实施红-绿-重构模式
-
为示例项目编写测试和解决方案代码
-
基于 Jira 工单创建 Behat 功能
-
通过 Behat 注册功能
技术要求
在本章中,建议您使用以下来自此代码仓库的基本代码:github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%207/base/phptdd。完成本章后,最终的解决方案代码可以在github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%207/complete处找到以供参考。
准备章节的开发环境
首先,获取本章的基础代码,可以在github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%207/base找到,或者简单地运行以下命令:
curl -Lo phptdd.zip "https://github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/raw/main/Chapter%207/base.zip" && unzip -o phptdd.zip && cd base && ./demoSetup.sh
要运行容器并执行本章中的命令,你应该在 docker-server-web-1 容器内。
运行以下命令以确认我们的 web 服务器的容器名称:
docker ps
要运行容器,从你的主机机器上的 /docker 目录运行以下命令:
docker-compose build && docker-compose up -d
docker exec -it docker-server-web-1 /bin/bash
一旦进入容器,运行以下命令以通过 Composer 安装所需的库:
/var/www/html/symfony# ./setup.sh
/var/www/html/behat# ./setup.sh
实施红-绿-重构模式
红绿重构模式是一种编程方法,用于实现 TDD。这是一个循环,你首先故意编写一个失败的测试,在执行测试时,你会看到一个红色的失败信息。然后,你编写解决方案代码来通过那个测试,在这个过程中,你会看到一个绿色的通过信息。通过测试后,你就可以回到清理和重构你的测试和解决方案代码了。
如果你打开本书前面创建的 codebase/symfony/runDebug.sh 文件,在 第五章 中,你会注意到我们通过添加 --color=always 参数来运行 PHPUnit。然后,无论何时我们运行 PHPUnit 并得到失败的测试,你都会注意到我们总是得到红色的错误或失败的测试信息。
为了清楚地展示这个模式,让我们通过一个例子来演示:
- 创建一个名为
HelloTest.php的新文件:
codebase/symfony/tests/Unit/HelloTest.php
<?php
namespace App\Tests\Unit;
use PHPUnit\Framework\TestCase;
class HelloTest extends TestCase
{
public function testCanSayHello()
{
$this->fail("--- RED ---");
}
}
-
在创建新的单元测试后,运行以下命令以确保 PHPUnit 可以执行
testCanSayHello测试:/var/www/html/symfony# php bin/phpunit --filter testCanSayHello --color=always
你应该会看到以下结果:

图 7.1 – 红色高亮的失败信息
在 TDD 中,我们总是从编写一个没有任何实现支持的测试开始。然后我们需要运行这个测试以确保 PHPUnit 识别测试并且可以执行它。我们还想确认我们已经创建了正确的测试类,在正确的测试套件和正确的目录中,并且使用了正确的命名空间。
在运行之前提到的命令后,这个新创建的测试将如预期失败,PHPUnit 将显示红色的错误或失败信息。这就是红-绿-重构模式中的红色!
一旦我们确信可以使用 PHPUnit 运行测试,我们就可以开始编写代码来通过失败的测试。还记得 TDD 吗?我们的测试将启动或驱动解决方案代码的创建,以解决问题,因此是测试驱动的。所以,现在,为了快速通过失败的测试,我们将编写一些代码来通过失败的测试,按照以下步骤进行:
- 修改测试并添加一个新类:
Codebase/symfony/tests/Unit/HelloTest.php
<?php
namespace App\Tests\Unit;
use App\Speaker;
use PHPUnit\Framework\TestCase;
class HelloTest extends TestCase
{
public function testCanSayHello()
{
$speaker = new Speaker();
$this->assertEquals('Hello' $speaker->
sayHello());
}
}
- 创建一个新类:
codebase/symfony/src/Speaker.php
<?php
namespace App;
class Speaker
{
public function sayHello(): string
{
return 'Hello';
}
}
在HelloTest类中,我们修改了testCanSayHello()方法,使其创建我们创建的新Speaker类的实例,然后在断言行中,我们直接将预期的单词Hello与sayHello()方法返回的字符串进行比较。现在,如果我们再次运行测试,我们就不应该再看到红色的失败消息。
-
使用以下命令运行相同的测试:
/var/www/html/symfony# php bin/phpunit --filter testCanSayHello --color=always
现在,我们应该看到以下结果来自 PHPUnit:

图 7.2 – 高亮显示的绿色消息
我们通过了测试!现在,我们的testCanSayHello()测试不再返回红色的错误/失败消息。我们只做了最基本的工作来通过测试,现在我们可以看到一个绿色的OK (1 test, 1 assertion)消息。这是红-绿-重构模式中的绿色。
当你在自己的项目中工作时,在通过测试的这个阶段,你可以继续进行列表中的下一个测试或下一个问题,或者你可以尝试改进测试和解决方案代码,使其更简洁、更易于阅读。
在本例中,我们将继续改进测试和解决方案代码,以便它支持更多的测试场景。
按照以下步骤操作:
- 使用以下内容修改
HelloTest类:
codebase/symfony/tests/Unit/HelloTest.php
<?php
namespace App\Tests\Unit;
use App\Speaker;
use PHPUnit\Framework\TestCase;
class HelloTest extends TestCase
{
/**
* @param \Closure $func
* @param string $expected
* @dataProvider provideHelloStrings
*/
public function testCanSayHello(\Closure
$func, string $expected)
{
$speaker = new Speaker();
$helloMessage = $speaker->sayHello($func);
$this->assertEquals($expected, $helloMessage);
}
/**
* @return array[]
*/
private function provideHelloStrings(): array
{
return [
[function($str) {return ucfirst($str);},
'Hello'],
[function($str) {return strtolower($str)
;}, 'hello'],
[function($str) {return strtoupper($str)
;}, 'HELLO'],
];
}
}
- 使用以下内容修改
Speaker.php类:
codebase/symfony/src/Speaker.php
<?php
namespace App;
class Speaker
{
/**
* @return string
*/
public function sayHello(\Closure $func): string
{
return $func('Hello');
}
}
我们重构了测试,以便我们可以为Speaker.php类添加更多灵活性。我们还重构了HelloTest.php测试类本身,使其更加灵活。如果我们再次运行测试,我们应该仍然通过测试。
-
通过运行以下命令再次运行测试:
/var/www/html/symfony# php bin/phpunit --filter testCanSayHello --color=always
现在,我们应该看到以下结果:

图 7.3 – 重构后仍然绿色
你会注意到,我们不是使用@dataProvider。然后我们创建了一个名为provideHelloStrings()的新函数,它返回一个包含闭包和字符串的数组。每个数组集将被用作testCanSayHello()测试方法的参数。在这个阶段,即使我们进行了重构,我们仍然可以通过测试。这是红-绿-重构模式中的重构阶段。
在现实世界的企业项目中,编写依赖于其他人项目(而你或你的团队无法轻易获得)的程序是非常常见的。这会阻止你开发依赖于尚未完成的某物的程序吗?可能不会!接下来,我们需要一种方法来专注于测试我们应用程序的特定部分,即使它依赖于尚未构建的其他对象。为此,我们将需要使用模拟对象。
为示例项目编写测试和解决方案代码
在第二章 理解并组织我们项目的业务需求中,我们使用 Jira 作为工具来组织我们需要为项目构建的项目清单。除了使用 Jira 之外,还有其他的项目跟踪软件,或者我们也可以简单地使用便签或一张纸,写下我们想要编写的程序的任务。但我们只是想更有条理一些,如果你与软件开发团队以及其他公司团队一起工作,使用问题跟踪软件比使用一张物理纸张更容易协作。
我们已经将 Jira 用户故事分为两组:库存管理员解决方案组和访客页面组。这些组被称为史诗。我们将首先开始工作于库存管理员解决方案史诗。这是为了让汽车博物馆的库存管理员能够将那些宝贵的数据输入到系统中供访客查看。
到目前为止,当我们通过 BDD 和 TDD 进行操作时,我们以我们的开发环境设置为例进行操作。现在,我们也可以用它来构建我们的示例项目。从github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%207/base/phptdd下载基础代码。你可以使用基础代码并将其推送到与你的 Jira 项目链接的 master 分支。然后,我们将从该 master 分支分叉,并将所有后续的票证分支出来,合并到该 master 分支中。
让我们从第一张票开始,TOYC-2。回到你的 Jira TOYC-2 故事,然后从弹出菜单中点击 创建分支 链接:

图 7.4 – TOYC-2 故事:创建分支链接
我们需要为这个特性创建一个新的 Bitbucket 分支。这就是我们将提交所有为这个特定票证构建的额外代码的地方。理想情况下,你需要从一个 master 分支分叉出一个分支。然后我们将从 develop 分支分叉,完成一个特性后,我们将将其合并回 develop 分支。
从 develop 分支创建一个新的特性分支。让我们称这个分支为 TOYC-1,它代表我们的 TOYC-1 分支,并创建一个新的特性分支——让我们称它为 TOYC-2。将 TOYC-2 分支检出至你的本地开发环境,在这个阶段,你应该已经将所有基础文件克隆到你的本地机器中。
我们需要确保我们的容器正在运行。运行以下命令来构建和运行容器。
使用你的终端,在 docker 目录下,运行以下命令:
$ docker-compose build && docker-compose up -d
容器成功运行后,执行以下命令,并确保你可以进入网络容器:
$ docker exec -it docker_server-web_1 /bin/bash
到目前为止,您应该已经看到了我们的主要 behat 和 symfony 目录:

图 7.5 – behat 和 symfony 根目录
在这个阶段,我们的开发环境再次正常运行。接下来,我们将创建一个 Behat 功能,这将帮助我们开始为 TOYC-2 工单的软件解决方案的开发。
基于 Jira 工单创建 Behat 功能
在上一章中,我们学习了如何创建一个简单的 Behat 功能。在本节中,我们将创建一个新的 Behat 功能,该功能将代表我们在 第二章,理解和组织我们项目的业务需求中创建的 TOYC-2 Jira 工单。这将有助于推动集成和单元测试的开发,从而帮助我们构建实际的解决方案代码。让我们开始步骤。
创建一个 Behat 功能文件,命名为 inventory_clerk_registration.feature,并保存以下功能内容:
codebase/behat/features/inventory_clerk_registration.feature
Feature: Inventory Clerk Registration
In order to access the inventory system
As an Inventory Clerk
I need to be able to create a clerk account
Scenario: Access Registration Page
Given I am in the home "/" path
When I click the "Register" link
Then I should be redirected to the registration page
Scenario: Register
Given I am in the register "/register" path
When I fill in Email "Email" with
"clerk_email@phptdd.bdd"
And I fill in Password "Password" with "password"
And I check the "AgreeTerms" checkbox
And I click on the "Register" button
Then I should be able to register a new account
如果您阅读了我们刚刚创建的 Behat 功能,它将非常清楚地说明我们试图实现什么。这些步骤是现实生活中的用户为了能够注册到我们的系统中会执行的步骤。在这个阶段,我们不会构建解决方案代码,而是首先创建 Behat 注册测试代码。
创建 Behat 注册功能
由于我们使用的是本章的基础代码,因此我们必须确保安装了所有库,以便我们能够运行 Behat。
我们需要再次安装 Composer 包才能使用 Behat。运行以下命令以重新安装所需的库:
/var/www/html/behat# composer install
这将拉取并安装我们在上一章中使用的所有库。安装后,让我们看看我们是否可以为我们的登录功能生成 Behat PHP 类:
- 使用以下内容更新
behay.yml文件:
codebase/behat/behat.yml
default:
suites:
default:
contexts:
- FeatureContext
- HomeContext
- InventoryClerkRegistrationContext
-
更新
behat.yml文件后,现在尝试运行以下命令以生成 PHP 上下文类:/var/www/html/behat# ./vendor/bin/behat --init
运行命令后,我们应该能够在 codebase/behat/features/bootstrap/InventoryClerkRegistrationContext.php 中生成一个新的 PHP 类。
- 现在,让我们尝试根据
inventory_clerk_registration.feature文件自动在InventoryClerkRegistrationContext.php类中生成 PHP 方法。
运行以下命令:
/var/www/html/behat# ./vendor/bin/behat features/inventory_clerk_registration.feature --append-snippets
运行命令后,您应该看到以下结果:

图 7.6 – 自动生成上下文方法
运行前面的命令后,如果您打开 codebase/behat/features/bootstrap/InventoryClerkRegistrationContext.php 类,您应该能够看到新添加的方法。现在,如果我们运行 Behat,我们可能会得到一个失败的结果。
-
运行以下命令:
/var/www/html/behat# ./vendor/bin/behat
您应该看到以下结果:

图 7.7 – Behat 失败
你会注意到我们失败了首页功能,跳过了挂起的测试,在这个阶段,我们甚至无法访问首页。这是因为我们还需要为 Symfony 应用程序安装缺失的库。就像我们对 Behat 所做的那样,让我们为 Symfony 应用程序安装缺失的 Composer 包。
-
运行以下命令:
/var/www/html/symfony# composer install -
在安装缺失的 Symfony 包之后,让我们修改
InventoryClerkRegistrationContext.php类,以便在iAmOn方法上抛出异常:
codebase/behat/features/bootstrap/InventoryClerkRegistrationContext.php
/**
* @Given I am on \/
*/
public function iAmOn()
{
throw new \Exception();
}
现在,让我们再次尝试运行 Behat,看看我们是否至少能够通过首页功能测试。
-
通过运行以下命令再次运行 Behat:
/var/www/html/behat# vendor/bin/behat
现在我们应该能够通过首页功能测试,同时仍然无法通过库存管理员功能测试:

图 7.8 – 首页功能通过,登录失败
由于我们已安装了缺失的 Symfony 包,首页测试现在通过了。但因为我们还没有构建任何解决方案代码来通过登录测试,它将继续失败。
通过遵循红-绿-重构模式,现在我们有一个失败的测试,这是红阶段,我们现在可以继续编写通过这个失败的测试所需的解决方案代码,这是绿阶段。
通过 Behat 注册功能
现在我们有几个失败的 Behat 登录功能测试,让我们尝试做最少的努力来完成这个功能,并通过测试。幸运的是,Symfony 使得实现安全性变得容易。我们可以使用symfony/security-bundle Composer 包来为我们的应用程序添加身份验证和授权,而无需从头开始构建一切。
你可以在symfony.com/doc/current/security.html上了解更多关于 Symfony 安全文档的信息。
为了通过失败的 Behat 注册功能,因为 Behat 模拟用户使用网络浏览器,我们必须创建所有必要的程序,以便真实用户能够从网络浏览器在我们的应用程序中注册账户,然后击中控制器,服务,然后到数据库持久化过程。让我们从控制器开始。
编写失败的控制器测试
在通过我们的主要 Behat 功能测试之前,这些测试也可以被视为功能测试,让我们在我们的 Symfony 应用程序内部编写一些控制器测试。尽管 Behat 测试也会对控制器进行测试,但这些 Symfony 控制器测试将比 Behat 功能测试更简单。
通过阅读我们之前创建的 Behat 注册功能,我们可以轻松地确定我们至少需要两个控制器:主页控制器和注册页面控制器。主页是用户开始旅程的地方,注册页面是职员为新账户注册的地方。
创建以下内容的首页测试类:
codebase/symfony/tests/ Integration /Controller/HomeControllerTest.php
<?php
namespace App\Tests\Integration\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class HomeControllerTest extends WebTestCase
{
public function testCanLoadIndex(): void
{
$client = static::createClient();
$client->request('GET', '/');
$this->assertResponseIsSuccessful();
}
}
接下来,创建以下内容的注册页面测试类:
codebase/symfony/tests/ Integration /Controller/RegistrationControllerTest.php
<?php
namespace App\Tests\Integration\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class RegistrationControllerTest extends WebTestCase
{
public function testCanLoadRegister(): void
{
$client = static::createClient();
$client->request('GET', '/register');
$this->assertResponseIsSuccessful();
$this->markTestIncomplete();
}
}
现在我们已经有了我们将用于通过 Behat 功能测试的主要控制器的测试,让我们首先看看我们是否通过了这些 Symfony 测试。
运行以下命令:
/var/www/html/symfony# php bin/phpunit --testsuite Functional
运行测试后,你应该得到两个失败的测试。我们使用了--testsuite参数,以便只执行我们刚刚创建的两个控制器测试。
现在我们知道我们必须通过这两个测试,我们可以继续工作,解决通过它们的问题。在这个阶段,我们处于之前章节中讨论的 Red-Green-Refactor 模式的“红”阶段。
我们现在可以开始着手注册和注册解决方案了。
使用 Symfony 实现注册解决方案
使用开源框架的好处是,我们开发者需要为我们的项目构建的许多软件很可能已经被作为开源库或包构建。为了通过失败的注册测试,让我们使用 Symfony 的security-bundle包而不是从头开始编写一切。
记住——作为软件开发者,我们不仅仅编写代码。我们开发解决方案。如果存在可以帮助你加快解决方案开发速度的现有包或库,并且如果它们符合你的规格,你可以考虑使用它们而不是从头开始构建代码。
你可以在 Symfony 官方文档页面上了解有关 Symfony 安全解决方案的更多信息:symfony.com/doc/current/security.html。
我们可以通过运行以下命令使用 Symfony 的安全解决方案:
/var/www/html/symfony# php bin/console make:user
阅读提示并输入建议的默认值。
接下来,我们需要设置我们需要的数据库。记住——我们不仅使用一个数据库,还需要一个单独的测试数据库。你可以在第五章,单元测试中了解更多关于此内容。
数据库设置
我们需要创建两个数据库:cars和cars_test数据库。cars数据库将作为我们的主数据库,而cars_test数据库将类似于一个副本数据库,我们的自动化测试将使用它。毕竟,你不想在生产数据库上运行数据突变测试。
运行以下命令来设置我们的数据库:
/var/www/html/symfony# php bin/console doctrine:database:create --env=test
/var/www/html/symfony# php bin/console doctrine:database:create
/var/www/html/symfony# php bin/console make:migration
/var/www/html/symfony# php bin/console doctrine:migrations:migrate -n --env=test
/var/www/html/symfony# php bin/console doctrine:migrations:migrate -n
正如我们在第五章中做的,单元测试,我们已经根据codebase/symfony/src/Entity目录中找到的 Doctrine 实体创建了我们的 MySQL 数据库和表。
接下来,让我们使用 Symfony 的security-bundle包创建一个注册表单。
使用 Symfony 的注册表单
接下来,我们可以使用 Symfony 的注册表单。基本解决方案代码已经在composer.json文件中声明了所有依赖项,所以你可以直接运行以下命令来生成注册代码:
/var/www/html/symfony# php bin/console make:registration-form
上述命令将生成几个文件,其中之一是RegistrationController.php类。如果你打开这个类,你会看到它有一个register方法。我们也为这个控制器和方法创建了一个测试。让我们看看它现在是否工作。
运行以下命令:
/var/www/html/symfony# php bin/phpunit --filter RegistrationControllerTest
运行测试后,我们现在应该能够通过这个测试:

图 7.9 – 通过注册路由测试
在这个阶段,我们处于红-绿-重构模式的“绿色”阶段。这意味着我们完成了注册功能吗?绝对不是。因为我们还没有完成这个测试,通常我会使用 PHPUnit 的$this->markTestIncomplete();方法并将其添加到测试类中。这可以帮助提醒开发者测试已经编写,解决方案部分存在,但仍然不完整。现在,请在codebase/symfony/tests/Functional/Controller/RegistrationControllerTest.php测试类中的testCanLoadRegister方法内添加$this->markTestIncomplete();方法。
现在,再次运行测试:
/var/www/html/symfony# php bin/phpunit --filter RegistrationControllerTest
你应该看到以下结果:

图 7.10 – 不完整的注册路由测试
现在测试被标记为不完整,我们可以在稍后回到它。是否使用这个功能取决于你,但我在处理大型项目时觉得它很有用。唯一我不喜欢的是,有时候它没有失败测试那么吸引我的注意力。现在,让我们移除不完整的标记。
创建主页控制器
现在让我们创建一个主页控制器,用户通常会首先到达这里。在这里,我们也会找到用户点击以重定向到注册页面的注册链接。
通过运行以下命令创建一个主页控制器:
/var/www/html/symfony# php bin/console make:controller HomeController
运行那个命令后,我们现在应该有一个新的 Symfony 控制器在codebase/symfony/src/Controller/HomeController.php。编辑控制器内的路由,并将/home替换为仅一个正斜杠(/)。
现在,让我们看看我们的控制器测试是否通过。再次运行 Symfony 功能测试:
/var/www/html/symfony# php bin/phpunit --testsuite Functional --debug
你现在应该看到以下结果:

图 7.11 – 通过控制器测试
由于我们的控制器测试非常简单,我们基本上只是在测试路由的页面响应是否成功;我们现在可以确信这两个测试都通过了。但这不会满足 Behat 注册功能测试的要求。所以,让我们继续工作吧!
让我们修改主页控制器的 twig 模板内容。打开以下文件,并将整个example-wrapper div 内容替换为以下内容:
codebase/symfony/templates/home/index.html.twig
<div class="example-wrapper">
<h1>{{ controller_name }}</h1>
<ul>
<li><a href="/register" id="lnk-register">
Register</a> </li>
</ul>
</div>
我们刚刚添加了一个指向注册页面的链接。如果你尝试通过浏览器访问主页,你会看到类似以下的内容:

图 7.12 – HomeController
接下来,让我们回到behat目录下的 BDD 测试。让我们尝试编写一些测试代码,看看我们是否最终能够注册一个新用户。
通过 Behat 功能传递
我们的 Behat 注册功能模拟了用户访问主页,点击注册链接,被重定向到注册页面,填写注册表单,点击注册按钮,然后被重定向到某个选定的页面。
这正是手动测试员测试注册功能时会做的事情。而不是手动使用浏览器执行这些步骤,让我们只用 Behat 为我们完成所有这些步骤。
打开以下 Behat 上下文文件,并将内容替换为以下内容:
codebase/behat/features/bootstrap/InventoryClerkRegistrationContext.php
<?php
use Behat\Mink\Mink;
use Behat\Mink\Session;
use Behat\Mink\Driver\GoutteDriver;
use Behat\MinkExtension\Context\MinkContext;
use Behat\MinkExtension\Context\MinkAwareContext;
/**
* Defines application features from the specific context.
*/
class InventoryClerkRegistrationContext extends MinkContext implements MinkAwareContext
{
/**
* Initializes context.
*
* Every scenario gets its own context instance.
* You can also pass arbitrary arguments to the
* context constructor through behat.yml.
*/
public function __construct()
{
$mink = new Mink([
'goutte' => new Session(new GoutteDriver()), // Headless browser
]);
$this->setMink($mink);
$this->getMink()->getSession('goutte')->start();
}
}
在前面的片段中,我们从构造函数开始。我们在类中声明了我们将要使用的模拟器和会话对象。
接下来,添加以下代码:
/**
* @Given I am in the home :arg1 path
*/
public function iAmInTheHomePath($arg1)
{
$sessionHeadless = $this->getMink()->getSession
('goutte');
$sessionHeadless->visit($arg1);
// Make sure the register link exists.
$assertHeadless = $this->assertSession('goutte');
$assertHeadless->elementExists('css', '#lnk-register');
}
/**
* @When I click the :arg1 link
*/
public function iClickTheLink($arg1)
{
$sessionHeadless = $this->getMink()->getSession
('goutte');
$homePage = $sessionHeadless->getPage();
$homePage->clickLink($arg1);
}
上述代码将模拟用户在主页上,然后点击注册链接。
在下一个片段中,Behat 将尝试确认它是否被重定向到了注册控制器页面:
/**
* @Then I should be redirected to the registration page
*/
public function iShouldBeRedirectedToTheRegistrationPage()
{
// Make sure we are in the correct page.
$assertHeadless = $this->assertSession('goutte');
$assertHeadless->pageTextContains('Register');
$assertHeadless->elementExists('css', '#registration_form_email');
}
你可以通过检查路由来轻松地检查你是否在正确的页面上,但前面的片段显示了你可以检查由控制器返回的 DOM 本身。
接下来,添加以下代码来模拟用户在输入表单中输入值:
/**
* @When I fill in Email :arg1 with :arg2
*/
public function iFillInEmailWith($arg1, $arg2)
{
$sessionHeadless = $this->getMink()->getSession
('goutte');
$registrationPage = $sessionHeadless->getPage();
$registrationPage->fillField($arg1, $arg2);
}
/**
* @When I fill in Password :arg1 with :arg2
*/
public function iFillInPasswordWith($arg1, $arg2)
{
$sessionHeadless = $this->getMink()->getSession
('goutte');
$registrationPage = $sessionHeadless->getPage();
$registrationPage->fillField($arg1, $arg2);
}
在前面的片段中,代码模拟了在Email和Password字段中输入文本。接下来,我们将模拟检查复选框并点击提交按钮。添加以下代码:
/**
* @When I check the :arg1 checkbox
*/
public function iCheckTheCheckbox($arg1)
{
$sessionHeadless = $this->getMink()->getSession
('goutte');
$registrationPage = $sessionHeadless->getPage();
$registrationPage->checkField($arg1);
}
/**
* @When I click on the :arg1 button
*/
public function iClickOnTheButton($arg1)
{
$sessionHeadless = $this->getMink()->getSession
('goutte');
$registrationPage = $sessionHeadless->getPage();
$registrationPage->pressButton($arg1);
}
在前面的代码中,我们检查了同意条款复选框,然后点击了注册按钮。
接下来,添加以下代码来完成测试:
/**
* @Then I should be able to register a new account
*/
public function iShouldBeAbleToRegisterANewAccount()
{
$sessionHeadless = $this->getMink()->getSession
('goutte');
$thePage = $sessionHeadless->getPage()->getText();
if (!str_contains($thePage, 'There is already an
account with this email')) {
$assertHeadless = $this->assertSession('goutte');
$assertHeadless->addressEquals('/home');
}
由于在 Symfony 应用中,我们在成功时将用户重定向回主页控制器,因此我们可以检查我们是否被重定向到了主页。你会注意到它还检查了用户是否已经存在;你可以根据需要进一步分解这个测试,以便可以分离出这样的场景。
在前面的代码块中,我们做了以下工作:将codebase/behat/features/inventory_clerk_registration.feature文件中的场景分解成 PHP 方法。然后我们编写了 PHP 代码来点击链接和按钮,填充文本字段,勾选复选框等等。
但让我们看看这是否真的有效。运行以下命令来运行此测试:
/var/www/html/behat# ./runBehat.sh --suite=suite_a features/inventory_clerk_registration.feature
执行将需要几秒钟,但您应该得到以下结果:

图 7.13 – 注册功能测试
通过运行 Behat 测试,我们可以替换通常从浏览器进行的手动测试过程。但我们需要确认我们是否真的能够注册,并且使用 Doctrine ORM 将数据持久化到我们的 MySQL 数据库中!在这个阶段,我们处于 Red-Green-Refactor 模式的“重构”阶段,我个人认为“重构”阶段可以更加开放和灵活。
您可以使用自己的 MySQL 客户端或我们之前配置的 phpMyAdmin 应用程序(第三章,使用 Docker 容器设置我们的开发环境),来验证数据。
您可以使用 MySQL 容器中的命令行得到以下结果:

图 7.14 – 用户成功注册:从 CLI 查看
这是我们配置的 phpMyAdmin 应用程序的结果,可以通过本地浏览器在http://127.0.0.1:3333访问:

图 7.15 – 用户成功注册:从 phpMyAdmin 查看
从数据库中我们可以看到,我们已经能够持久化注册详情。在这个阶段,我们可以说我们的注册功能是正常工作的!而且我们能够在不手动打开桌面浏览器输入表单详情的情况下对其进行测试。
我们现在有一个 PHP 程序在为我们进行注册功能测试,但我们还需要构建登录功能以及最重要的部分:库存系统本身。我们还有许多其他功能要构建,但这是一个很好的开始!
摘要
在本章中,我们首先基于一个 Jira 工作项创建了一个易于理解的特性列表和场景列表,详细说明了需要构建的内容。在着手编写解决方案代码之前,我们首先从 Gherkin 功能“库存管理员注册”开始。这个功能可以被任何人阅读——甚至非开发者也能理解它。这个功能解释了我们的系统应该如何表现。然后,我们根据这种行为在 Symfony 应用程序内部创建了简单且失败的函数测试。创建这些失败的测试给我们列出了一些建设内容。然后,我们着手开发解决方案以通过这些失败的函数测试。最后,我们编写了代码来告诉 Behat 如何执行点击链接或按钮以及填写字段的复杂步骤。BDD 和 TDD 不仅关乎编写自动化测试——它关乎将它们作为开发我们解决方案的过程。
在下一章中,我们将继续构建测试和解决方案代码。我们将通过 SOLID 原则来帮助我们结构化自己的代码,以确保代码更易于维护和测试。
第八章:使用 SOLID 原则进行 TDD
当我开始编程时,我立刻沉迷其中。我对于使用程序和自己的想象力来解决问题想出解决方案的想法感到非常兴奋。在学校的时候,有一次老师给我们布置了一个任务,就是使用 Turbo-C 来解决一些简单的代数挑战。当我很快意识到我可以编写程序来反复解决这类挑战时,我感到非常兴奋,甚至起了一身鸡皮疙瘩。编写一次程序,传递不同的参数,得到不同的结果。我喜欢这样。我记得有一个挑战是计算如果有人站在桥上,扔下一个球,几秒钟后听到声音,桥的高度是多少。简单!现在,我可以用我的程序反复计算桥的高度。现在,我不再需要记住地球的重力加速度大约是 9.8 m/s²了——我可以在程序中声明它!我了解到在编程中,我可以遵循自己的规则从 A 点到 B 点。给我一个任务,我可以用自己的想象力想出解决方案来完成这个任务。对我来说,这就是编程最好的地方。我成了一个自豪的意大利面代码编写机器。我不在乎我的代码有多干净——我只需要用代码来解决问题!学习其他编程语言让我更加兴奋,我认为可能性是无限的——如果任务或挑战不违反物理定律,我认为可以用编程来解决!我没有注意代码的整洁性或可维护性。那些是什么?我不需要那些!
当我开始作为软件开发者专业工作时,我继续以仅仅享受使用编程解决问题的心态工作。我不在乎我的解决方案有多不整洁——它们解决了问题,我的雇主和客户都很高兴。完成了,我就可以离开了。太简单了。我认为我知道一切,而且我觉得自己无所不能。哦,我错了。我学得越多,就越意识到我懂得编程的越少。
当我继续与其他开发者一起工作,同时维护这些项目时,我痛苦地意识到,由于编写了自己难以维护的代码,我给自己带来了多大的麻烦。我可能不是地球上唯一经历过这种问题的开发者。我确信其他人之前也遇到过这些问题,我也确信有解决方案。其中一个帮助我生活变得容易的解决方案是尝试遵循罗伯特·C·马丁的SOLID原则。它们真的帮助改变了我的编程生活,并且使用这些原则与测试驱动开发(TDD)一起使我的编程生活变得更加容易!还有更多的原则和架构设计模式可以帮助使你的应用程序更易于维护,但在这个章节中,我们将逐个关注 SOLID 原则,并在进行 TDD 的同时进行。
我们将逐步解释将 Jira 工单转换为 BDD 测试的过程,这将反过来帮助我们创建集成测试,直至开发解决方案代码。然后,我们将逐个通过使用 TDD 来介绍每个 SOLID 原则,就像在真实项目中做的那样。
本章将介绍以下主题:
-
Jira 到 BDD 到 TDD
-
基于单一职责原则的 TDD
-
基于开放封闭原则的 TDD
-
基于 Liskov 替换原则的 TDD
-
基于接口分离原则的 TDD
-
基于依赖倒置原则的 TDD
技术要求
在本章中,读者需要使用位于github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%208的仓库中的基础代码。
准备本章的开发环境
首先,获取本章的基础代码,该代码位于 https://github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter 6/base/phptdd,或者简单地运行以下命令:
curl -Lo phptdd.zip "https://github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/raw/main/Chapter%208/base.zip" && unzip -o phptdd.zip && cd base && ./demoSetup.sh
要运行容器并执行本章中的命令,你应该在 docker-server-web-1 容器内部。
运行以下命令以确认我们的 Web 服务器的容器名称:
docker ps
要运行容器,从主机机器上的 /docker 目录运行以下命令:
docker-compose build && docker-compose up -d
docker exec -it docker-server-web-1 /bin/bash
一旦进入容器,运行以下命令通过 composer 安装所需的库:
/var/www/html/symfony# ./setup.sh
/var/www/html/behat# ./setup.sh
Jira 到 BDD 到 TDD
根据罗伯特·C·马丁的定义,SOLID 原则是一套编码指南或标准,有助于开发者编写更组织化、解耦、可维护和可扩展的软件。在本章中,我们将逐一介绍它们,但我们将通过在一个真实项目上工作并实现每个原则来模拟这个过程。
在本章中,我们将编写尝试遵循 SOLID 原则的解决方案代码,但在那之前,我们需要一个要解决的问题的例子。正如我们在第七章,“使用 BDD 和 TDD 构建解决方案代码”中所做的那样,我们将从一个 Jira 工单开始,编写一些 Gherkin 特性,编写 Behat 测试,编写集成和单元测试,然后编写如以下流程图所示的遵循 SOLID 原则的解决方案代码:

图 8.1 – 开发流程
让我们使用我们在第二章,“理解并组织我们项目的业务需求”中创建的一个 Jira 工单,理解并组织我们项目的业务需求。我们创建了一个故事,让登录用户输入并保存一些玩具车型号数据。这将是一个很好的简单功能,用于演示 SOLID 原则:

图 8.2 – 创建玩具模型数据的工单
正如我们在第七章,使用 BDD 和 TDD 构建解决方案代码中做的那样,为你的 Jira 票据创建一个新的 git 分支。从你在第二章,理解和组织我们项目的业务需求中设置的仓库检出 git 分支,然后让我们开始编写一些测试和程序!
在我们开始学习 SOLID 原则之前,首先,我们需要工作于 BDD 测试,这将引导我们编写遵循 SOLID 原则的解决方案代码。记住,我们始终需要从失败的测试开始。接下来,为了开始使用 BDD,我们需要首先编写一个 Gherkin 功能。
Gherkin 功能
让我们从编写一个 Gherkin 功能来描述我们期望构建的行为开始。在behat目录中创建以下功能文件,并包含以下内容:
codebase/behat/features/create_toy_car_record.feature
Feature: Clerk creates new toy car record
In order to have a collection of toy car model records
As an Inventory Clerk
I need to be able to create a single record
Scenario: Create new record
Given I am in the inventory system page
When I submit the form with correct details
Then I should see a success message
现在我们有了我们的功能,让我们为它生成 Behat PHP 上下文类。
Behat 上下文
现在,我们将使用 Gherkin 功能并为其创建一个 PHP 上下文类。按照以下步骤操作:
- 首先,更新
behat.yml文件:
codebase/behat/behat.yml
default:
suites:
default:
contexts:
- FeatureContext
- HomeContext
suite_a:
contexts:
- InventoryClerkRegistrationContext
suite_create:
contexts:
- CreateToyCarRecordContext
-
更新主
behat.yml文件后,运行以下命令来创建 PHP 上下文类:/var/www/html/behat# ./vendor/bin/behat --init/var/www/html/behat# ./vendor/bin/behat features/create_toy_car_record.feature --append-snippets –suite=suite_create -
现在应该在
features/bootstrap/CreateToyCarRecordContext.php中创建了一个新的类。重构iAmInTheInventorySystemPage方法,使其抛出\Exception。 -
接下来,让我们确保我们可以通过运行以下命令来执行此功能测试:
/var/www/html/behat# ./vendor/bin/behat features/create_toy_car_record.feature --suite=suite_create
你应该会看到以下测试结果:

图 8.3 – 失败的测试
好的 – 现在,我们知道这个功能的 Behat 测试可以执行并且如预期那样失败,所以让我们继续到 Symfony 应用程序。
功能测试
我们创建的 Behat 测试已经是一个功能测试 – 我们是否还需要在 Symfony 目录中创建一个功能测试?我认为这是可选的,但它将帮助我们快速运行基本烟雾测试 – 例如,如果我们想快速检查我们的控制器是否加载并且没有遇到致命错误。我们不需要运行更大、更慢的 Behat 测试来找出这一点:
- 创建以下测试类,并包含以下内容:
codebase/symfony/tests/Functional/Controller/InventoryAdminControllerTest.php
<?php
namespace App\Tests\Functional\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class InventoryControllerTest extends WebTestCase
{
public function testCanLoadIndex(): void
{
$client = static::createClient();
$client->request(‘GET’, ‘/inventory-admin’);
$this->assertResponseIsSuccessful();
}
}
-
在创建我们的控制器测试类之后,让我们运行以下命令以确保 PHPUnit 可以执行此测试并且它失败:
/var/www/html/symfony# ./vendor/bin/phpunit --filterInventoryAdminControllerTest
在运行测试后,请确保你得到一个测试失败。还记得那个红色阶段吗?
太好了 – 我们现在可以暂时忘记创建控制器了。让我们继续到集成测试。这些测试将用于开发将玩具车模型持久化到数据库的机制。
集成测试
我们现在需要开始编写集成测试,这将帮助我们编写代码以持久化或创建一个新的玩具车模型。通过通过这些测试后,然后我们可以回到我们之前创建的 Behat 测试,并确保它们通过:
- 创建以下测试类,内容如下:
codebase/symfony/tests/Integration/Processor/ToyCarProcessorTest.php
<?php
namespace App\Tests\Integration\Repository;
use Symfony\Bundle\FrameworkBundle\Test\
KernelTestCase;
class ToyCarProcessorTest extends KernelTestCase
{
public function testCanCreate()
{
$this->fail(“--- RED ---”);
}
}
-
在创建测试类后,确保 PHPUnit 可以通过运行以下命令识别新的测试类:
/var/www/html/symfony# ./vendor/bin/phpunit --filter ToyCarRepositoryTest -
运行命令后,你应该看到熟悉的、令人安慰的 PHPUnit 失败结果:

图 8.4 – 失败的处理器测试
现在我们有一个失败的集成测试,让我们构建代码来通过它。我们希望能够将新的玩具车模型持久化到持久层,也就是我们的数据库。我们甚至有为其创建的 DB 表吗?没有,还没有。但我们不在乎。我们可以继续工作在解决方案代码上。接下来,我们将尝试遵循单一职责原则(SRP)来编写我们的解决方案代码。
使用单一职责原则进行 TDD(测试驱动开发)
让我们从我认为在 SOLID 原则中最重要的一条原则开始。你对上帝类或对象熟悉吗——一个类可以几乎做任何事情?一个用于登录、注册、显示已注册用户等的单一类?如果有两个开发者正在同一个上帝类上工作,你能想象这会有多具挑战性吗?而且,当你将其部署到生产环境中并发现显示已注册用户列表的部分有问题时,会发生什么?你将不得不更改或修复那个上帝类,但现在用于登录和注册的同一个类已经被修改,这些流程也可能受到影响。仅仅尝试修复已注册用户列表,就有可能引入登录和注册功能的回归。你修复了一个功能,但打破其他功能的可能性更大。
这就是 SRP 开始变得有意义的地方。SRP 规定,一个类应该只有一个主要职责,以及一个改变的理由。这很简单吗?有时并不简单。一个Login类应该只了解让用户登录,而不应该让程序负责显示已注册用户列表或检查购物车,但有时在哪里划线可能非常主观。
接下来,我们将开始编写实际的解决方案代码,同时尝试实现 SRP(单一职责原则)。
编写解决方案代码
我们有一个失败的测试,用于测试我们的应用程序是否能够创建一个玩具车模型并将其持久化到数据库中,但我们还没有为它创建数据库表。没关系——我们现在只关注 PHP 方面的事情。
模型类
对于我们的处理器 PHP 类来说,处理对象而不是直接了解数据库表行等会更好。让我们创建一个普通 PHP 对象(POPO),它将代表玩具汽车模型,而不关心数据库结构:
- 创建以下文件,内容如下:
codebase/symfony/src/Model/ToyCar.php
<?php
namespace App\Model;
class ToyCar
{
/**
* @var int
*/
private $id;
/**
* @var string
*/
private $name;
/**
* @var CarManufacturer
*/
private $manufacturer;
/**
* @var ToyColor
*/
private $colour;
/**
* @var int
*/
private $year;
}
在声明属性后,最好为所有这些属性生成访问器和修改器,而不是直接访问它们。
如您所见,这只是一个 POPO 类。没有任何关于如何在我们的数据库中持久化的信息。它的职责只是作为一个代表玩具汽车的模型。
- 让我们再创建
CarManufacturer和ToyColor模型。创建以下类,内容如下:
codebase/symfony/src/Model/ToyColor.php
<?php
namespace App\Model;
class ToyColor
{
/**
* @var int
*/
private $id;
/**
* @var string
*/
private $name;
}
在声明属性后,为这个类生成访问器和修改器。
- 以下为汽车制造商的示例:
codebase/symfony/src/Model/CarManufacturer.php
<?php
namespace App\Model;
class CarManufacturer
{
/**
* @var int
*/
private $id;
/**
* @var string
*/
private $name;
}
现在,也为这个类生成访问器和修改器。
现在,我们有主要的ToyCar模型,它也使用了ToyColor和CarManufacturer模型。如您所见,与ToyCar模型一样,这两个类也不负责持久化或读取数据。
如您所记,我们正在使用 Doctrine ORM 作为与数据库交互的工具。如果我们想的话,我们也可以在我们的处理器类中直接使用 Doctrine 实体,但这意味着我们的处理器类现在将使用一个依赖于 Doctrine 的类。如果我们需要使用不同的 ORM 呢?为了使事情稍微不那么耦合,我们将在我们即将创建的处理器类中使用codebase/symfony/src/Model/ToyCar.php。
处理器类
为了创建和持久化玩具汽车模型,我们需要一个类来为我们处理它。问题是,在这个阶段我们还没有数据库——我们该在哪里持久化玩具汽车模型?目前还没有地方,但我们仍然可以通过测试:
- 创建以下接口,内容如下:
codebase/symfony/src/DAL/Writer/WriterInterface.php
<?php
namespace App\DAL\Writer;
interface WriterInterface
{
/**
* @param $model
* @return bool
*/
public function write($model): bool;
}
我们创建了一个非常简单的接口,我们的数据写入对象可以实现。然后我们将使用这个接口来处理我们的处理器类。
- 现在,让我们创建玩具汽车工作流程或处理器类。创建以下类,内容如下:
codebase/symfony/src/Processor/ToyCarProcessor.php
<?php
namespace App\Processor;
use App\DAL\Writer\WriterInterface;
use App\Model\ToyCar;
use App\Validator\ToyCarValidationException;
class ToyCarProcessor
{
/**
* @var WriterInterface
*/
private $dataWriter;
/**
* @param ToyCar $toyCar
* @return bool
* @throws ToyCarValidationException
*/
public function create(ToyCar $toyCar)
{
// Do some validation here
$this->validate($toyCar);
// Write the data
$result = $this->getDataWriter()->
write($toyCar);
// Do other stuff.
return $result;
}
/**
* @param ToyCar $toyCar
* @throws ToyCarValidationException
*/
public function validate(ToyCar $toyCar)
{
if (is_null($toyCar->getName())) {
throw new ToyCarValidationException
(‘Invalid Toy Car Data’);
}
}
/**
* @return WriterInterface
*/
public function getDataWriter(): WriterInterface
{
return $this->dataWriter;
}
/**
* @param WriterInterface $dataWriter
*/
public function setDataWriter(WriterInterface
$dataWriter): void
{
$this->dataWriter = $dataWriter;
}
}
我们创建了一个处理器类,它有一个create方法,接受我们之前创建的玩具汽车模型,然后尝试使用一个不存在的写入类实例来写入模型。如果贵公司的另一位开发者正在处理数据写入类,并且需要两周时间才能完成,你会等待两周来通过集成测试吗?
如果你的处理器类必须在数据写入数据库后验证数据并执行其他操作,那么这些程序是否也应该因为你在等待其他开发者完成他们的工作而延迟?可能不是!我们可以使用测试替身来暂时替换缺失的依赖项。
测试替身
大多数时候,要运行一个针对具有所有已构建依赖项的功能的测试是困难的或不切实际的。有时,我们需要一个解决方案来测试我们想要的特定功能,即使我们还没有构建其他依赖项,或者只是想隔离或仅关注某个特定功能。在这里,我们可以使用测试替身。你可以在phpunit.readthedocs.io/en/9.5/test-doubles.html了解更多关于 PHPUnit 测试替身的信息。
模拟和存根
我们刚刚创建的处理器类需要一个ToyValidatorInterface和WriterInterface的具体实例。由于我们还没有创建这些类,我们仍然可以通过使用Mock对象来通过测试。在 PHPUnit 中,Mock对象是一个扩展了Stub接口的接口。这意味着在代码中,Mock对象是Stub接口的一个实现。用Mock对象替换ToyValidatorInterface和WriterInterface的实例,并在执行特定方法时设置return值的过程称为存根(stubbing)。让我们实际尝试一下:
- 返回到
ToyCarProcessorTest类,并使用以下内容重构它:
codebase/symfony/tests/Integration/Processor/ToyCarProcessorTest.php
<?php
namespace App\Tests\Integration\Repository;
use App\DAL\Writer\WriterInterface;
use App\Model\CarManufacturer;
use App\Model\ToyCar;
use App\Model\ToyColor;
use App\Processor\ToyCarProcessor;
use Symfony\Bundle\FrameworkBundle\Test\
KernelTestCase;
class ToyCarProcessorTest extends KernelTestCase
{
/**
* @param ToyCar $toyCarModel
* @throws \App\Validator
\ToyCarValidationException
* @dataProvider provideToyCarModel
*/
public function testCanCreate
(ToyCar $toyCarModel): void
{
// Mock: Data writer
$toyWriterStub = $this->createMock
(WriterInterface::class);
$toyWriterStub
->method(‘write’)
->willReturn(true);
// Processor Class
$processor = new ToyCarProcessor();
$processor->setDataWriter($toyWriterStub);
// Execute
$result = $processor->create($toyCarModel);
$this->assertTrue($result);
}
public function provideToyCarModel(): array
{
// Toy Car Color
$toyColor = new ToyColor();
$toyColor->setName(‘Black’);
// Car Manufacturer
$carManufacturer = new CarManufacturer();
$carManufacturer->setName(‘Ford’);
// Toy Car
$toyCarModel = new ToyCar();
$toyCarModel->setName(‘Mustang’);
$toyCarModel->setColour($toyColor);
$toyCarModel->setManufacturer
($carManufacturer);
$toyCarModel->setYear(1968);
return [
[$toyCarModel],
];
}
}
在这里的testCanCreate函数中,我们正在为ValidationModel、ToyCarValidator和ToyCarWriter类创建模拟对象。然后我们实例化主要的ToyCarCreator类,并将模拟的ToyCarValidator和ToyCarWriter类传递给其构造函数。这被称为依赖注入,将在本章后面进一步讨论。最后,我们运行ToyCarCreator的create方法来模拟开发者尝试创建一个新的玩具车记录:
-
让我们通过输入以下命令来运行测试,看看我们会得到什么结果:
/var/www/html/symfony# ./vendor/bin/phpunit --filter ToyCarProcessorTest
你应该会看到以下结果:

图 8.5 – 使用存根通过了测试
我们通过了测试,尽管我们还没有在数据库中真正持久化任何内容。在更大、更复杂的项目中,你可能会不得不依赖于测试替身(test doubles)来隔离并专注于你的测试,即使其他依赖项尚未构建,或者太复杂以至于无法作为测试的一部分包含在内。
现在回到 SRP(单一职责原则),我们的ToyCarProcessor现在有两个职责 – 验证和创建玩具车模型。同样,其他开发者也在使用你的类的validate方法。让我们重构我们的代码,重新定义ToyCarProcessor类的焦点和职责:
-
重命名以下类:
-
将
ToyCarProcessor.php重命名为ToyCarCreator.php -
将
ToyCarProcessorTest.php重命名为ToyCarCreatorTest.php
-
-
接下来,让我们重构
ToyCarCreatorTest.php类。打开以下类,并将其内容替换为以下内容:
codebase/symfony/tests/Integration/Processor/ToyCarCreatorTest.php
<?php
namespace App\Tests\Integration\Repository;
use App\DAL\Writer\WriterInterface;
use App\Model\CarManufacturer;
use App\Model\ToyCar;
use App\Model\ToyColor;
use App\Processor\ToyCarCreator;
use App\Validator\ValidatorInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class ToyCarCreatorTest extends KernelTestCase
{
/**
* @param ToyCar $toyCarModel
* @throws \App\Validator
\ToyCarValidationException
* @dataProvider provideToyCarModel
*/
public function testCanCreate
(ToyCar $toyCarModel): void
{
// Mock 1: Validator
$validatorStub = $this->createMock
(ValidatorInterface::class);
$validatorStub
->method(‘validate’)
->willReturn(true);
// Mock 2: Data writer
$toyWriterStub = $this->createMock
(WriterInterface::class);
$toyWriterStub
->method(‘write’)
->willReturn(true);
// Processor Class
$processor = new ToyCarCreator();
$processor->setValidator($validatorStub);
$processor->setDataWriter($toyWriterStub);
// Execute
$result = $processor->create($toyCarModel);
$this->assertTrue($result);
}
public function provideToyCarModel(): array
{
// Toy Car Color
$toyColor = new ToyColor();
$toyColor->setName(‘Black’);
// Car Manufacturer
$carManufacturer = new CarManufacturer();
$carManufacturer->setName(‘Ford’);
// Toy Car
$toyCarModel = new ToyCar();
$toyCarModel->setName(‘Mustang’);
$toyCarModel->setColour($toyColor);
$toyCarModel->setManufacturer
($carManufacturer);
$toyCarModel->setYear(1968);
return [
[$toyCarModel],
];
}
}
如您所见,我们添加了一个新的Mock对象用于验证。我将在重构ToyCarCreator.php类的内容后解释为什么我们必须这样做。让我们创建一个验证器接口,然后重构ToyCarCreator类。
- 创建以下文件,并包含以下内容:
codebase/symfony/src/Validator/ValidatorInterface.php
<?php
namespace App\Validator;
interface ValidatorInterface
{
/**
* @param $input
* @return bool
* @throws ToyCarValidationException
*/
public function validate($input): bool;
}
-
打开
codebase/symfony/src/Processor/ToyCarCreator.php,并使用以下内容:<?phpnamespace App\Processor;use App\DAL\Writer\WriterInterface;use App\Model\ToyCar;use App\Validator\ToyCarValidationException;use App\Validator\ValidatorInterface;class ToyCarCreator{/*** @var ValidatorInterface*/private $validator;/*** @var WriterInterface*/private $dataWriter;/*** @param ToyCar $toyCar* @return bool* @throws ToyCarValidationException*/public function create(ToyCar $toyCar): bool{// Do some validation here and so on...$this->getValidator()->validate($toyCar);// Write the data$result = $this->getDataWriter()->write($toyCar);// Do other stuff.return $result;}}
接下来,为我们在类中声明的私有属性添加必要的访问器和修改器。
我们只是将类重命名,使其具有更具体的名称。有时,只是将类的名称改为其他名称就可以帮助你清理代码。此外,您会注意到我们已经删除了公开可见的validate类。这个类将不再包含任何验证逻辑 – 它只知道在尝试持久化数据之前将运行一个验证例程。这是这个类的主要职责。
我们还没有编写任何验证和数据持久化代码,但让我们看看我们是否还能通过测试来测试类的主体职责,即执行以下操作:
-
接受一个
ToyCar模型对象。 -
运行验证例程。
-
尝试持久化数据。
-
返回结果。
-
运行以下命令:
/var/www/html/symfony# ./vendor/bin/phpunit --filter ToyCarCreatorTest
现在,你应该看到以下结果:

图 8.6 – 使用两个存根通过测试
在本节中,我们使用了 BDD 和 TDD 来指导我们编写解决方案代码。我们创建了具有单一职责的 POPOs。我们还创建了一个ToyCarCreator类,该类不包含验证逻辑,也不包含持久化机制。它知道它需要执行一些验证和一些持久化,但没有这些程序的具体实现。每个类都将有自己的专业化或特定的工作,或特定的单一职责。
太好了 – 现在即使在重构之后,我们也能再次通过测试。接下来,让我们继续编写解决方案代码,遵循 SOLID 原则中的 O(开闭)原则,即开闭原则(OCP)。
基于开闭原则的 TDD
OCP(开闭原则)最初由伯纳德·迈耶定义,但在这章中,我们将遵循罗伯特·C·马丁定义的后续版本,也称为多态 OCP。
OCP 原则指出,对象应该对扩展开放,对修改封闭。目标是我们应该能够通过扩展原始代码来修改行为或功能,而不是直接重构原始代码。这很好,因为它将帮助我们这些开发者和测试者对我们正在处理的工单更有信心,因为我们没有触及可能被其他地方使用的原始代码——降低回归的风险。
在我们的ToyCarCreateTest类中,我们正在模拟一个验证对象,因为我们还没有编写具体的验证类。实现验证的方法有很多种,但在这个例子中,我们将尝试让它非常简单。让我们回到代码中创建一个验证器:
- 创建一个新的测试类,内容如下:
codebase/symfony/tests/Unit/Validator/ToyCarValidatorTest.php
<?php
namespace App\Tests\Unit\Validator;
use App\Model\CarManufacturer;
use App\Model\ToyCar;
use App\Model\ToyColor;
use App\Validator\ToyCarValidator;
use PHPUnit\Framework\TestCase;
class ToyCarValidatorTest extends TestCase
{
/**
* @param ToyCar $toyCar
* @param bool $expected
* @dataProvider provideToyCarModel
*/
public function testCanValidate(ToyCar $toyCar,
bool $expected): void
{
$validator = new ToyCarValidator();
$result = $validator->validate($toyCar);
$this->assertEquals($expected, $result);
}
public function provideToyCarModel(): array
{
// Toy Car Color
$toyColor = new ToyColor();
$toyColor->setName(‘White’);
// Car Manufacturer
$carManufacturer = new CarManufacturer();
$carManufacturer->setName(‘Williams’);
// Toy Car
$toyCarModel = new ToyCar();
$toyCarModel->setName(‘’); // Should fail.
$toyCarModel->setColour($toyColor);
$toyCarModel->setManufacturer
($carManufacturer);
$toyCarModel->setYear(2004);
return [
[$toyCarModel, false],
];
}
}
在创建测试类之后,像往常一样,我们需要运行测试以确保 PHPUnit 能够识别你的测试。
-
运行以下命令:
/var/www/html/symfony# ./vendor/bin/phpunit --testsuite=Unit --filter ToyCarValidatorTest
确保你得到一个错误,因为我们还没有创建验证器类。还记得红色阶段吗?你会注意到在数据提供者中,我们为名称设置了一个空字符串。我们将使验证器类在看到玩具汽车名称为空字符串时返回false。
- 现在,我们已经有了失败的测试,让我们继续创建一个类来通过它。创建一个新的 PHP 类,内容如下:
codebase/symfony/src/Validator/ToyCarValidator.php
<?php
namespace App\Validator;
use App\Model\ToyCar;
class ToyCarValidator
{
public function validate(ToyCar $toyCar): bool
{
if (!$toyCar->getName()) {
return false;
}
return true;
}
}
我们创建了一个非常简单的验证逻辑,我们只检查玩具汽车的名称是否不为空字符串。现在,让我们再次运行测试。
-
运行以下命令:
/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter ToyCarValidatorTest
你现在应该看到一个通过测试。
好吧,所以现在我们可以确保玩具汽车型号的名称应该始终是一个非空字符串——但是这里的问题是,如果我们想添加更多的验证逻辑怎么办?我们将不得不继续修改ToyCarValidator类。这并没有错。只是我们认为遵循 OCP 更好,这样我们就不需要修改我们的代码——减少类修改,降低破坏事物的风险。让我们重构我们的解决方案代码以通过测试:
-
让我们为年份添加一些验证逻辑,并保留玩具汽车的名称验证。
-
目前,我们处于绿色阶段,正在转向重构阶段。我们将使用多态性,这在第四章中讨论过,即使用 PHP 面向对象编程,而不是在这个解决方案中使用继承。创建以下接口并包含以下内容:
codebase/symfony/src/Validator/ToyCarValidatorInterface.php
<?php
namespace App\Validator;
use App\Model\ToyCar;
use App\Model\ValidationModel;
interface ToyCarValidatorInterface
{
public function validate(ToyCar $toyCar):
ValidationModel;
}
- 我们创建了一个新的
ToyCarValidatorInterface接口,它将取代ToyCarValidator具体类。你会注意到验证方法返回了一个对象——让我们也创建这个对象:
codebase/symfony/src/Model/ValidationModel.php
<?php
namespace App\Model;
class ValidationModel
{
/**
* @var bool
*/
private $valid = false;
/**
* @var array
*/
private $report = [];
}
在创建类之后,为属性生成访问器和修改器。
在我们的验证程序中,我们不仅可以简单地返回true或false,现在还可以返回一个包含字段名和该字段名的验证结果的数组。让我们继续编码。
- 创建以下测试类,内容如下:
codebase/symfony/tests/Unit/Validator/ToyCarValidatorTest.php
<?php
namespace App\Tests\Unit\Validator;
use PHPUnit\Framework\TestCase;
use App\Validator\YearValidator;
class YearValidatorTest extends TestCase
{
/**
* @param $data
* @param $expected
* @dataProvider provideYear
*/
public function testCanValidateYear(int $year,
bool $expected): void
{
$validator = new YearValidator();
$isValid = $validator->validate($year);
$this->assertEquals($expected, $isValid);
}
/**
* @return array
*/
public function provideYear(): array
{
return [
[1, false],
[2005, true],
[1955, true],
[312, false],
];
}
}
-
如果你运行这个测试,你会看到四个失败,因为我们有四个
provideYear数据提供者中的值集。通过以下命令运行测试:/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter YearValidatorTest --debug
如果测试失败,那很好。让我们继续编写解决方案代码:
- 创建以下解决方案类,内容如下:
codebase/symfony/src/Validator/YearValidator.php
<?php
namespace App\Validator;
class YearValidator implements ValidatorInterface
{
/**
* @param $input
* @return bool
*/
public function validate($input): bool
{
if (preg_match(“/^(\d{4})$/”, $input,
$matches)) {
return true;
}
return false;
}
}
现在,我们有一个简单的验证类,用于检查年份是否适合我们的汽车。如果我们想在这里添加更多逻辑,例如检查最小和最大可接受值,我们可以将所有这些逻辑都放在这里。
-
再次运行以下命令,看看测试是否通过:
/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter YearValidatorTest --debug
你应该看到以下结果:

图 8.7 – 简单日期验证测试
现在我们已经通过了年份验证器的非常简单的测试,接下来,让我们继续到名称验证器:
- 创建以下测试类,内容如下:
codebase/symfony/tests/Unit/Validator/NameValidatorTest.php
<?php
namespace App\Tests\Unit\Validator;
use App\Validator\NameValidator;
use PHPUnit\Framework\TestCase;
class NameValidatorTest extends TestCase
{
/**
* @param $data
* @param $expected
* @dataProvider provideNames
*/
public function testCanValidateName(string $name,
bool $expected): void
{
$validator = new NameValidator();
$isValid = $validator->validate($name);
$this->assertEquals($expected, $isValid);
}
/**
* @return array
*/
public function provideNames(): array
{
return [
[‘’, false],
[‘$50’, false],
[‘Mercedes’, true],
[‘RedBull’, true],
[‘Williams’, true],
];
}
}
-
与年份验证器一样,如果你现在运行这个测试,你会遇到多个错误,但我们必须确保它确实失败或出错。运行以下命令:
/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter NameValidatorTest -
运行命令后,你应该看到五个错误。这没关系。现在让我们为它构建解决方案代码。创建以下类,内容如下:
codebase/symfony/src/Validator/NameValidator.php
<?php
namespace App\Validator;
class NameValidator implements ValidatorInterface
{
public function validate($input): bool
{
if (preg_match(“/^([a-zA-Z’ ]+)$/”, $input)) {
return true;
}
return false;
}
}
-
现在,我们有一个简单的逻辑来验证名称。让我们再次运行名称验证器测试,看看它是否通过。再次运行以下命令:
/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter NameValidatorTest
你现在应该看到五个通过测试。
让我们总结一下到目前为止我们添加了什么。我们创建了两个新的验证类,并且根据我们的单元测试,它们都按预期工作 - 但这比我们最初创建的解决方案好在哪里?这与 OCP 有什么关系?首先,我们需要将这些事情联系起来,并通过更大的ToyCarValidatorTest。
- 让我们使用以下内容重构
ToyCarValidator类:
codebase/symfony/src/Validator/ToyCarValidator.php
<?php
namespace App\Validator;
use App\Model\ToyCar;
use App\Model\ValidationModel as ValidationResult;
class ToyCarValidator implements
ToyCarValidatorInterface
{
/**
* @var array
*/
private $validators = [];
public function __construct()
{
$this->setValidators([
‘year’ => new YearValidator(),
‘name’ => new NameValidator(),
]);
}
/**
* @param ToyCar $toyCar
* @return ValidationResult
*/
public function validate(ToyCar $toyCar
ValidationResult
{
$result = new ValidationResult();
$allValid = true;
foreach ($this->getValidators() as $key =>
$validator) {
$accessor = ‘get’ . ucfirst(strtolower
($key));
$value = $toyCar->$accessor();
$isValid = false;
try {
$isValid = $validator->validate
($value);
$results[$key][‘message’] = ‘’;
} catch (ToyCarValidationException $ex) {
$results[$key][‘message’] = $ex->
getMessage();
} finally {
$results[$key][‘is_valid’] =
$isValid;
}
if (!$isValid) {
$allValid = false;
}
}
$result->setValid($allValid);
$result->setReport($results);
return $result;
}
}
然后,为$validators属性生成访问器和修改器。
- 你会注意到在构造函数中,我们实例化了两个验证类,在
validate方法中使用了这些验证类。每个验证类都将有自己的自定义逻辑来运行validate方法。现在,使用以下内容重构以下测试类:
codebase/symfony/tests/Unit/Validator/ToyCarValidatorTest.php
<?php
namespace App\Tests\Unit\Validator;
use App\Model\CarManufacturer;
use App\Model\ToyCar;
use App\Model\ToyColor;
use App\Validator\ToyCarValidator;
use PHPUnit\Framework\TestCase;
class ToyCarValidatorTest extends TestCase
{
/**
* @param ToyCar $toyCar
* @param array $expected
* @dataProvider provideToyCarModel
*/
public function testCanValidate(ToyCar $toyCar,
array $expected): void
{
$validator = new ToyCarValidator();
$result = $validator->validate($toyCar);
$this->assertEquals($expected[‘is_valid’],
$result->isValid());
$this->assertEquals($expected[‘name’],
$result->getReport()[‘name’][‘is_valid’]);
$this->assertEquals($expected[‘year’],
$result->getReport()[‘year’][‘is_valid’]);
}
public function provideToyCarModel(): array
{
// Toy Car Color
$toyColor = new ToyColor();
$toyColor->setName(‘White’);
// Car Manufacturer
$carManufacturer = new CarManufacturer();
$carManufacturer->setName(‘Williams’);
// Toy Car
$toyCarModel = new ToyCar();
$toyCarModel->setName(‘’); // Should fail.
$toyCarModel->setColour($toyColor);
$toyCarModel->setManufacturer
($carManufacturer);
$toyCarModel->setYear(2004);
return [
[$toyCarModel, [‘is_valid’ => false,
‘name’ => false, ‘year’ => true]],
];
}
}
-
现在,在这个测试中,我们正在检查整个玩具车模型对象的合法性,以及检查玩具车模型的具体字段是否通过了或未通过验证。让我们看看测试是否通过。运行以下命令:
/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter ToyCarValidatorTest
现在,你应该看到以下结果:

图 8.8 – 通过的玩具车验证测试
你会注意到我们通过了三个断言。看起来我们开始得到一个具有更多职责的测试。仍然最好每个测试只做一条断言,这样我们才不会最终得到一个糟糕的测试类!现在,我们将继续前进。
现在,通过重构,我们取得了什么成果?首先,我们不再需要在 ToyCarValidatorTest 类中检查玩具名称的合法性验证逻辑。其次,我们现在可以检查年份的合法性。如果我们想改进日期和名称验证逻辑,我们不需要在主要的 ToyCarValidator 类中做这件事——但如果我们想添加更多的验证器类呢?比如一个 ToyColorValidator 类?嗯,我们仍然可以做到,甚至不需要触及主类!我们将重构 ToyCarValidator,并在本章的 TDD 与依赖倒置原则 部分稍后讨论如何做。
但如果我们想改变我们创建的 ToyCarValidator.php 类的整个行为并完全改变逻辑呢?嗯,没有必要修改它——我们只需用 ToyCarValidatorInterface 接口的不同具体实现来替换整个 ToyCarValidator.php 类!
接下来,我们将讨论 Liskov 替换原则(LSP)。
使用 Liskov 替换原则进行 TDD
LSP 是由 Barbara Liskov 提出的。我使用它的方式是,一个接口的实现应该可以用另一个接口的实现替换,而不会改变行为。如果你正在扩展一个超类,子类必须能够替换超类而不破坏行为。
在这个例子中,让我们尝试添加一个业务规则来拒绝在 1950 年或之前建造的玩具车模型。
如往常一样,让我们从测试开始:
- 打开我们之前创建的
YearValidatorTest.php类,并使用以下内容修改测试类:
codebase/symfony/tests/Unit/Validator/YearValidatorTest.php
<?php
namespace App\Tests\Unit\Validator;
use App\Validator\ToyCarTooOldException;
use PHPUnit\Framework\TestCase;
use App\Validator\YearValidator;
class YearValidatorTest extends TestCase
{
/**
* @param $data
* @param $expected
* @dataProvider provideYear
*/
public function testCanValidateYear(int $year,
bool $expected): void
{
$validator = new YearValidator();
$isValid = $validator->validate($year);
$this->assertEquals($expected, $isValid);
}
/**
* @return array
*/
public function provideYear(): array
{
return [
[1, false],
[2005, true],
[1955, true],
[312, false],
];
}
/**
* @param int $year
* @dataProvider provideOldYears
*/
public function testCanRejectVeryOldCar(int
$year): void
{
$this->expectException
(ToyCarTooOldException::class);
$validator = new YearValidator();
$validator->validate($year);
}
/**
* @return array
*/
public function provideOldYears(): array
{
return [
[1944],
[1933],
[1922],
[1911],
];
}
}
-
我们添加了一个新的测试,以便我们检查
ToyCarTooOldException。让我们也添加这个异常类,但首先,让我们运行测试。 -
运行以下命令:
/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter testCanRejectVeryOldCar -
现在你将看到四个错误。这是可以的。现在,让我们添加缺失的异常类:
codebase/symfony/src/Validator/ToyCarTooOldException.php
<?php
namespace App\Validator;
class ToyCarTooOldException extends \Exception
{
}
正如你所见,它只是一个简单的异常类,它扩展了主要的 PHP \Exception 类。
如果我们再次运行测试,现在我们应该通过测试,因为我们已经通过使用 $this->expectException() 方法告诉 PHPUnit 我们期望这个测试抛出异常。
-
运行以下命令:
/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter testCanRejectVeryOldCar
现在,我们应该能够通过测试 - 你应该看到以下结果:

图 8.9 – 通过旧车拒绝测试
这意味着每次我们提交一个小于或等于 1950 年的年份时,我们都会正确地抛出ToyCarTooOldException对象 - 但我们的ToyCarValidatorTest会发生什么?
让我们修改测试数据,年份小于 1950 年,看看会发生什么:
- 使用以下内容修改数据提供者内容:
codebase/symfony/tests/Unit/Validator/ToyCarValidatorTest.php
public function provideToyCarModel(): array
{
// Toy Car Color
$toyColor = new ToyColor();
$toyColor->setName(‘White’);
// Car Manufacturer
$carManufacturer = new CarManufacturer();
$carManufacturer->setName(‘Williams’);
// Toy Car
$toyCarModel = new ToyCar();
$toyCarModel->setName(‘’); // Should fail.
$toyCarModel->setColour($toyColor);
$toyCarModel->setManufacturer($carManufacturer);
$toyCarModel->setYear(1935);
return [
[$toyCarModel, [‘is_valid’ => false, ‘name’ =>
false, ‘year’ => false]],
];
}
-
现在,运行以下命令并查看会发生什么:
/var/www/html/symfony# ./runDebug.sh --filter ToyCarValidatorTest
你会注意到我们用以下消息失败了测试:

图 8.10 – 玩具车验证失败
现在,我们可以看到我们有一个未捕获的异常。我们的ToyCarValidator程序没有编写来处理这个异常对象。为什么是这样呢?嗯,这个例子中的接口是codebase/symfony/src/Validator/ValidatorInterface.php接口。这个接口抛出一个ToyCarValidationException对象。现在的问题是,我们的实现类,即YearValidator.php类,抛出的异常与其合同或接口不同。因此,它破坏了行为。为了解决这个问题,我们只需要抛出接口中声明的正确异常。
- 让我们修改
ToyCarTooOldException类:
codebase/symfony/src/Validator/ToyCarTooOldException.php
<?php
namespace App\Validator;
class ToyCarTooOldException extends
ToyCarValidationException
{
}
如你所见,我们只是将继承的类替换为ToyCarValidationException。ToyCarValidator.php类被设计用来捕获这个异常。
-
现在,让我们运行以下命令来执行测试,看看它是否真的起作用:
/var/www/html/symfony# ./runDebug.sh --filter ToyCarValidatorTest
我们现在应该通过测试并看到以下结果:

图 8.11 – 通过玩具车验证器测试,使用旧车验证
-
现在我们再次通过测试,让我们看看我们的
ToyCarValidator程序返回了什么。还记得我们之前在第五章,单元测试中写的 shell 脚本吗?让我们使用其中一个。在codebase/symfony/tests/Unit/Validator/ToyCarValidatorTest.php的第23行设置一个断点。然后,运行以下命令:/var/www/html/symfony# ./runDebug.sh --filter ToyCarValidatorTest -
检查
$result变量,你应该看到以下内容:

图 8.12 – 验证模型
你可以看到我们的ToyCarValidator的 validate 方法返回一个ValidationModel对象。它总结了我们对哪些字段进行了验证,以及year字段的异常消息。
我们已经看到了接口如何有用,但有时它们变得过于强大。接下来,我们将讨论接口分离原则(ISP)来帮助防止这种情况发生。
基于接口分离原则的 TDD
接口非常有用,但有时很容易让它们充满那些实际上不应该成为接口一部分的功能。我经常遇到这种违规行为。我一直在想,我怎么能一直创建带有待办注释的空方法,几个月或几年后,发现这些类仍然带有那些待办注释,方法仍然是空的。
我过去总是先触摸接口,然后把所有我认为需要的功能都塞进去。然后,当我最终编写具体的实现时,这些具体的类大多数都是空方法。
一个接口应该只包含与该接口特定的方法。如果其中有一个方法与该接口完全不相关,你需要将其分离到不同的接口中。
让我们看看它是如何工作的。再次,让我们从一个——你猜对了——测试开始:
-
打开
codebase/symfony/tests/Unit/Validator/NameValidatorTest.php测试类,并添加以下内容:/*** @param $data* @param $expected* @dataProvider provideLongNames*/public function testCanValidateNameLength(string$name, bool $expected): void{$validator = new NameValidator();$isValid = $validator->validateLength($name);$this->assertEquals($expected, $isValid);}/*** @return array*/public function provideLongNames(): array{return [[‘TheQuickBrownFoxJumpsOverTheLazyDog’,false],];}
我们在测试中引入了一个新的函数validateLength,这是字符串共有的。我们还添加了一个非常长的名字,并将false设置为在数据提供者中期望返回的值。
-
运行以下测试:
/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter testCanValidateNameLength
你应该会得到一个错误,因为我们还没有创建新的方法。
- 现在,打开
ValidatorInterface.php接口,并添加我们期望在测试中拥有的validateLength方法:
codebase/symfony/src/Validator/ValidatorInterface.php
<?php
namespace App\Validator;
interface ValidatorInterface
{
/**
* @param $input
* @return bool
* @throws ToyCarValidationException
*/
public function validate($input): bool;
/**
* @param string $input
* @return bool
*/
public function validateLength(string $input):
bool;
}
- 太好了——现在我们有了验证字符串长度的合约。如果我们回到
NameValidator.php类,IDE 会显示以下错误:

图 8.13 – 必须实现的方法
显然,我们需要为NameValidator.php类实现validateLength方法,这是可以接受的,因为我们想验证字符串长度——但如果我们也想为ToyCar模型的颜色创建一个验证器呢?ToyCar模型的颜色属性期望一个ToyColor.php对象,而不是一个字符串!因此,解决方案是从ValidatorInterface中删除validateLength方法。某些类将不需要实现此逻辑即可实现ValidatorInterface。我们可以做的是创建一个新的接口,称为StringValidator接口,它可以包含validateLength方法。
- 重构
codebase/symfony/src/Validator/ValidatorInterface.php接口,并删除我们刚刚添加的validateLength方法,并创建以下文件,内容如下:
codebase/symfony/src/Validator/StringValidatorInterface.php
<?php
namespace App\Validator;
interface StringValidatorInterface
{
/**
* @param string $input
* @return bool
*/
public function validateLength(string $input):
bool;
}
在这个阶段,我们将validateLength方法分离到一个单独的接口中,从ValidatorInterface.php接口中移除了它。
- 现在,打开
NameValidator.php类,并使用以下内容重构它:
codebase/symfony/src/Validator/NameValidator.php
<?php
namespace App\Validator;
class NameValidator implements ValidatorInterface,
StringValidatorInterface
{
const MAX_LENGTH = 10;
/**
* @param $input
* @return bool
*/
public function validate($input): bool
{
$isValid = false;
if (preg_match(“/^([a-zA-Z’ ]+)$/”, $input)) {
$isValid = true;
}
if ($isValid) {
$isValid = $this->validateLength($input);
}
return $isValid;
}
/**
* @param string $input
* @return bool
*/
public function validateLength(string $input):
bool
{
if (strlen($input) > self::MAX_LENGTH) {
return false;
}
return true;
}
}
-
我们已经重构了
NameValidator类,使其现在也检查名称的长度。让我们运行测试,看看它是否通过:/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter testCanValidateNameLength
现在,你应该能看到以下结果:

图 8.14 – 通过字符串长度验证测试
我们所做的是,不是将不同的方法组合到ValidatorInterface中,而是将它们分成了两个不同的接口。然后,我们只为需要这个validateLength方法的验证器对象实现StringValidator接口。这基本上就是 ISP(接口隔离原则)的全部内容。这是一个非常基础的例子,但如果你不留意,很容易就会成为这些非常强大的接口的受害者。
接下来,我们将回到ToyCarValidator类,看看我们如何可以在使用开放-封闭原则的 TDD示例中改进我们之前的内容。
使用依赖倒置原则的 TDD
在使一个类更易于测试方面,DIP(依赖倒置原则)可能是列表中对我最重要的原则。DIP 建议细节应该依赖于抽象。对我来说,这意味着不属于类的程序的具体细节应该被抽象化。DIP 允许我们作为开发者移除一个程序或例程的具体实现,并将其放入一个完全不同的对象中。然后我们可以使用 DIP 在需要的时候注入所需的对象。我们可以在构造函数中注入对象,作为类实例化时的参数传递,或者简单地暴露一个修改器函数。
让我们回顾一下本章早期创建的ToyCarValidator类,看看我们如何可以实施 DIP(依赖倒置原则)。
在我们的代码中这会是什么样子?
回到ToyCarValidator.php类,你会在__constructor方法中注意到,我们实例化了两个类:

图 8.15 – 硬编码的依赖关系
我们如何改进这个?嗯,这个程序是工作的——正如你所看到的,我们正在传递ToyCarValidatorTest。唯一的问题是我们的ToyCarValidator类现在硬编码到了它的依赖中——YearValidator和NameValidator类。如果我们想替换这些类——或者如果我们想添加更多的验证器呢?嗯,我们可以从类内部移除这个依赖。按照以下步骤操作:
- 重构以下测试类,并将
testCanValidate方法替换为以下内容:
codebase/symfony/tests/Unit/Validator/ToyCarValidatorTest.php
/**
* @param ToyCar $toyCar
* @param array $expected
* @dataProvider provideToyCarModel
*/
public function testCanValidate(ToyCar $toyCar, array
$expected): void
{
$validators = [
‘year’ => new YearValidator(),
‘name’ => new NameValidator(),
];
// Inject the validators
$validator = new ToyCarValidator();
$validator->setValidators($validators);
$result = $validator->validate($toyCar);
$this->assertEquals($expected[‘is_valid’],
$result->isValid());
$this->assertEquals($expected[‘name’],
$result->getReport()[‘name’][‘is_valid’]);
$this->assertEquals($expected[‘year’],
$result->getReport()[‘year’][‘is_valid’]);
}
你会注意到,ToyCarValidator所依赖的对象现在是在ToyCarValidator类外部实例化的——然后我们使用setValidators修改器设置验证器。
- 现在,从
ToyCarValidator的构造函数中移除硬编码的验证器实例化:
codebase/symfony/src/Validator/ToyCarValidator.php
<?php
namespace App\Validator;
use App\Model\ToyCar;
use App\Model\ValidationModel as ValidationResult;
class ToyCarValidator implements
ToyCarValidatorInterface
{
/**
* @var array
*/
private $validators = [];
/**
* @param ToyCar $toyCar
* @return ValidationResult
*/
public function validate(ToyCar $toyCar):
ValidationResult
{
$result = new ValidationResult();
$allValid = true;
$results = [];
foreach ($this->getValidators() as $key =>
$validator) {
$accessor = ‘get’ . ucfirst(strtolower
($key));
$value = $toyCar->$accessor();
$isValid = false;
try {
$isValid = $validator->validate
($value);
$results[$key][‘message’] = ‘’;
} catch (ToyCarValidationException $ex) {
$results[$key][‘message’] =
$ex->getMessage();
} finally {
$results[$key][‘is_valid’] =
$isValid;
}
if (!$isValid) {
$allValid = false;
}
}
$result->setValid($allValid);
$result->setReport($results);
return $result;
}
/**
* @return array
*/
public function getValidators(): array
{
return $this->validators;
}
/**
* @param array $validators
*/
public function setValidators(array $validators):
void
{
$this->validators = $validators;
}
}
-
我们不再有硬编码的验证器实例化——现在,让我们运行测试,看看测试是否仍然通过:
/var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter testCanValidateNameLength
运行命令后,你应该看到测试仍然通过。在这个时候,我们可以继续创建新的验证器,并将它们添加到我们想要注入到ToyCarValidator.php类的验证器数组中。
-
现在,打开本章中我们创建的
ToyCarCreator.php类,你会看到它已经准备好接受外部的依赖。我们还可以重构这个类,以便在实例化时自动注入它需要的依赖。 -
打开以下测试类,并使用以下内容进行重构:
codebase/symfony/tests/Integration/Processor/ToyCarCreatorTest.php
<?php
namespace App\Tests\Integration\Repository;
use App\DAL\Writer\WriterInterface;
use App\Model\CarManufacturer;
use App\Model\ToyCar;
use App\Model\ToyColor;
use App\Model\ValidationModel;
use App\Processor\ToyCarCreator;
use App\Validator\ToyCarValidatorInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class ToyCarCreatorTest extends KernelTestCase
{
/**
* @param ToyCar $toyCarModel
* @throws \App\Validator
\ToyCarValidationException
* @dataProvider provideToyCarModel
*/
public function testCanCreate(ToyCar
$toyCarModel): void
{
$validationResultStub = $this->createMock
(ValidationModel::class);
$validationResultStub
->method(‘isValid’)
->willReturn(true);
// Mock 1: Validator
$validatorStub = $this->createMock
(ToyCarValidatorInterface::class);
$validatorStub
->method(‘validate’)
->willReturn($validationResultStub);
// Mock 2: Data writer
$toyWriterStub = $this->createMock
(WriterInterface::class);
$toyWriterStub
->method(‘write’)
->willReturn(true);
// Processor Class
$processor = new ToyCarCreator($validatorStub,
$toyWriterStub);
// Execute
$result = $processor->create($toyCarModel);
$this->assertTrue($result);
}
public function provideToyCarModel(): array
{
// Toy Car Color
$toyColor = new ToyColor();
$toyColor->setName(‘Black’);
// Car Manufacturer
$carManufacturer = new CarManufacturer();
$carManufacturer->setName(‘Ford’);
// Toy Car
$toyCarModel = new ToyCar();
$toyCarModel->setName(‘Mustang’);
$toyCarModel->setColour($toyColor);
$toyCarModel->setManufacturer
($carManufacturer);
$toyCarModel->setYear(1968);
return [
[$toyCarModel],
];
}
}
如你所见,我们已经实例化了ToyCarCreator.php类的依赖,然后在ToyCarCreator($validatorStub, $toyWriterStub);中实例化类时将它们作为参数注入。
- 然后,打开
ToyCarCreator.php解决方案类,并使用以下内容进行重构:
codebase/symfony/src/Processor/ToyCarCreator.php
<?php
namespace App\Processor;
use App\DAL\Writer\WriterInterface;
use App\Model\ToyCar;
use App\Validator\ToyCarValidationException;
use App\Validator\ToyCarValidatorInterface;
class ToyCarCreator
{
/**
* @var ToyCarValidatorInterface
*/
private $validator;
/**
* @var WriterInterface
*/
private $dataWriter;
public function __construct
(ToyCarValidatorInterface $validator,
WriterInterface $dataWriter)
{
$this->setValidator($validator);
$this->setDataWriter($dataWriter);
}
/**
* @param ToyCar $toyCar
* @return bool
* @throws ToyCarValidationException
*/
public function create(ToyCar $toyCar): bool
{
// Do some validation here and so on...
$this->getValidator()->validate($toyCar);
// Write the data
$result = $this->getDataWriter()->write
($toyCar);
// Do other stuff.
return $result;
}
/**
* @return WriterInterface
*/
public function getDataWriter(): WriterInterface
{
return $this->dataWriter;
}
/**
* @param WriterInterface $dataWriter
*/
public function setDataWriter(WriterInterface
$dataWriter): void
{
$this->dataWriter = $dataWriter;
}
/**
* @return ToyCarValidatorInterface
*/
public function getValidator():
ToyCarValidatorInterface
{
return $this->validator;
}
/**
* @param ToyCarValidatorInterface $validator
*/
public function setValidator
(ToyCarValidatorInterface $validator): void
{
$this->validator = $validator;
}
}
在实例化时,验证器和编写器依赖项都通过构造函数设置。
如果我们运行测试,它应该仍然通过:
/var/www/html/symfony# ./runDebug.sh --testsuite=Integration --filter ToyCarCreatorTest
运行命令后,你应该仍然看到通过测试。
使用这种方法最明显的事情是,你必须自己管理所有依赖,然后将它们注入到需要它们的对象中。幸运的是,我们不是第一个遇到这种头疼问题的人。有很多服务容器可以帮助管理应用程序需要的依赖,但在选择 PHP 服务容器时,最重要的是它应该遵循 PSR-11 标准。你可以在www.php-fig.org/psr/psr-11/了解更多关于 PSR-11 的信息。
摘要
在本章中,我们逐个介绍了 SOLID 原则。我们使用测试来启动解决方案代码的开发,以便我们可以将它们作为在现实生活中实现 SOLID 原则的示例。
我们已经介绍了 SRP(单一职责原则),它帮助我们使 PHP 类的职责或能力更加专注。OCP(开闭原则)帮助我们避免在某些情况下需要修改类的行为时,需要触及或修改类。LSP(里氏替换原则)帮助我们更严格地对待接口的行为,使得我们更容易在不破坏父类行为的情况下切换实现该接口的具体对象。ISP(接口隔离原则)帮助我们使接口的职责更加专注——实现此接口的类将不再因为接口的声明而拥有空方法。DIP(依赖倒置原则)帮助我们快速测试ToyCarCreator类,即使没有创建其依赖的具体实现,如ToyCarValidator类。
在实际项目工作中,一些原则很难严格遵循,有时边界也模糊不清。再加上现实生活中的截止日期压力,事情就变得更加有趣。有一点可以肯定,使用行为驱动开发(BDD)和技术驱动开发(TDD)将帮助您对自己的开发功能更有信心,尤其是在您已经深入一个项目几个月之后。在之上添加 SOLID 原则会使您的解决方案更加完善!
在下一章中,我们将尝试利用自动化测试来帮助我们确保任何开发者在将代码推送到您的代码仓库时,不会破坏软件预期的行为。我们将通过使用持续集成来自动化这一过程。
第三部分 - 自动化部署和监控
在本书的这一部分,您将学习如何利用自动化测试来改进和自动化代码集成过程,以及自动化应用程序的部署过程。您还将学习如何在应用程序部署后监控 PHP 应用程序。
本节包括以下章节:
-
第九章,持续集成
-
第十章,持续交付
-
第十一章,监控
第九章:持续集成
在前几章中,我们探讨了玩具车模型库存解决方案的软件开发过程。我们遵循了测试驱动开发的过程,在这个阶段,我们应该已经熟悉了它。然而,我们正在构建的软件中仍然有很多缺失的功能。在本章中,我们将从一个几乎完整的解决方案开始,你可以从本章的 GitHub 仓库中获取这个解决方案。然后我们将确保我们的解决方案能够正常工作。
在本章中,我们将从一个几乎完整的软件解决方案开始,除了缺少的最后一个功能,即过滤包含玩具车模型的表格。我们已经编写了很多测试;如果我们能够在创建 develop 或 main 分支的 pull request 时自动触发这些测试的执行,那岂不是很好?自动触发我们花费了大量时间开发的测试套件将帮助我们捕捉到在它们甚至被合并到主分支之前就已经存在的错误或缺陷。这就是持续集成能够帮助我们的地方。
在本章中,我们将探讨以下主题:
-
运行并通过所有 Symfony 应用程序测试
-
使用 Bitbucket Pipelines 进行持续集成
技术要求
在本章中,你应具备使用 Git 版本控制进行 Web 应用程序的基本知识。你还应使用本书 GitHub 仓库中的开发环境和解决方案代码:github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%209。
为本章准备开发环境
首先,下载位于github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%209的代码。
要运行容器并执行本章中的命令,你应该在 docker-server-web-1 容器内部。
运行以下命令以确认我们的 web 服务器的容器名称:
docker ps
要运行容器,请从主机机器上的 /docker 目录运行以下命令:
docker-compose build && docker-compose up -d
docker exec -it docker-server-web-1 /bin/bash
一旦进入容器,运行以下命令以通过 Composer 安装所需的库:
/var/www/html/symfony# ./setup.sh
/var/www/html/behat# ./setup.sh
运行并通过所有 Symfony 应用程序测试
在上一章中,我们开始编写解决方案代码,试图遵循 SOLID 原则。为了开发应用程序的其他部分,我们可以继续遵循相同的过程。在本章中,我已自行完成了所有其他测试和通过这些测试所需的解决方案代码。我们将通过这些测试并确保它们通过。
设置本地环境
将技术要求部分提供的源代码检查到你的本地开发机器中,并从主机机器上运行以下命令来配置你的开发环境:
$ cd docker
$ docker-compose build && docker-compose up -d
在运行这些命令后,确保本书前面构建的容器正在运行,可以通过运行以下命令来检查:
$ docker ps
你应该会看到以下 Docker 容器名称:
docker_server-web_1
docker_app-phpmyadmin_1
docker_server-mysql_1
现在容器正在运行,我们需要通过安装它们依赖的包以及创建我们的示例 PHP 应用所需的数据库和表来准备docker_server-web_1容器内运行的 Symfony 和 Behat 应用。
准备 Symfony 应用和测试
现在,让我们设置我们的 Symfony 应用所需的数据库表和库。仍然在docker目录内,从你的主机机器上运行以下命令:
$ docker exec -i docker_server-web_1 /var/www/html/symfony/setup.sh
或者,在docker_server-web_1容器内部运行以下命令:
/var/www/html/symfony# ./setup.sh
setup.sh文件只是一个 shell 脚本,我添加它来方便地配置我们的 Symfony 应用,并准备运行测试所需的数据库和表。
如果你打开以下 shell 文件,你会看到我们只是在运行一些 Doctrine 命令,并使用 Composer 安装所有所需的 Symfony 依赖项:
codebase/symfony/setup.sh
#!/bin/bash
composer install -d /var/www/html/symfony/
# Test DB
php /var/www/html/symfony/bin/console doctrine:database:create -n --env=test
php /var/www/html/symfony/bin/console doctrine:migrations:migrate -n --env=test
php /var/www/html/symfony/bin/console doctrine:fixtures:load -n --env=test
# Main DB
php /var/www/html/symfony/bin/console doctrine:database:create -n
php /var/www/html/symfony/bin/console doctrine:migrations:migrate -n
php /var/www/html/symfony/bin/console doctrine:fixtures:load -n
当我们在持续集成(CI)机器上运行应用时,这个 shell 脚本将非常有用。
在运行setup.sh命令后,你的开发环境应该已经准备好,可以从我们的 Symfony 应用内部运行所有单元和集成测试。
在第五章的单元测试部分,我们创建了一个名为codebase/symfony/runCoverage.sh的 shell 脚本。这个 shell 脚本将帮助我们运行所有测试并检查我们的解决方案代码的测试覆盖率。然而,从现实的角度来看,我们不需要覆盖应用内部的所有代码,因为其中一些是没有任何测试的第三方库,或者其中一些可能是简单的纯 PHP 对象(POPO)类。但对于我们开发的代码,我们应该为它们添加自动化的测试。
如果你认为需要从代码覆盖率报告中排除一些类,你可以打开codebase/symfony/phpunit.xml配置文件,并添加你想要从覆盖率报告中排除的目录。
在docker_server-web_1容器内部,如果你打开我们的 Symfony 应用中的codebase/symfony/tests目录,你会看到以下测试:

图 9.1 – Symfony 测试目录
我们有三个主要的测试目录:功能、集成和单元。在这个例子中,功能测试是控制器测试,这些测试使用 HTTP 请求;你可以把它们看作是集成测试,但它们覆盖了更集成的代码。集成测试是运行通过不同自定义类及其相互交互的测试,确保它们都按预期工作。最后,单元测试是简单的测试,专注于特定类中的单个单元或函数。
你可以浏览codebase/symfony/src目录和类,以检查我们的测试实际针对的类。
让我们看看测试是否通过;从主机机器上运行以下命令:
$ docker exec -i docker_server-web_1 /var/www/html/symfony/runTests.sh
或者,在docker_server-web_1容器内部运行以下命令:
/var/www/html/symfony# ./runTests.sh
由于我们已经配置了测试所需的数据库和表,你应该看到以下结果:

图 9.2 – 通过 Symfony 测试
看起来我们有很多通过测试,但让我们打开一个测试类,看看我们确切在测试什么。
打开codebase/symfony/tests/Integration/DAL/Reader/ColorReaderTest.php;你会看到以下内容:
<?php
namespace App\Tests\Integration\DAL\Reader;
use App\DAL\Reader\Doctrine\ColorReader;
use App\Entity\Color;
use App\Model\ToyColor;
class ColorReaderTest extends DataReaderTestBase
{
public function testCanReadColors()
{
$reader = $this->getServiceContainer()->get(ColorReader::class);
$colorsFromDb = $reader->getAll();
/** @var Color $color */
foreach ($colorsFromDb as $color) {
$this->assertInstanceOf(ToyColor::class, $color);
$this->assertIsInt($color->getId());
$this->assertNotNull($color->getName());
}
}
}
你会注意到,在这个测试类中,我们从数据库中读取数据,并对数据库的结果执行一些断言。在第七章,“使用 BDD 和 TDD 构建解决方案代码”,我们创建了测试所需和实际解决方案代码的主数据库。
在docker_server-mysql_1容器内部有以下数据库:

图 9.3 – MySQL 数据库
我们使用cars数据库作为实际解决方案代码,使用cars_test进行自动化测试。这样,我们的自动化测试就不会污染我们的主要应用程序数据库。
现在,最后,在我们部署到 CI 解决方案之前,让我们运行我们之前构建的runCoverage.sh shell 脚本。
从主机机器上运行以下命令:
$ docker exec -i docker_server-web_1 /var/www/html/symfony/runCoverage.sh
或者,在docker_server-web_1容器内部运行以下命令:
/var/www/html/symfony# ./runCoverage.sh
现在,你应该看到以下测试结果:

图 9.4 – 测试覆盖率报告
太好了!在这个阶段,我们确信测试正在正常运行,并且它们也可以连接到测试数据库:

图 9.5 – 测试数据
如果你检查cars_test MySQL 数据库中的toy_cars表,你应该看到自动化测试创建的一些样本数据。
接下来,让我们继续到 Behat 应用程序内部定义的更详细的函数测试。
准备 Behat 应用程序和测试
在上一节中,我们能够运行并通过所有 Symfony 应用程序测试。现在,我们需要确保我们也通过了 Behat 应用程序中的行为驱动测试。
从您的宿主机运行以下命令来设置 Behat 应用程序:
$ docker exec -i docker_server-web_1 /var/www/html/
behat/setup.sh
或者,在docker_server-web_1容器内运行以下命令:
/var/www/html/behat# ./setup.sh
就像我们在之前的 Symfony 应用程序中做的那样,我们首先需要设置 Behat 应用程序并安装其依赖项。
在第六章“应用行为驱动开发”中,我们使用 Gherkin 语言创建了测试功能和场景。我们还创建了一些包含我们在创建的功能文件中定义的步骤的上下文 PHP 类。这些上下文类将由 Behat 执行,作为功能 PHP 测试。
Symfony 测试和我们的 Behat 应用程序测试之间的区别在于,Behat 测试不关心我们如何实现结果或特定的 PHP 类如何与其他 PHP 类交互。它们只关心测试非常高级的功能和行为场景。
就这些了——现在,我们已经准备好运行我们的行为驱动测试了。
从宿主机运行以下命令,看看您是否可以通过我们的 Behat 测试:
docker exec -i docker_server-web_1 /var/www/html/behat/runBehatTests.sh
或者,在docker_server-web_1容器内运行以下命令:
/var/www/html/behat# ./runBehatTests.sh
您应该看到以下结果:

图 9.6 – Behat 测试通过
太好了!我们已经通过了我们定义的所有测试场景。如果您想检查所有功能文件和场景,您可以在codebase/behat/features中打开这些文件。
现在我们确信我们的自动化测试在开发机器上运行正常,我们就可以准备在 Bitbucket Pipelines 中为我们 CI 解决方案使用它们了。
使用 Bitbucket Pipelines 进行持续集成
我们投入了大量的努力来构建我们的自动化测试和解决方案代码,所有这些努力都是为了帮助我们开发可维护且更可靠的软件。持续集成(CI)是指能够从不同来源集成代码变更的实践。随着自动化测试的加入,我们所有的努力将开始在大规模上得到回报,因为我们已经编写了所有这些测试。这将帮助我们防止将回归引入主代码库。例如,CI 过程可以拒绝一个 git pull 请求,如果存在损坏的自动化测试。
现在有大量的 CI 工具,但在这个例子中,由于我在项目中使用 Bitbucket 进行版本控制,所以我将只使用 Bitbucket Pipelines,因为它已经很好地与 Bitbucket Cloud 集成。它非常容易使用,您将会看到。让我们开始吧:
- 在 Bitbucket Cloud 仪表板中,选择您用于项目的存储库,然后点击左侧菜单上的流水线链接:

图 9.7 – 创建你的第一个管道
- 然后,选择 构建 PHP 应用程序 复选框。您将看到创建第一个管道的示例模板:

图 9.8 – 创建你的第一个管道
- 在这里,您将看到一个简单的
.yml文件;您可以编辑它以运行您的脚本。对于我们的示例项目,您可以使用以下内容:
bitbucket-pipelines.yml
image: docker:stable
options:
docker: true
pipelines:
default:
- parallel:
- step:
name: Prepare environment
script:
- set -eu
- apk add --no-cache py-pip bash
- apk add --no-cache gcc
- apk add --update --no-cache --virtual
.tmp-build-deps gcc libc-dev linux-headers postgresql-dev && apk add libffi-dev
- apk update && apk add python3-dev gcc libc-dev
- pip install --upgrade pip setuptools wheel
- pip install --no-cache-dir docker-compose
- docker-compose -v
- ls -lap
- pwd
- cd docker
- docker-compose build && docker-compose up -d
- docker exec -i docker_server-web_1 /var/www/html/symfony/setup.sh
- docker exec -i docker_server-web_1
/var/www/html/symfony/runCoverage.sh
- docker exec -i docker_server-web_1
/var/www/html/behat/setup.sh
- docker exec -i docker_server-web_1
/var/www/html/behat/runBehatTests.sh
caches:
- composer
如您所见,这些只是我们在设置 CI 云中需要使用的 Docker 容器时想要运行的命令。您会注意到我们正在使用 codebase/symfony/setup.sh 和 codebase/behat/setup.sh 文件来安装我们的 Symfony 和 Behat 应用程序所需的全部依赖项和库。这包括在我们的 Symfony 应用程序内部创建我们使用的 cars 和 cars_test 数据库!
- 将脚本粘贴到文本区域,然后点击 提交文件 按钮。您将被重定向到 管道 页面,在那里您将看到您的构建正在运行:

图 9.9 – 管道运行
从前面的屏幕截图可以看出,我们能够构建与我们在本地机器上工作相同的容器。
构建过程将需要几分钟。在这里,我们希望在 CI 云中发生以下操作:
-
创建主机机器。
-
安装运行
docker-compose所需的必要库。 -
构建我们用于解决方案的 Docker 容器。
-
安装 Symfony 的 Composer 包。
-
运行 Doctrine 数据库迁移以进行 Symfony。
-
执行
runCoverage.sh测试脚本以进行 Symfony。 -
确保我们通过所有的 Symfony 测试。
-
安装 Behat 的 Composer 包。
-
执行
runBehatTests.sh测试脚本以进行 Behat。 -
确保我们通过所有的 Behat 测试。
这需要很多步骤!但我们需要做所有这些事情,以确保我们可以像在本地机器上运行它们一样运行我们的自动化测试。几分钟后,回到您的 构建 页面,查看我们是否通过了 Symfony 测试:

图 9.10 – CI 通过 Symfony 测试
太好了!通过在 CI 内部运行 runCoverage.sh 脚本,我们可以确保所有测试和代码仍然按预期运行!现在,让我们看看我们的 Behat 测试是否也通过。继续向下滚动构建屏幕,直到您找到 Behat 测试结果:

图 9.11 – CI 通过 Behat 测试
如您从日志中看到的,我们通过了与之前从本地开发机器上通过相同的五个场景!
在这个阶段,管道 将显示一个带有勾选标记的绿色条,表示我们已经通过了整个构建。
在第二章,理解和组织我们项目的业务需求中,我们创建了一些 Jira 工单并将我们的 Bitbucket 仓库集成到 Jira 项目中。现在,这对于 Bitbucket Pipelines 来说将非常方便,因为它也与 Jira 无缝集成。
在从github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%209克隆的解决方案代码的根目录中,您将找到一个bitbucket-pipelines.yml文件,其中包含我们用于运行第一个管道的脚本。现在,每次您将更新推送到与您正在处理的 Jira 工单连接的 Bitbucket 分支时,Jira 将能够自动检测为您的 Jira 工单运行的 Pipelines 构建:

图 9.12 – Jira 和 Pipelines 集成
点击前面截图中的创建拉取请求区域下方的1 构建不完整链接;您将看到一个弹出窗口,其中包含为该分支和工单已执行的构建列表:

图 9.13 – 从 Jira 页面构建弹出窗口
这是一套非常强大的工具。您的团队可以使用 Jira 监控任务,并确保在您决定是否部署解决方案之前,您或任何其他开发者推送的源代码不会对现有软件造成损害。
如果开发者推送的代码改变了应用程序的负面行为,并且有足够的自动化测试来覆盖它,那么您将能够在 CI 构建失败时捕捉到问题。
摘要
在本章中,我们完成了设置我们的更新后的开发环境的过程,包括构建示例项目所需的全部测试和解决方案代码。我们创建并使用 shell 脚本来帮助我们安装依赖项、设置数据库和初始化数据,确保我们拥有从本地机器或云端轻松运行测试所需的一切。
我们还创建了我们的第一个 Bitbucket Pipeline,以帮助我们实现持续集成。通过使用 CI,我们可以将所有自动化测试在云端运行,以确保每次提交和推送更改到分支时,我们不会破坏代码库中的任何内容。
在下一章中,我们将把我们的解决方案部署到外部 Web 服务器,在那里我们也将能够使用 Web 浏览器测试应用程序。
第十章:持续交付
我们已经构建了一个包含自动化测试的软件解决方案,并设置了一个持续集成管道来运行这些自动化测试。现在,如果团队中的开发人员推送了一些更改解决方案预期行为的代码,我们的自动化测试和持续集成解决方案将捕获这些问题,并帮助你和你的团队停止发布有害代码。但是,如果在将所有新代码推送到仓库后所有测试都通过了,那会怎么样?如果有一个解决方案可以帮助我们准备并将应用程序部署到开发、测试或生产服务器,那岂不是很好?
在本章中,我们将为我们的开发过程添加最后一块缺失的拼图。我们将在 AWS 中准备一个远程服务器,并使用 持续交付(CD)自动将我们的应用程序部署到该服务器。
图 10.1 展示了我们将要采取的步骤,将我们的解决方案代码部署到面向公众的 web 服务器。我们将通过将新代码推送到仓库的过程,这将反过来触发我们在上一章中配置的 CI 管道。CI 管道将运行我们构建的自动化测试,如果成功,CD 过程将把我们解决方案代码上传到 AWS S3。然后,我们将使用 AWS CodeDeploy 将我们的应用程序部署到作为示例生产服务器的 AWS EC2 实例:

图 10.1 – 整个流程
从开发人员推送新代码并在云中运行所有自动化测试,到自动将解决方案代码部署到 Linux 服务器,我们将涵盖所有这些内容!
在本章中,我们将讨论以下主题:
-
设置 AWS EC2 实例
-
创建 AWS CodeDeploy 应用程序
-
在 AWS EC2 实例内安装 Docker 和其他依赖项
-
使用 Bitbucket Pipelines 和 AWS CodeDeploy 进行持续交付
技术要求
在本章中,你应该遵循上一章中提供的说明并配置 Bitbucket Pipelines 管道。你还应具备 AWS 的基本知识,并应使用本书代码库中的代码 github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%2010。
要查看所有测试正常运行,你可以运行以下命令以下载本章的完整代码,并运行 Docker 容器:
curl -Lo phptdd.zip "https://github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/raw/main/Chapter%2010/complete.zip" && unzip -o phptdd.zip && cd complete && ./demoSetup.sh
要运行容器并执行本章中的命令,你应该在 docker-server-web-1 容器内部。
运行以下命令以确认我们的 web 服务器的容器名称:
docker ps
要运行容器,请从主机上的 /phptdd/docker 目录运行以下命令:
docker-compose build && docker-compose up -d
docker exec -it docker_server-web_1 /bin/bash
一旦进入容器,请运行以下命令以通过 Composer 安装所需的库:
/var/www/html/symfony# ./setup.sh
在/var/www/html/symfony目录中,运行以下命令以查看所有测试通过:
/var/www/html/symfony# ./runCoverage.sh
运行runCoverage.sh命令后,它应该执行我们所有的 Symfony 测试,并且你可以确保它们都通过。
设置 AWS EC2 实例
如果你还没有 AWS 账户,你可以按照aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/上的说明创建一个账户。你还需要创建一个 AWS IAM 用户组。说明可以在docs.aws.amazon.com/IAM/latest/UserGuide/id_groups_create.html找到。你需要一个 AWS IAM 用户,并按照 AWS 的官方文档在docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html在你的 AWS 账户中创建 IAM 用户。
为了完成 EC2 设置,我们还需要以下 AWS 资源:
-
AWS EC2 密钥对
-
IAM 实例配置文件
为什么我们需要 EC2 实例?嗯,这将作为我们的远程服务器。你可以把它想象成在云中运行的宿主计算机。我们将使用这个服务器来托管我们的 Docker 容器,以便运行和提供我们的应用程序:

图 10.1 – EC2 实例
如您从图中所见,它几乎将成为您本地开发环境的复制品。这就是使用容器的大好处,正如我们在第三章,使用 Docker 容器设置我们的开发环境中讨论的那样。
按照以下步骤创建 EC2 实例:
-
登录 AWS 控制台,在服务搜索栏中搜索
EC2以进入 EC2 仪表板。 -
在 EC2 仪表板中,点击启动实例按钮:

图 10.2 – 启动实例按钮
你将看到启动实例向导。
- 在
tddphp-instance1中。这个名称标签将非常重要。我们将在本章后面使用这个标签来部署我们的 CodeDeploy 应用程序:

图 10.3 – 实例名称标签
- 接下来,在应用程序和操作系统镜像(Amazon 机器镜像)区域,选择Amazon Linux 2 AMI:

图 10.4 – Amazon Linux 2 AMI
- 接下来,你可以选择 EC2 实例类型。在这个例子中,你可以坚持使用t2.micro实例,因为这个类型的实例是免费层合格的。你也可以选择更强大的实例配置 – 这完全取决于你:

图 10.5 – t2.micro 实例类型
- 你需要一个密钥对才能能够 SSH 进入这个机器实例。如果你还没有设置一个,只需点击创建新的密钥对链接;将弹出一个窗口供你创建一个新的密钥对:

图 10.6 – 创建新的密钥对
-
创建密钥对后,你可以将其分配给 EC2 实例向导中的密钥对字段。
-
接下来,在网络设置部分,允许所有 HTTP 和 HTTPS 流量。这样我们就可以轻松地从浏览器访问网络应用程序:

图 10.7 – 网络设置;允许 HTTP 和 HTTPS
- 接下来,在高级详情部分,如果你还没有 IAM 实例配置文件,你可以通过点击创建新的 IAM 配置文件链接轻松创建一个:

图 10.8 – 创建新的 IAM 配置文件链接
- 你将被重定向到 IAM 向导。输入你想要使用的任何 IAM 角色名称;然后,在受信任实体 类型部分选择自定义信任策略选项:

图 10.9 – 自定义信任策略文本区域
-
在文本区域中,使用以下策略:
{“Version”: “2012-10-17”,“Statement”: [{“Effect”: “Allow”,“Principal”: {“Service”: [“ec2.amazonaws.com”,“codedeploy.ap-southeast-2.amazonaws.com”]},“Action”: “sts:AssumeRole”}]}
由于我在澳大利亚,我通常使用Sydney ap-southeast-2区域。你可以用你喜欢的任何区域替换它。
-
点击下一步按钮继续。
-
在添加权限部分,搜索以下策略,并在策略名称前勾选复选框:
-
AmazonEC2FullAccess
-
AmazonS3FullAccess
-
AWSCodeDeployFullAccess
-
-
选择这些策略后,点击下一步。
在创建角色之前,确保这些策略在 IAM 向导的审查屏幕上显示:

图 10.10 – 访问策略
- 接下来,点击创建角色按钮,然后返回到EC2 实例向导。你现在可以从高级 详情部分选择你刚刚创建的 IAM 角色:

图 10.11 – 新创建的 IAM 实例配置文件
- 就这样 – 滚动到页面底部并点击启动实例按钮。AWS 启动你的新 EC2 实例需要几分钟时间。几分钟后,返回到仪表板;你现在应该能看到你的 EC2 实例正在运行:

图 10.12 – 正在运行的 Amazon Linux 2 实例
我们现在有一个正在运行的 Amazon Linux 2 实例;我们将使用这个实例来运行我们的容器。
在进行 CodeDeploy 设置之前,我们需要创建一个 S3 存储桶。这个存储桶将同时被我们的 Bitbucket Pipelines 和 CodeDeploy 应用程序使用:
- 在 AWS 控制台中,搜索
S3并点击S3服务项:

图 10.13 – S3 服务
您将被重定向到 Amazon S3 控制台。
- 点击 创建 存储桶 按钮:

图 10.14 – 创建存储桶按钮
- 为存储桶使用您想要的任何唯一名称,并选择与您的 EC2 实例相同的区域。对我来说,是 ap-southeast-2:

图 10.15 – 创建 S3 存储桶
- 您可以保留所有默认设置,然后点击 创建存储桶 按钮。
就这样。我们现在可以继续创建 CodeDeploy 应用程序。CodeDeploy 应用程序将使用我们刚刚创建的 EC2 实例和 S3 存储桶。
创建 AWS CodeDeploy 应用程序
我们将使用 AWS CodeDeploy 自动将我们的 PHP 应用程序部署到 EC2 服务器。但 CodeDeploy 将从哪里获取部署的文件呢?它将从 S3 存储桶中获取。但我们的解决方案代码最初是如何出现在 S3 中的呢?嗯,我们将告诉 Bitbucket Pipelines 将其上传到那里!我们将在本章后面详细说明:

图 10.16 – CodeDeploy 流程
按照以下步骤设置 AWS CodeDeploy,一旦所有自动化测试通过,我们的 Bitbucket CI 管道将触发它:
- 在 AWS 控制台中,搜索 CodeDeploy 服务并点击 创建 应用程序 按钮:

图 10.17 – 创建 CodeDeploy 应用程序
- 在 CodeDeploy 向导中,在 应用程序配置 部分使用您想要的任何名称。然后,在 计算平台 字段中,选择 EC2/本地 选项,并点击 创建 应用程序 按钮:

图 10.18 – 应用程序配置部分
您将被重定向到 CodeDeploy 应用程序 页面。
- 接下来,点击 创建部署 组 按钮:

图 10.19 – 创建部署组按钮
- 在
codedeploy_group1:

图 10.20 – 部署组向导 – 组名称
- 接下来,在 服务角色 部分中选择我们在本章前面创建的 IAM 角色:

图 10.21 – 部署组向导 – IAM 角色
- 接下来,在
tddphp-instance1。这非常重要。这是我们告诉 CodeDeploy 我们想要在这个特定实例中部署应用程序的方式。如果您想部署到其他实例,也可以在这里添加更多标签:

图 10.22 – 部署组向导 – EC2 实例详情
- 接下来,在负载均衡器部分,取消选中启用负载均衡复选框,然后点击创建部署****组按钮:

图 10.23 – 部署组向导 – 负载均衡器
太好了!这就是从 AWS 控制台进行 CodeDeploy 配置的全部内容。
接下来,我们需要进入我们刚刚创建的 EC2 实例内部,并安装一些我们需要的应用程序,以便它能够连接到 CodeDeploy,并且我们能够运行 Docker 容器。
在 AWS EC2 实例内部安装 Docker 和其他依赖项
在 EC2 实例内部,我们需要三个非常重要的应用程序。首先,我们需要 AWS CodeDeploy 代理,之后我们需要安装 Docker 和 docker-compose,这样我们才能构建和运行我们应用程序所需的 Docker 容器。
连接到 EC2 实例
在安装任何东西之前,我们需要进入实例内部。幸运的是,我们可以通过浏览器中的 AWS 控制台来完成这个操作:
- 在 EC2 仪表板上,选择我们之前创建的正在运行的实例,然后点击表格顶部的连接按钮:

图 10.24 – EC2 表格 – 连接按钮
您将被重定向到连接到实例页面。
- 在该页面上点击连接按钮。最后,您将被重定向到浏览器的终端窗口:

图 10.25 – EC2 终端窗口
太好了!现在,我们可以安装我们为 CD 和我们的 PHP 解决方案所需的程序。
安装 CodeDeploy 代理
我们在本章中创建的 CodeDeploy 应用程序需要在我们的 EC2 实例内部安装额外的软件,以便它能与之通信。这就是为什么我们需要 CodeDeploy 代理。您可以在 AWS CodeDeploy 代理的官方 AWS 文档页面上了解更多信息:docs.aws.amazon.com/codedeploy/latest/userguide/codedeploy-agent.html。
按照以下步骤安装 CodeDeploy 代理:
-
在终端中,输入以下命令来安装代理:
sudo yum update -ysudo yum install -y rubysudo yum install -y wgetwget https://aws-codedeploy-ap-southeast-2.s3.ap-southeast-2.amazonaws.com/latest/installchmod +x ./installsudo ./install auto -
运行这些命令后,通过运行以下命令来验证代理是否正在运行:
sudo service codedeploy-agent status
您现在应该得到以下结果:

图 10.26 – CodeDeploy 代理正在运行
太棒了!我们的 EC2 实例现在可以被我们的 CodeDeploy 应用程序使用。接下来,我们可以继续安装 Docker。
安装 Docker
我们一直使用 Docker 容器来运行我们的 PHP 解决方案。现在,CodeDeploy 将尝试在我们刚刚创建的 EC2 实例中部署我们的代码,但我们的解决方案代码依赖于已安装的 Docker 和 docker-compose。
按照以下步骤安装 Docker:
-
在 AWS 终端窗口中运行以下命令:
sudo amazon-linux-extras install -y dockersudo service docker startsudo usermod -aG docker ec2-usersudo chkconfig docker on -
运行安装命令后,通过运行以下命令检查 Docker 是否正确安装:
docker --version
你应该看到以下结果:

图 10.27 – Docker 已安装
-
接下来,我们需要重新启动实例以确保我们可以以正确的权限执行 Docker。运行以下命令:
sudo reboot -
运行命令后,终端窗口会挂起。给它几分钟时间,然后再次连接到 EC2 门户,就像我们之前做的那样:

图 10.28 – 重启时的终端错误
如果你看到前面的错误消息,不要担心。只需等待几分钟,然后再次尝试连接。
最后,我们还需要安装 docker-compose。
安装 docker-compose
我们一直在使用 docker-compose 工具在我们的本地开发环境中运行和配置我们的多容器设置。我们还需要在 EC2 实例中安装它。按照以下步骤操作:
-
在 AWS 终端窗口中,运行以下命令:
sudo curl -L “https://github.com/docker/compose/releases/download/v2.11.2/docker-compose-$(uname -s)-$(uname -m)” -o /usr/local/bin/docker-composesudo chmod +x /usr/local/bin/docker-compose -
运行这两个命令后,为了确保 docker-compose 已正确安装,请运行以下命令:
docker-compose --version
你应该看到已安装的版本。太好了!在这个阶段,我们已经安装了 CodeDeploy 能够在这个 EC2 实例中部署我们的 PHP 应用程序所需的所有东西。
接下来,我们将为我们的 EC2 实例添加一个弹性 IP。
将弹性 IP 附加到 EC2 实例
为了使我们的 EC2 实例可以通过网页浏览器轻松访问,我们将添加一个 AWS 弹性 IP 到 EC2 实例。我们还可以在需要时轻松地将此弹性 IP 附加到不同的 EC2 实例。
要创建弹性 IP,请按照以下步骤操作:
- 返回 AWS 控制台中的 EC2 仪表板,然后点击弹性 IPs按钮:

图 10.29 – 弹性 IP 按钮
- 在下一屏幕上,在公共 IPv4 地址池部分中选择Amazon 的 IPv4 地址池单选按钮。然后,点击分配按钮:

图 10.30 – 分配弹性 IP 地址
- 接下来,点击新创建的弹性 IP 地址,然后点击关联弹性 IP 地址按钮:

图 10.31 – 关联弹性 IP 地址按钮
- 在下一屏幕上,在实例字段中,选择我们之前创建的 EC2 实例。然后,点击关联按钮:

图 10.32 – 将弹性 IP 地址与 EC2 实例关联
在这个阶段,我们将有一个指向我们的 EC2 实例的永久 IP 地址。一旦我们将 PHP 应用程序部署到 EC2 实例中,我们将使用这个 IP 来访问 Web 应用程序。
接下来,我们需要配置 Bitbucket Pipelines,使其知道我们希望使用 AWS CodeDeploy 自动部署我们的代码。
使用 Bitbucket Pipelines 和 AWS CodeDeploy 进行持续交付
在上一节中,我们为 PHP 应用准备了一个 AWS EC2 实例。现在,我们需要一种方法将我们的解决方案代码从 Bitbucket 传输到 EC2 实例本身。为此,我们需要配置 Bitbucket Pipelines 以使用 AWS CodeDeploy。你可以阅读有关在support.atlassian.com/bitbucket-cloud/docs/deploy-to-aws-with-codedeploy/中使用 Bitbucket Pipelines 到 AWS CodeDeploy 部署的更多信息。
Bitbucket Pipelines 设置
由于我们将使用这些信息连接到我们的 AWS CodeDeploy 应用程序,因此我们需要在 Bitbucket 中添加一些 AWS 特定的信息。要添加这些信息,请按照以下步骤操作:
- 在 Bitbucket 仓库仪表板中,点击仓库 设置选项:

图 10.32 – 仓库设置
-
然后,从左侧菜单中选择仓库变量链接。你应该会被重定向到仓库变量页面。在名称和值字段中,添加以下名称和值:
-
AWS_ACCESS_KEY_ID: 使用密钥对文件中的值
-
AWS_SECRET_ACCESS_KEY: 使用密钥对文件中的值
-
AWS_DEFAULT_REGION: ap-southeast-2
-
S3_BUCKET: <你的唯一 S3 存储桶名称>
-
DEPLOYMENT_GROUP: codedeploy_group1
-
APPLICATION_NAME: mycodedeployapp:
-

图 10.33 – 仓库变量;AWS 值
接下来,我们需要告诉 Bitbucket Pipelines,我们希望将我们的应用程序压缩并上传到 AWS S3。然后,我们将使用 AWS CodeDeploy 将其部署到我们的 EC2 实例。
- 返回我们的代码库,在根目录下,你会找到我们在上一章创建的
bitbucket-pipelines.yml文件。向文件中添加以下行:
/bitbucket-pipelines.yml
- step:
name: Package and Upload
script:
- apk add zip
- zip -r phptddapp.zip .
- pipe: atlassian/aws-code-deploy:0.2.10
variables:
AWS_DEFAULT_REGION: $AWS_DEFAULT_REGION
AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
$AWS_SECRET_ACCESS_KEY
COMMAND: ‘upload’
APPLICATION_NAME: ‘mycodedeployapp’
ZIP_FILE: ‘phptddapp.zip’
S3_BUCKET: $S3_BUCKET
VERSION_LABEL: ‘phptdd-app-1.0.0’
- step:
name: Deploy to AWS
script:
- pipe: atlassian/aws-code-deploy:0.2.5
variables:
AWS_DEFAULT_REGION: $AWS_DEFAULT_REGION
AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
$AWS_SECRET_ACCESS_KEY
APPLICATION_NAME: $APPLICATION_NAME
DEPLOYMENT_GROUP: $DEPLOYMENT_GROUP
S3_BUCKET: $S3_BUCKET
COMMAND: ‘deploy’
VERSION_LABEL: ‘phptdd-app-1.0.0’
IGNORE_APPLICATION_STOP_FAILURES: ‘true’
FILE_EXISTS_BEHAVIOR: ‘OVERWRITE’
WAIT: ‘true’
在这里,我们将使用我们在上一节仓库变量页面中输入的 AWS 值。
接下来,我们需要告诉 CodeDeploy 在部署我们的应用程序时运行哪些脚本。
创建 CodeDeploy 配置文件
CodeDeploy 需要一个名为appspec.yml的基本配置文件。在这里,我们可以告诉 CodeDeploy 为我们运行脚本,例如运行 docker-compose 和运行我们的 Symfony 应用的setup.sh脚本。
创建/appspec.yml文件,并将以下内容添加到其中:
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/phptdd
hooks:
AfterInstall:
- location: aws/codedeploy/containers_setup_php.sh
timeout: 3600
runas: ec2-user
在此文件中,我们告诉 CodeDeploy,我们希望将代码复制到/home/ec2-user/phptdd目录。然后,在安装过程之后,我们希望运行我们将在下一节创建的containers_setup_php.sh文件。使用以下内容创建此文件:
aws/codedeploy/containers_setup_php.sh
#!/bin/bash
# Build and run containers (PHP, MySQL)
docker-compose -f ~/phptdd/docker/docker-compose-production.yml down
docker-compose -f ~/phptdd/docker/docker-compose-production.yml build
docker-compose -f ~/phptdd/docker/docker-compose-production.yml up -d
# Setup the PHP Applications inside the containers (install composer packages, setup db, etc).
docker-compose -f ~/phptdd/docker/docker-compose-production.yml exec server-web php --version
docker-compose -f ~/phptdd/docker/docker-compose-production.yml exec server-web /var/www/html/symfony/setup.sh
docker-compose -f ~/phptdd/docker/docker-compose-production.yml exec server-web /var/www/html/behat/setup.sh
您会注意到我们在运行 docker-compose。之后,我们运行为 Symfony 和 Behat 应用程序创建的自定义setup.sh文件。
接下来,我们需要使用 Bitbucket Pipelines 运行整个 CI/CD 流程。
运行 Bitbucket Pipelines
现在我们已经拥有了所有需要的东西,只需提交并推送文件到您的仓库以触发 Bitbucket 管道运行,或者直接手动运行管道。
我们的工作流程现在分为三个步骤:
-
运行自动化测试:这将运行我们的 Symfony 和 Behat 测试
-
打包和上传:这将压缩并将我们的代码上传到 AWS S3
-
部署到 AWS:这将使用 CodeDeploy 将我们的解决方案部署到我们配置的 EC2 实例中
运行所有内容需要几分钟,但想象一下用手工测试成百上千个特性和服务器部署的过程:

图 10.34 – CI/CD 结果
太好了 – 经过 18 分钟,我们已经完成了整个流程!这个设置和流程仍然可以进行调整和优化,但仅仅几分钟,我们就能够自动运行所有测试场景,并将它们自动部署到远程服务器上!
但部署是否成功?让我们来查明。
返回到 AWS 控制台,再次连接到 EC2 实例:

图 10.35 – 新的 phptdd 目录
如果代码部署成功,您会注意到应该有一个新的/phptdd目录。让我们看看里面是否有内容:

图 10.36 – /phptdd 内容
如您所见,我们已经将所有推送到 Bitbucket 仓库的文件都准备好了!但我们的 Docker 容器怎么办?让我们看看它们是否正在运行。
运行以下命令:
docker ps
如果 CodeDeploy 正确安装了所有内容,我们应该看到我们的容器正在运行:

图 10.37 – 运行中的 Docker 容器
很好 – 所有容器都在运行!现在,如果我们运行自动化测试呢?让我们看看结果。运行以下命令:
docker-compose -f -/phptdd/docker/docker-compose-production.yml
您应该看到以下内容:

图 10.38 – 在 EC2 中运行 Symfony 覆盖率测试
太好了!所有我们的 Symfony PHP 测试都通过了!但实际的 Web 应用程序怎么办?自动化测试对我们访客来说没有意义。
返回到 EC2 仪表板,点击我们创建的 EC2 实例。然后,点击弹性 IP 地址链接:

图 10.39 – 弹性 IP 地址链接
将加载弹性 IP 地址的详细信息。然后,复制公共 DNS值:

图 10.40 – 弹性 IP – 公共 DNS
在复制公共 DNS 后,只需简单地将其粘贴到您的网页浏览器中,看看是否可以加载:

图 10.41 – HomeController
如您所见,我们现在可以通过网页浏览器访问我们的 Web 应用程序。您可以尝试注册账户、登录并添加新的玩具车条目。
让我们先尝试注册功能:
- 点击注册链接,并输入电子邮件地址和密码:

图 10.42 – 注册
- 点击注册。接下来,点击登录按钮:

图 10.40 – 登录
如果一切正常,它将重定向您到添加玩具车页面。
- 在添加玩具车页面上,在字段中添加一些值:

图 10.41 – 添加玩具车
- 点击添加玩具车按钮。如果一切正常,它应该会重定向您到表格控制器:

图 10.42 – 表格控制器
在表格控制器中,我们将创建的玩具车条目将全部显示。如果我们想向应用程序添加更多功能,我们可以简单地从一个 Jira 工单开始,创建一个新的分支,编写一些代码,提交并推送代码——这就完成了。CI/CD 流程将负责剩下的工作!
摘要
在本章中,我们介绍了设置 AWS EC2 实例、AWS CodeDeploy 以及我们托管 PHP 应用程序所需的所有其他 AWS 资源的过程。我们将 Bitbucket Pipelines 与我们的 AWS CodeDeploy 应用程序集成,并在 CodeDeploy 运行时使用自定义脚本来自动配置 AWS EC2 实例内部的 Docker 容器。
我们从开发者推送新的代码更改到应用程序,运行所有自动化测试,并通过 AWS 将整个解决方案部署到 Linux 服务器的过程进行了介绍。我们还能使用网页浏览器手动测试我们的 Web 应用程序,以确保消费者可以使用该应用程序。
在下一章中,我们将探讨一些帮助我们监控应用程序的工具。这对于开发大型应用程序非常有帮助,因为这将帮助我们,作为开发者,分析应用程序的性能和健康状况。
第十一章:监控
在现实世界中,在生产环境中,您的应用程序将真正受到极限的考验。尽管在开发测试场景和通过不同阶段的质量保证方面付出了很多努力,但仍然会有一些边缘情况,开发团队或质量保证团队可能没有考虑到,因此这些遗漏的边缘情况可能会导致错误发生。有时会遇到与硬件相关的问题,或者有时可能会有一些代码相关的性能瓶颈导致超时和不愉快的客户。这种情况会发生,但并非世界末日。如果开发团队能够访问生产环境的用法统计信息、容器的 CPU 或内存使用情况、最常访问的控制器、异常的堆栈跟踪等,那将是极好的。
拥有这些信息将帮助您和您的团队在问题发生时更快地解决问题。这将使您和您的团队能够更好地理解您的应用程序被使用的程度。在本章中,我们将使用一个应用程序性能监控(APM)工具来收集和查看这些宝贵的应用程序性能和用法数据。
在本章中,我们将讨论以下主题:
-
为 PHP 设置 New Relic APM
-
查看应用程序性能数据
技术要求
对于本章,您应已通过 第十章,持续交付 的流程和步骤,并且需要能够访问用于托管 PHP 应用程序的 AWS EC2 实例;PHP 应用程序的代码库可以在 github.com/PacktPublishing/Test-Driven-Development-with-PHP-8/tree/main/Chapter%2010 找到。
为 PHP 设置 New Relic APM
市面上有很多不同的 APM 工具,但在这本书中,我们将只关注使用 New Relic。
New Relic 提供了许多性能监控工具,但在这本书中,我们将专注于监控我们的 PHP 应用程序及其运行的基础设施。
在本节中,我们将安装和配置 New Relic PHP 代理到我们的 EC2 实例中,以便我们开始收集 PHP 和服务器数据。
创建 New Relic 账户和许可证密钥
在将 New Relic 代理安装到 EC2 实例之前,您需要的是一个免费的 New Relic 账户。您可以在 newrelic.com/signup 注册以创建一个免费账户。您将在设置过程中稍后需要您的许可证密钥。
许可证密钥可以在管理仪表板下的 New Relic API 密钥页面找到:

图 11.1 – 许可证密钥
您可以从本页复制许可证密钥,并在下一步中设置代理。
在 PHP 8 容器中安装 New Relic 代理
要安装 PHP 代理,我们需要连接到我们用于托管应用程序的 AWS EC2 实例。
使用 SSH 或 AWS EC2 实例连接 Web 应用程序连接到 EC2 实例。
一旦进入实例,我们将在容器内安装代理。这可以是您部署自动化的一部分,但在这本书中,我们将手动安装它。您可以使用以下命令连接到您的 PHP 容器:
docker exec -it docker-server-web-1 /bin/bash
一旦进入容器,运行以下命令来安装 PHP 代理:
curl -L "https://download.newrelic.com/php_agent/release/newrelic-php5-10.2.0.314-linux.tar.gz" | tar -C /tmp -zx
export NR_INSTALL_USE_CP_NOT_LN=1
/tmp/newrelic-php5-*/newrelic-install install
运行这些命令后,您将被提示输入您的 New Relic 许可证密钥。从 New Relic 的API 密钥页面粘贴许可证密钥,并完成安装过程:

图 11.2 – PHP 代理安装结果
安装过程完成后,您会注意到在/usr/local/etc/php/conf.d/newrelic.ini中创建了一个新的ini文件。您可以手动修改此文件以设置您选择的 PHP 应用程序名称,或者只需运行以下命令:
sed -i \
-e 's/newrelic.appname = "PHP Application"/newrelic.appname = "NEWRELIC_TDDPHP"/' \
-e 's/;newrelic.daemon.app_connect_timeout =.*/newrelic.daemon.app_connect_timeout=15s/' \
-e 's/;newrelic.daemon.start_timeout =.*/newrelic.daemon.start_timeout=5s/' \
/usr/local/etc/php/conf.d/newrelic.ini
更新newrelic.ini文件后,通过运行以下命令重启apache2:
service apache2 restart
新的 Relic PHP 代理现在已安装在我们为 Symfony 和 Behat 应用程序服务的 PHP 容器中。
接下来,我们将检查代理是否能够将数据发送到 New Relic,并且我们将通过 New Relic 仪表板查看我们将能够获得哪些性能数据。
查看应用程序性能数据
在上一节中,我们安装了一个工具来从我们的 PHP 应用程序中收集性能和用法数据。除非我们能够查看并理解这些数据,否则这些数据将毫无用处。
要查看我们刚刚安装的 PHP 代理正在收集的数据,请按照以下步骤操作:
- 返回到
one.newrelic.com上的new relic仪表板,然后点击APM & services菜单项:

图 11.3 – New Relic 服务 – APM
- 接下来,点击仪表板上的
NEWRELIC_TDDPHP项目。您会注意到这与我们在/usr/local/etc/php/conf.d/newrelic.ini文件中使用的 PHP 名称相同。

图 11.4 – newrelic.ini – newrelic.appname
一旦您监控了许多应用程序,这些应用程序名称将非常有用,因此如果您能够标准化您的应用程序名称将非常棒。您可以在官方文档中了解更多关于 New Relic 的 APM 最佳实践,该文档位于docs.newrelic.com/docs/new-relic-solutions/best-practices-guides/full-stack-observability/apm-best-practices-guide/。
点击NEWRELIC_TDDPHP项目后,您将被重定向到 APM 仪表板:

图 11.5 – PHP APM 仪表板
在仪表板上,您将能够查看关于我们正在监控的 PHP 应用程序的不同指标。例如,我们可以检查哪些数据库操作执行时间最长:

图 11.6 – 数据库操作
您和您的团队能够从这些仪表板上报告的数据中学到很多东西。您可以发现性能瓶颈,并查看用户遇到的所有错误异常。
在错误仪表板上,您将能够查看应用程序报告的错误指标:

图 11.7 – 错误仪表板
您还可以点击错误项本身以深入了解正在发生的事情:

图 11.8 – 异常跟踪
在这里,您可以看到哪个特定的对象抛出了异常,以及抛出了什么异常,以及堆栈跟踪。这些都是对您和您的团队发现和解决问题非常有价值的信息。
监控您设置中的其他容器
在我们的示例应用程序中,我们使用了不止一个容器。我们还可以监控这些容器,例如 MySQL 服务器,以及我们之前使用的那个示例phpMyAdmin容器。
在 EC2 控制台中,运行以下命令将 New Relic 代理安装到我们的 AWS EC2 Linux 实例中:
curl -Ls https://download.newrelic.com/install/newrelic-cli/scripts/install.sh | bash && sudo NEW_RELIC_API_KEY=<your licence key> NEW_RELIC_ACCOUNT_ID=<your account id> /usr/local/bin/newrelic install -n logs-integration
请确保包括您的 New Relic 许可证密钥和账户 ID,这些可以在之前章节中提到的API 密钥页面找到。
安装过程完成后,返回New Relic仪表板,在左侧菜单中点击所有实体菜单项,然后选择容器。您将看到该 EC2 实例中所有的 Docker 容器:

图 11.9 – Docker 容器性能监控
如果您点击列表中的其中一个容器,您将能够获取更多关于该容器的指标,例如内存使用情况、CPU 利用率等:

图 11.10 – MySQL 容器指标
所有这些数据可视化工具都将帮助您更好地了解您的容器和应用程序的使用情况,并在出现性能问题时帮助您诊断问题。
摘要
在本章中,我们已经讨论了将 APM 工具作为您设置的一部分的重要性。我们已经将 New Relic APM 代理安装到我们的 AWS EC2 实例和 Docker 容器中,以开始记录性能和用法数据。使用 APM 工具完全是可选的,但拥有一个将帮助您和您的团队能够通过提供真实的生产性能数据来更快地解决问题。通过拥有 APM 工具,您将能够更好地理解您的应用程序,并且它将帮助您优化和改进您的应用程序。


浙公网安备 33010602011771号