Yii-测试学习指南-全-
Yii 测试学习指南(全)
原文:
zh.annas-archive.org/md5/ac759df79e95ac03493a2475aea38454译者:飞龙
前言
由于我先发现了 Yii,后来又发现了 Codeception,我简直不敢相信有人真的想到了解决大多数 Web 开发者,包括我,多年来一直遭受的艰难测试困境。
我将我很大一部分的精力投入到了这本书中,希望我最终能创作出一本我从未成功找到的、关于测试方面的书籍。
我认为最困难的部分是将所有积累的经验、阅读、会议和关于质量保证、测试和项目管理方面的交谈集中起来。
但如果没有在 Yii 当前版本(版本 2)和 Codeception 上投入的巨大努力,这一切都是不可能的。这两款软件,以及书中提到的其他软件,都是全球数百名开发者努力的结果。
在所有这些的基础上,加上克里斯蒂娜的同情和耐心,她忍受了我疲惫不堪的夜晚,我有幸发布这本书,希望你能从中找到讨论、改进和为测试和 Web 开发社区做出贡献的灵感。
本书涵盖内容
第一章, 测试心态,首先定义了全书使用的概念,试图解释为什么测试如此重要,介绍了主要的测试技术,并展示了如何进入正确的思维模式来阅读本书。
第二章, 为测试做好准备,介绍了 Yii 2,为你提供了一个了解代码如何组织的概述。我们还开始从测试的角度定义将在剩余章节中继续的工作。
第三章,进入 Codeception,介绍了 Codeception,并解释了它做什么,如何构建,以及如何工作。
第四章, 使用 PHPUnit 进行隔离组件测试,展示了 PHPUnit。本章实现了第一个单元测试,并辅以数据提供者。
第五章, 召唤测试替身,介绍了使用模拟和存根的测试替身,同时仍然坚持使用 PHPUnit。我们还欣赏一种类似于 BDD 的语法来编写我们的测试。
第六章, 测试 API – PHPBrowser 来拯救,概述了功能测试,然后展示了与 Yii 2 允许你创建的 REST 接口相关的扩展。
第七章, 享受浏览器测试的乐趣,最终展示了使用 Selenium WebDriver 进行验收测试的一些实际操作。
第八章,分析测试信息,涵盖了一些关于测试和代码优化技术的更高级主题,这要归功于 Codeception 和其他工具生成的报告。
第九章,借助自动化消除压力,是一个更高级的章节,介绍了与 Jenkins CI 的持续集成,旨在自动化测试并使用 Jenkins CI 显示它们的报告。
你需要这本书的东西
这本书开始时需要的很少。如果你有一台不错的开发机器,你还需要安装和设置的其他唯一东西就是一个 LAMP 堆栈。
如果你之前在这个领域工作过,你可能已经知道有许多其他变体允许你使用一个完全兼容的 LAMP 堆栈,例如 Nginx、PostgreSQL 或其他。你甚至可以在本地硬盘上的 VPS 或虚拟机上运行所有这些。这本书没有提供如何设置所有内容的说明,所以请在打开浏览器之前准备好将某些东西设置并运行起来。
这本书面向的对象
基于对底层软件和服务层的必要理解,这本书适合任何有一定 Web 开发经验和 OOPHP 编程知识的读者。经验丰富的程序员在接近这本书时应该不会有问题,因为它可以被视为增加和重申他们在测试技术和实践方面的知识。
尽管了解测试会有所帮助,但它不是严格必需的,因为所有方面都会被涵盖,从理论方面到深入实践方面。
惯例
在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“记住,总有一个你可以查阅的README.md文件。”
代码块如下设置:
if (YII_ENV_DEV) {
    // configuration adjustments for 'dev' environment
    $config['bootstrap'][] = 'debug';
    $config['modules']['debug'] = 'yii\debug\Module';
当我们希望将你的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
// tests/codeception.yml
...
config:
    test_entry_url: https://basic.yii2.sandbox/index-test.php
任何命令行输入或输出都如下所示:
$ cd tests/codeception
新术语和重要词汇以粗体显示。你会在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“只需记住勾选覆盖复选框并点击生成。”
注意
警告或重要提示以如下框中的形式出现。
小贴士
小贴士和技巧如下所示。
读者反馈
我们读者的反馈总是受欢迎的。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者的反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。
要发送一般反馈,请简单地通过电子邮件联系mailto:feedback@packtpub.com,并在邮件主题中提及书籍的标题。
如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 书籍的骄傲拥有者,我们有多种方式帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在www.packtpub.com的账户下载所有已购买的 Packt 出版物的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过链接mailto:copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者和我们提供有价值内容的能力方面的帮助。
问题
如果您在这本书的任何方面有问题,您可以通过电子邮件联系mailto:questions@packtpub.com,我们将尽力解决问题。
第一章. 测试心态
这本书的写作目的是教授如何结合使用Codeception和Yii 2。通过使用这两个优秀的框架,我证实了测试最终可以成为任何人都会欣赏去做的事情,而不是将其视为开发中一个奇特且不太清晰的附录。
因此,第一章试图解决几个很少被触及但希望给您带来理解和必要的推动去学习和采用测试的方面,并在更大范围内推广测试作为一种改进开发的方法论。
在本章中,您将了解到测试的原因以及为什么测试应该被纳入到项目中,而不是作为事后考虑。
您还将看到开始测试时会发生什么:短期和长期内隐含和显性的好处,例如对测试心态的改变、改进组件规格的能力、架构、设计和实现选择,以及重构、重新分配和代码的整体质量。
为了解释为什么测试如此重要,我还会简要地探讨过程中的组织部分,其中测试驱动开发(TDD)和行为驱动开发(BDD)将与现代项目管理技术(如敏捷和 XP)相结合,在一个多技能、自我组织的团队中进行解释。
您还将看到如何改善和重组整个团队环境,以帮助分享知识和加快工作流程。
本章分为以下三个部分:
- 
理解测试的重要性 
- 
项目管理的涉及 
- 
获得测试心态 
理解测试的重要性
自从我以专业方式开始从事质量保证和测试以来,我从未面临过“什么是测试?”这个问题。
我必须诚实地说,但在大学期间,测试并不是任何课程的一部分。我不确定最近是否有所改变,以及所教授的内容是否对商业世界有任何重要性或相关性。
在这本书中,我尝试结合使用一个优秀的 PHP 框架 Yii 2 及其版本 2 和 Codeception 测试套件来展示开发和测试的实用性。我将从实际对团队有益的角度展示每个主题,同时从更高的视角展示工作的规划和组织。在这本书的各章节中,我会来回切换,试图清晰地理解您将要工作的细节和工作的范围,从测试的角度来看整体目标。
但是,在我们开始这段旅程之前,什么是有效的测试?谷歌工程总监詹姆斯·惠特克的话对此给出了很好的答案:
"尽管质量无法在产品中测试出来,但同样明显的是,没有测试,就不可能开发出高质量的产品。"
测试的某些方面与开发完全融合在一起,以至于两者实际上无法区分,而在其他时候,它又是如此独立,以至于开发者甚至没有意识到这种情况正在发生。
在整个项目生命周期中,你从想法开始,将其转化为功能或故事,将它们分解为任务。然后你进入执行这些任务的阶段,希望最终得到一个成品。
在我们的开发过程中,我们试图在各个阶段都加入一定程度的质量,无论是通过“检查”页面加载,还是通过进行一些更智能和深入的测试,即使不是自动化的。
Atlassian 的 QA 团队负责人 Penny Wyatt 指出,那些没有执行质量保证或将其留给团队自行执行小型自动化任务(例如单元测试)的团队,故事拒绝率最高,这意味着一个故事在完成之后因为功能错误或缺失而需要重新打开。我们谈论的是 100%的拒绝率。
当这种情况发生时,我们就处于一个不得不回到开发中去修复我们所做的工作的状态。这并非唯一的情况:与之相伴的,还有晚发现的错误和缺陷,以及修复它们,可能是软件开发中最昂贵的任务之一。在大多数情况下,已经证明它们的成本远远高于最初预防的成本。不幸的是,软件开发很少没有缺陷,这一点始终应该牢记在心。
作为开发者和管理者,我们应该有的一个目标是将缺陷的发生率降低到经济可接受的水平,这样做也减少了与之相关的风险。
作为实际例子,一个大型网站可能会有数千个软件错误,但由于 99%的网站内容显示正确,它仍然具有经济可行性。对于一个猎鹰火箭或一级方程式赛车,如此高的缺陷率是不可接受的:一个错误放置的部件可能也会危及人们的生命。
缺陷减少的另一个隐含目标是投资于团队合作。一个开发者引入的错误可能会对其他团队成员的工作产生连锁反应,总体上,对代码库和其他同事工作的信任度也会受到影响。在本章和随后的章节中,我们将通过介绍一些项目管理概念以及它如何在不同层面上确保质量来更详细地讨论这一方面。
最后,可能也是一个同样重要的方面是,如何通过示例使用测试来记录代码。这一点很少被讨论或引起开发者的注意,但我们将看到测试如何以比 PHP 文档注释更精确的方式描述我们实现的功能。我并不是说文档注释没有用,恰恰相反:在 NetBeans 或 PHPStorm 等现代集成开发环境(IDE)中,自动完成和代码提示是提高发现底层框架所需时间的绝佳方式,而不需要查阅参考手册。测试实际上可以,并且应该提供开发者可能需要的帮助,当他们在尝试组合和使用尚不为人知的接口时。
当与由小型自发团队工作的开源软件合作时,如果能够提供文档而不需要大量努力,这可能是快速和持续交付的关键。
但我们如何确保在强加给团队的约束条件下能够满足交付?为了解释这一点,我们不得不快速转向项目管理,其中一些讨论和使用的实践就源于此。
涉及项目管理
如果你曾经参与过软件开发规划阶段,或者如果你曾经担任过项目经理,你应该清楚地知道,有三个基本变量你可以利用来管理项目:
- 
时间 
- 
质量 
- 
成本 
在大多数理论和实践描述的商业场景中,利益相关者决定修复两个变量,将第三个变量留给团队来估算。换句话说:
时间、质量和成本...选择两个。
事实上,通常发生的情况是时间和成本最终被设定在项目之外,从而留下质量作为开发者唯一可以操作的变量。
你可能已经体验过,降低质量并不能消除工作,它只会推迟它。关于缺陷率的我们之前提到的,降低质量实际上可能会在长期内增加成本,如果不造成短期内的很多问题,那么技术债务可能会失控。
注意
术语技术债务被引入作为一种隐喻,指的是不良设计、架构或开发选择在代码库中的后果。已经有一些书籍专门写来对抗那些不是旨在管理它的不良实践。
现今不太被谈论的敏捷方法之一,极限编程(XP),引入了,如果不是暴露了,方程中的一个新变量:范围。
通过明确范围,它执行以下操作:
- 
创建一种安全的方式来适应 
- 
提供一种协商的方式 
- 
给我们一个工具来控制请求和需求 
从 XP(极限编程)的角度来看,在分解阶段之后,我们不得不进入一个估计每个单独任务的阶段,并且基于预算,你只需不断添加或删除任务。
这次讨论提出了一个目前在社区中广泛讨论的问题,因为估计任务并不像人们想象的那样简单。我们很快就会深入探讨这个问题,因为我看到太多人对这个话题的理解有误。
估计任务
正如我们所见,任务的估计一直被认为是项目交付路径安排的基本原则之一。这在敏捷方法中尤其有效,因为它们使用固定时间迭代,并计算在给定冲刺中可以适应的功能和任务数量,并通过燃尽图等工具在每个迭代中进行调整。
注意
如果你曾在敏捷环境中工作过,这应该很容易理解,如果你没有,那么通过阅读在线免费提供的关于 SCRUM 的书籍或文章,你可以获得很多信息。
很遗憾,尽管估计如此重要,似乎没有人真正深入研究过它:有很多文章警告说,软件开发任务的估计总是偏离 2 到 3 倍。那么,我们是否应该接受我们不会在估计上变得更好的事实,或者这背后还有更多东西?
“估计不起作用”的论点可能也不正确,最近,#NoEstimates 这个标签在网络上引发了一些讨论,这可能值得在这里包括。
事实上,估计是有效的。唯一通常被忽视的细节是,估计接近实际花费的时间,这取决于开发者的知识程度和环境控制的程度。
事实上,现实是双重的:一方面,我们的估计会随着经验的增加而变得更好,另一方面,如果我们的道路上未知因素减少,我们的估计将更接近现实。
这在项目管理中众所周知,被称为“不确定性锥”。
我们真正需要做的是承认并揭露所有会增加我们估计风险和不确定性的方面,同时试图隔离那些我们知道将需要特定时间的事情。
例如,为了创建我们将要实现的功能的工作原型而设定一个固定时间的研究期,为未来的计算设定了先例,同时需要考虑人为因素。
尽管估计在软件开发和项目管理从业务角度来看特别重要,但我们在这本书中不会再涉及它们。我更愿意关注开发工作流程的更多实际方面。
测试方法
极限编程试图强调对缺陷减少的投资。为了做到这一点,它引入了两个基本原则:双重检查和缺陷成本增加(DCI)。
仔细检查就是软件测试。我们知道一个特定的功能应该如何工作,这可以通过一个测试来表示。当实现这样的功能时,我们以准确定的方式知道我们所做的是正确的。
注意
极限编程利用价值观、原则和实践来概述方法论的核心结构:简而言之,你选择描述团队特性的价值观;你遵守某些原则,这些原则将引导你使用特定的实践。
原则可以被认为是价值观和实践之间的桥梁,为在比“但每个人都这么做”更具体的事物上使用实践提供了正当理由。
DCI的另一个原则可以用来提高测试的成本效益。DCI 所陈述的是以下内容:
| *"越早发现缺陷,修复成本就越低。" | ||
|---|---|---|
| --肯特·贝克 | 
为了通过一个例子使这一点更加清晰,如果你在开发多年后发现一个缺陷,可能需要花费大量时间来调查代码最初打算做什么以及最初是在什么背景下开发的。相反,如果你在实现时立即发现缺陷,修复的成本将是最小的。显然,这甚至没有考虑到一个严重缺陷可能对我们代码库的关键部分造成的所有隐藏成本和风险;例如,考虑安全和隐私。
不仅等待时间越长,修正缺陷就越困难,而且成本也会增加,并有可能留下许多残余缺陷。
这意味着,首先,我们需要有更短的反馈周期,以便我们可以连续发现尽可能多的缺陷;其次,我们不得不采用不同的实践,以帮助我们尽可能保持成本和质量不变。
快速且频繁地发现缺陷的想法已经被正式化为持续集成(CI),这需要引入自动化测试以避免成本失控。这种实践在极限编程之外也获得了很大的动力,并且目前被许多组织广泛使用,无论采用哪种项目管理方法。我们将在第九章消除自动化带来的压力中更详细地了解如何在我们的工作流程和开发中引入 CI 和自动化。
这些实践完全反对像以下图中所示的那样以瀑布方式工作的想法:

水瀑布工作流程中的交付路径
在瀑布模型中,我们有一些可能影响我们工作质量的因素的组合:在大多数情况下,规格不是一开始就设定,也不会在任何时候冻结。这意味着我们很可能不会生产出业务所要求的产品。
换句话说,你会在开发完成后才开始测试,这显然太晚了,正如前图所示:你将无法在发布日期前及时捕捉到任何缺陷。不幸的是,尽管瀑布模型可能感觉自然,但其有效性已经被多次证明是错误的,我不会在这个话题上投入更多时间。
注意
值得注意的是,“瀑布”这个定义,尽管没有具体使用这个术语,是由温斯顿·W·罗伊斯在 1970 年正式提出的,当时他在描述一个有缺陷且无法工作的软件管理模式。
自从敏捷方法的出现,XP 也是其中一部分,人们一直在努力尽早引入测试。
记住,测试和开发一样重要,因此很明显,我们需要将其视为一等公民。
你可能会遇到的一种常见情况是,即使你在代码库开发的同时开始测试,也可能引发比所需或可以解决的问题更多的问题。结果情况仍然会产生大量的问题和技术债务,这些债务不会适合在交付路径中,正如以下图所示:

在敏捷环境中,交付路径
团队的目标是消除所有开发后的测试,并将测试资源转移到开始阶段。如果你有如压力测试或负载测试这样的测试形式,它们在开发结束时可以突出显示缺陷,尝试将它们纳入开发周期。尝试持续和自动地运行这些测试。
转变为一个在开始时就包含测试的工作流程会暴露出两个主要问题:技术债务的积累以及开发人员和测试人员被视为两个独立实体的固有问题。别忘了,在开发之后仍将有一些测试需要执行,并且显然需要第三方来完成,但无论如何,让我们强调我们的努力是尽可能减少它。
正如我将会不断提醒你的,测试不是别人的问题。相反,通过这本书,我旨在为开发者提供所有可以让他/她首先成为测试者的工具。对于这个问题有不同方法,我们将在本章末尾简要讨论,当谈到测试心态时。
引入测试驱动开发
如果你曾经带着测试意识进行开发,你可能已经欣赏到从一开始就做对是至关重要的。那么,我们需要测试什么呢?
在过去的几年里,已经创建了各种方法,为开发者提供了一套规则,以解决如何在开发周期中包含测试。
第一也是最著名的,是测试驱动开发。
在你的团队中采用 TDD 作为实践的主要目标是实现测试优先的心态,这通过红-绿-重构的概念来实现。你首先实现测试,这些测试不应该通过(红色状态),然后实现被测试的接口,以便测试通过(绿色状态),最后根据需要重构接口以改进测试所强调的内容。
我们已经从管理的角度看到了使用这种方法的好处,但这对开发者的影响更为直接。实际上,TDD 允许你实现软件开发中所教授的内容,即接口不应该受实现的影响。并且,作为次要影响,正如我们所看到的,它提供了一种记录接口本身的方法。
通过首先实现测试,你关注的是方法、类和接口应该如何被团队内部或外部的人使用。这被称为黑盒测试,这意味着我们的测试应该完全不了解实现细节。这带来了隐含的好处,即允许实现随着时间的推移而改变。
小贴士
如果你对这个主题感兴趣,你可能值得探索设计规范(DbC)的规范,它允许你在特定的面向对象编程语言中以更正式的方式描述接口。一个不错的起点可能是在c2.com/cgi/wiki?DesignByContract。
不幸的是,TDD 试图关注正在开发的功能的原子部分,但它未能提供一个更广泛的视角,包括测试了什么、测试了多少,或者,更好的是,测试的内容对业务和产品本身是否有任何相关性。
再次强调,XP 为了获得双重检查的全部好处,引入了以下两组测试:
- 
另一套从程序员角度编写的代码 
- 
另一套从用户角度编写的代码 
在第一种情况下,它允许程序员彻底测试系统的所有组件,而在后者情况下,则是整个系统的操作。
从某种意义上说,后者可以被视为行为驱动开发(BDD)以更正式的方式所描述的内容。我们将在第二章 为测试做准备中更详细地介绍 BDD。
BDD 试图弥补 TDD 在整体范围上的不足,并将注意力转向项目的行为方面。BDD 实际上是 TDD 的一种演变,但需要对工作组织和交付方式进行一些改变,这在某些环境中可能相当困难,尤其是在不重新评估整个工作流程的情况下。
使用 BDD(行为驱动开发),你可以在多个层面上定义要测试的内容以及如何测试,使用一种定义良好、面向业务的语言——通用语言,这种语言是从领域驱动设计(DDD)中借用的,并由团队的所有成员共享,无论是技术性的还是非技术性的。对于本章的范围,可以说 BDD 引入了故事和场景的概念,赋予开发者正式描述应用程序的用户视角和功能的能力。测试应该使用用户故事的标准敏捷框架来编写:“作为[角色],我想[功能]以便[好处]。”验收标准应以场景的形式编写,并作为类实现:“给定[初始上下文],当[事件发生]时,然后[确保某些结果]。”
测试计划
因此,从软件开发的角度来看,规划测试是至关重要的,而在不太久远的几年里,已经出现了几种从规划角度改进测试的解决方案,它们提供了更详细和紧凑的方式来定义所谓的测试计划。
在以测试为导向的环境中,测试计划应为你提供在任何级别上测试什么和测试多少的方向和指示。此外,测试计划应该是向各种利益相关者公开的,其可见性不应局限于开发的范围内。因此,我们的责任是维护并确保这份文档在整个项目生命周期中保持活跃。
在实践中,我很少看到这种情况发生,因为测试计划从未被正式化,或者如果被正式化,它们太长且难以维护,从其最初构思以来寿命非常短暂。
例如,属性-组件-能力(ACC)是由谷歌创建的,旨在解决测试计划一直面临的一些主要问题,尤其是其可维护性。你可以在code.google.com/p/test-analytics/找到有关 ACC 和谷歌测试分析软件的更多信息。
ACC 测试计划简短且紧凑,整个项目都试图实现可以在几分钟内创建的测试计划,这些计划能够自我描述,对项目中的任何人都是有价值的。
对于每个组件,你有一系列的能力,这些能力可以用一个或多个属性来描述;例如,“安全”、“快速”或“用户友好”。此外,每个能力和组件都与一个相对风险水平相关联。这两者结合起来,可以帮助你理解什么是最重要的测试内容,以及你的测试应该有多彻底。
生成测试
显然,规划测试只是开始。一旦你进入实施阶段,你可以选择这本书,它提供了如何使用可用的工具来创建测试的知识。
关于这个方面,我无法告诉你更多。你可能只需要阅读所有内容,但应该强调的是,在编写测试时,你必须牢记一些基本原则。
优秀的测试应具备以下三个重要特征:
- 
可重复性:测试必须是确定的。这确保测试不依赖于外部因素问题。 
- 
简洁性:只测试一件事。测试越小,就越容易控制。 
- 
独立性:测试应该独立执行。测试之间不应存在依赖关系。这也有助于测试和代码的调试。 
一旦你掌握了如何从架构角度接近项目,一旦你理解了测试计划的工作原理以及你真正需要测试的内容,你就可以开始实施测试,与他们讨论,并在同事的帮助下改进工具和你的使用方式。
获得测试思维模式
因此,到目前为止,我们已经看到了测试在当前开发实践中的重要性,我们也看到了从项目管理角度围绕开发本身的所有方面,但我们仍然不知道要成为一名优秀的测试员需要什么。
寻找了解测试的开发者尤其困难,网上有很多讨论这个问题:如果这么难,我们难道不能做得更好吗?我们如何让开发者首先成为测试员,尤其是当你真正希望开发者从代码发布的最初就负责代码质量时?
我倾向于同意这样一个普遍观点,即测试员或了解测试的开发者需要三个基本要素:思维模式、技能集和知识。
那么,你该如何着手获取或提高这三个基本方面呢?
即使你能阅读所有关于测试的书籍并收听所有相关的播客,尽管这些会给你提供大量关于如何测试事物以及各种测试套件和框架如何工作的技能集,但你仅凭这些是无法成为一名测试员的。
当然,实践可以帮助你很多,但总的来说,质量思维模式和测试知识可能是最难获得的。
知识部分来自于对产品的更高视角,既包括技术方面,也包括业务端。介绍项目分解和为即将在我们的软件中引入的功能进行的提案可以是这个过程的起点。
质量思维模式可能是最棘手的,因为它最终会融入软件开发的所有方面,从技术角度来看,需要所有相关方的积极参与,首先是开发者。
如前所述,在质量方面没有固定的定义。你可以在项目中投入多少质量没有上限。因此,在任何项目中你可以进行多少测试也没有限制。
从我所见证的情况来看,除了成为一名优秀的开发者之外,还有两个要求可以加快成为一名优秀测试员的过程:其中一个来自环境,另一个来自我们自身。
我认为环境因素可能是获得我们所说的正确测试心态的关键,而达到这一点可能应该是任何决定质量有价值的公司优先考虑的事情,并且是可以衡量的。
当然,有一个可以进行测试指导的人总是效果最好:通过模仿和辩论可能是最好的团队导向工具。即使你的团队中没有测试员,你可能也注意到在开发中,像结对编程或代码审查这样的实践可以大大帮助团队跟上所需实践和知识。
让我们更详细地看看这在实际中意味着什么,同时记住在应用实践和方法论方面没有一劳永逸的解决方案,你的任务是根据手头的资源进行实验和调整。
从无测试文化开始——一种实用方法
在这一系列关于如何在团队或项目中引入测试的实践例子中,我们将假设一些不可或缺的要求,以帮助你取得进展。
在这个特定的情况下,我们将假设你在一个团队中工作。理想的情况是拥有至少三个人的团队。
注意
如果你与不到三个人一起工作,或者你是一个独立开发者,大多数技术和实践可能带来的成本可能会高于预期的收益。
一个测试计划和合理的工作流程组织(尽量保持简单)不仅会在需要时为在大团队中工作提供坚实的基础,而且还能为你提供快速交付质量的工具。
首先,你需要来自业务和直接管理者的支持;说到直接和间接经验,没有这些你将无法取得任何进展。公司的业务方面需要理解测试是什么,就像本章开头所描述的那样,测试的价值,以及它能带来的所有好处。网上有大量的文档可以帮助你构建商业案例。
其次,你需要具备一些测试技能。这本书应该涵盖这部分内容——希望做得相当好——而且还有许多其他书籍可以教你更多关于测试的理论知识,无论在线上关于这个主题的资源有多少。
注意
你可以在网上找到一些不错的文章,如下所示:
- 
单元测试:为什么麻烦? 可在 soundsoftware.ac.uk/unit-testing-why-bother/查阅
- 
Airbnb 的测试 可在 nerds.airbnb.com/testing-at-airbnb/查阅
一旦你有了这个,你就可以开始采取行动。
大多数人可能会发现自己处于这样的情况:完全没有测试文化。这里你有两个选择:要么采取自下而上的方法,从 TDD(测试驱动开发)入门,要么采取自上而下的方法,从更高的视角出发。
无论哪种方式,你都需要开始制定一个紧凑的测试计划来遵守。以 ACC 的方法为例,你首先将应用程序/项目/库分解成模块(组件),每个模块将由功能(能力)组成。每个功能都将有一个特定的属性。从那里,你应该有一个足够紧凑的表示,说明你试图实现什么。在此基础上,你可以开始分配相对风险等级,这将用于确定测试方法的优先级,即测试什么和测试多少。
最终的测试计划应由所有利益相关者签署,并尽可能频繁地更新,以定义项目的目标本身。这份文件越正式越好,因为它将成为项目的名片。
如许多人所强调的,直接的目标是开始在开发者中培养测试文化。定义你的工作范围,无论是测试还是开发,都要谨慎行事,评估风险和成本,并利用这些因素来决定如何进行测试。
幸运的是,如果你发现自己正在使用 Yii 和 Codeception 进行工作,你应该可以避免一些头痛,不必将不同的框架组合在一起,也不必浪费太多时间来实验一个可行的解决方案。
在团队层面,当测试经验既不广泛也不稳固时,可以引入一些额外的实践来帮助避免瓶颈或让所有知识都集中在一个人身上,例如结对编程和代码审查。
一些公司,如 Atlassian,引入了测试工程师,他们可以从指导和质量保证的角度帮助团队。他们在开发周期中的干预最终被限制在更有限的参与中,即在任务开始之前和完成之前。尽管如此,他们的角色是基本的,因为他们成为了测试基础设施、工具和要采用的实践的守护者,而开发者则成长为一名能够几乎涵盖测试所有方面的全面测试人员,而不需要太多支持。
摘要
在本章中,我们涵盖了与测试直接相关但并非严格必要的许多方面,尽管如果你想要理解为什么选择这本书,以及是否需要继续阅读它是基本的。
你已经看到了测试的重要性,一些项目管理方法,如何估算任务以及它包含的内容,你还看到了不同的测试方法,如 TDD 和 BDD,这些将成为许多剩余章节的基础。最后,我尝试给出一个关于获得成为测试大师所需测试心态的想法。
在第二章中,准备测试工具,我们将开始准备本书剩余部分将要使用的工具,了解 Yii 2 的基础知识,并通过概述我们的测试计划来应用本章所学。
第二章 为测试做准备
在本章中,我们将概述 Yii 2,自上一版本以来有哪些变化,以及你可能已经熟悉的新目录结构和组织方式,以及其新特性和优点。
我们不能不介绍 Composer,这是在 PHP 中组织和扩展项目的新方法。
一旦我们查看完所有我们将要使用的基本工具,让我们回顾我们的计划,并考虑在这本书的其余部分我们将要做什么:用户身份验证 REST 接口和从模态窗口进行用户登录。
为了开始我们的功能开发,我们需要暂时退一步,从项目管理和质量保证的角度回顾我们的计划,也就是说,引入主测试计划。换句话说,我们需要考虑在开始实际实施工作之前,我们将要测试什么以及测试多少。
我们将按照以下步骤进行操作:
- 
下载和安装 Yii 2 
- 
在 Yii 2 中找到你的位置 
- 
定义我们的工作策略 
- 
为我们的目的引入测试 
下载和安装 Yii 2
如果你以前使用过 Yii,请做好准备。Yii 2 的新版本可以被视为一个全新的框架,现代且稳健。
Yii 2 在正确的方向上迈出了期待已久的一大步。这是多年来共同努力的结果,主要在互联网上协作完成,主要在 GitHub (github.com/yiisoft/yii2) 上,由来自世界各地的开发者完成。
作为 Yii 的用户,你也可以通过简单地提交错误报告、功能请求在 github.com/yiisoft/yii2/issues,完善文档和翻译,以及创建新的扩展和功能以供审查和包含到项目中来进行协作。还有许多其他非官方支持的项目可以从你的支持中受益:一些已经为你准备好了使用,还有一些可能是你自己编写的。
环境和工作流程
作为一名开发者,你将拥有自己的环境,你需要对其有足够的信心,并且实际上它可以帮助你在编写代码时无需过多担忧。如果你确实觉得在编写代码和看到实际结果之间存在差距,那么你需要修复某些问题。
对于本书的目的来说,概述我将贯穿整个后续章节和代码示例中的最佳环境非常重要。
当然,我会注意环境可能产生的影响,但请注意,如果你的环境不同,你可能需要检查开发者的文档或联系可能知道答案的任何人,以防某些事情不起作用。
我的个人开发环境由以下组成:
- 
一个强大的 集成开发环境 (IDE),例如 IntelliJ PHPStorm,而不是一个简单的代码编辑器(例如,VIM):您可以从它那里获得一些额外的优势,例如,集成的调试器、语法检查器、代码提示系统等等。 
- 
一个现代的版本控制系统(例如,GIT):经常提交。这是您理解项目历史和以合理方式控制项目变更的唯一方法。如果您需要更多信息,请访问 git-scm.com/doc,并在pcottle.github.io/learnGitBranching/通过视觉实验来学习它。
- 
Linux Apache MariaDB PHP (LAMP)虚拟机:我已经经历过将我的机器作为 LAMP 机器的阶段,但这已经证明由于许多原因而过于不可靠。主要原因是过一段时间后,您可能会混淆那些本不应该用于某些项目的实验性插件和工具,这可能会弄乱您的工作。 
开发或测试环境通常设置起来很简单,因为它不会像在关键或生产环境中那样需要广泛的配置。
有这样一个环境设置的理由之一,特别是针对 LAMP 机器,就是您可以根据正在工作的项目来配置它,特别是能够尽可能接近地复现实时/生产环境。这在以下方面具有明显的优势:
- 
在一个拥有多个开发者的团队中工作 
- 
在任何环境中(例如,测试、预发布或生产)复现出现的错误 
如果您想轻松开始,Vagrant 可能是您需要的工具(见 www.vagrantup.com/),如果您被说服了,那么尝试一下由 Michael Peacock 编写、Packt Publishing 出版的书籍《Creating Development Environments with Vagrant》可能是个不错的选择(www.packtpub.com/creating-development-environments-with-vagrant/book)。
PHP 不需要做重大调整,我相信默认的 PHP 安装就足够您开始使用,因为这是运行 Yii 2 的唯一约束。请确保您有一个等于或高于 5.4 的版本,并在命令行上有一个可用的 CLI PHP,通过以下命令执行:
$ php –v
PHP 5.5.22-1+deb.sury.org~precise+1 (cli) (built: Feb 20 2015 11:25:06) 
Copyright (c) 1997-2015 The PHP Group
Zend Engine v2.5.0, Copyright (c) 1998-2015 Zend Technologies
 with Zend OPcache v7.0.4-dev, Copyright (c) 1999-2015, by Zend Technologies
 with Xdebug v2.2.5, Copyright (c) 2002-2014, by Derick Rethans
上述输出来自一个运行着 Ubuntu 12.04 且已安装 PHP 5.5 的 Vagrant 机器。
美元符号 ($) 表示该命令可以被用户运行,您不需要管理员权限来运行它。
如果您遇到“找不到命令”的错误,请务必参考您的发行版/操作系统供应商以获取有关如何安装它的支持。大多数发行版默认提供它,而其他则需要额外的配置参数或软件包。
介绍 Composer
如你所知,Yii 1 最初是以独立库的形式发布的,需要在目标环境中安装,然后你可以使用它的 CLI 界面来创建你的 Web 应用。之后,库将位于你的文件系统中,以便 Web 应用在加载时直接调用。
当 Yii 开始时,这是一种常见的做法;当时没有一种方法可以保持代码自包含,而且每次你需要将代码发送到共享主机环境时(我指的是 Plesk/OpenBaseDir 限制),你很容易遇到几个问题。
其次,系统范围内的包和依赖通常限制了开发者采用新特性并解决现有问题,甚至不考虑这些(过于)经常被忽视。如果你用 PHP 做 Web 开发有一段时间了,我相当肯定你体验过落后于其他大型框架在开发场景的感觉(不仅仅是在 PHP 领域)。
Composer (getcomposer.org)从许多方面解决了问题,多亏了 Nils Adermann、Jordi Boggiano 以及许多社区贡献者的努力,它于 2012 年首次发布。
Composer 从 Node.js 的npm和 Ruby 的bundler中汲取灵感。它提供了一种定义和安装依赖项(即库)的方法,并按项目安装从 Packagist (packagist.org/)提供的 Web 应用。
安装和使用它
让我们从遵循 Composer 网站上提出的安装指南开始(getcomposer.org/doc/00-intro.md#installation-nix)。考虑以下命令:
$ curl -s https://getcomposer.org/installer | php
在前面的命令中,我们使用curl下载安装程序,使用php解析它并输出一个名为composer.phar的可执行 PHP 文件。请注意,在不同的操作系统下安装可能会有所不同(如果你没有 Linux 环境可以玩),例如,在 OS X 下,Composer 是homebrew-php项目的一部分,可以在github.com/Homebrew/homebrew-php找到。
在这一点上,你可以直接使用相对或绝对路径调用 Composer,如下所示:
$ php composer.phar
或者将其移动到更合适的位置以便更容易调用,正如你接下来将要看到的。
如果你可以运行 sudo 或以 root 身份登录,将其移动到系统范围的bin文件夹,如下所示:
$ sudo mv composer.phar /usr/local/bin/composer
如果前面的选项不适用,你可以在用户空间中安装它,例如,~/bin/,然后按照以下示例将路径添加到你的PATH环境变量中:
$ mv composer.phar ~/bin/composer
$ PATH=$PATH:~/bin/; export PATH
最后一个命令是将路径添加到你的终端环境,这样你就可以在任何文件系统位置调用它。每次你打开终端时,都需要发出这个特定的命令。
否则,你可以永久添加它,如下所示:
$ echo "export PATH=$PATH:~/bin/;" >> ~/.bashrc
通过将export语句添加到你的.bashrc文件中(>> ~/.bashrc将echo的输出追加到.bashrc文件的末尾),你只是在每次登录时自动使目录可搜索,前提是你使用的是 BASH 作为 shell 解释器。
如果你不确定你使用的是哪个 shell,你可以使用以下命令来检查:
$ echo $0
然而,尽管这在大多数 shell 上都能正常工作,而且很容易记住,但如果你的 shell 是 CSH,则不会工作,在这种情况下,使用更复杂但更便携的ps调用,如下所示:命令
$ ps -p $$ -o cmd=''
一旦你安装了 Composer,你可以简单地使用以下命令来调用它:
$ composer
composer.json 和 composer.lock 文件
Composer 通过读取位于项目根目录下的composer.json文件来工作,该文件将包含所有需求和依赖项:
composer.json
{
  "require": {
    "twig/twig": "1.16.*"
  }
}
上述片段非常清晰:它定义了我们的项目对 Twig 的依赖(twig.sensiolabs.org/)。这是一个具有清晰和紧凑语法的模板引擎。它还定义了对从 1.16 版本开始的任何版本的 Twig 的特定依赖。
手动修改composer.json文件可能会出现人为错误,有时,正如我们稍后将看到的,可能需要通过命令行使用以下命令将软件包添加到你的require或require-dev部分:
$ composer require "twig/twig:1.16.*"
这样,如果composer.json文件不存在,它将自动创建,并且将为你安装带有其依赖项的软件包。或者,如果你自己创建了该文件,或者作为项目的一部分接收了该文件,你可以按照以下方式调用install命令:
$ composer install
Loading composer repositories with package information
Installing dependencies (including require-dev)
 - Installing twig/twig (1.6.5)
 Downloading: 100% 
Writing lock file
Generating autoload files
上述命令的正常行为是从稳定源获取所需的软件包作为存档(在 Composer 术语中称为 dist),或者如果 dist 不可用或软件包处于某些非稳定阶段(例如 beta 或 dev),则通过仓库获取。
你可以通过使用--prefer-dist选项强制搜索 dist,即使对于开发软件包也是如此,或者使用--prefer-source强制从仓库而不是 dist 检查稳定软件包来更改此行为。
如你通过列出目录内容所看到的,Composer 会将所有库安装到你的项目文件夹中的/vendor目录下,并在根目录下创建一个composer.lock文件,该文件将保存安装的当前状态快照,将安装的库锁定到锁文件中定义的特定版本,如下所示:
$ tree -L 2
.
├── composer.json
├── composer.lock
└── vendor
 ├── autoload.php
 ├── composer
 └── twig
当你分享代码时,你需要提交composer.lock文件,这样你团队中的每个人以及你将要部署的任何其他环境都将运行你拥有的依赖的确切版本,从而降低仅影响某些环境的 bug 风险。Composer 会首先查找锁文件,然后再决定使用 JSON 文件下载基于定义的更新版本。
另一方面,不建议将/vendor目录提交到你的版本控制系统中,因为它可能会引起几个问题,如下所示:
- 
处理修订和更新的困难 
- 
仓库规模增加但没有带来任何好处 
- 
在 Git 中,如果你正在添加通过 Git 签出的包,可能会引起问题,因为它将它们显示为子模块,而实际上它们不是。 
这在很大程度上取决于你的部署策略,但一般来说,让你的环境和团队成员各自运行composer install命令会更好。
如果你需要更新依赖项,你可以简单地发出以下命令:
$ composer update
或者要更新特定的包,命令将是以下:
$ composer update twig/twig [...]
[...] 表示你可以使用单个命令添加多个要更新的包。
包和 Packagist
通过创建composer.json文件,你也在定义你的项目作为一个包。这是一个依赖于其他包的包。唯一的区别是,你的项目还没有名字。
Composer 可以帮助你以更一致和清晰的方式定义你的项目/包。考虑以下命令:
$ composer init
这将首先询问你一些关于你的项目的基本信息,包括你希望为你的项目设置的要求,然后创建(或覆盖)composer.json文件,如下所示:
Package name (<vendor>/<name>) [peach/yii2composer]:
Description []: Installing Yii 2 from scratch with composer
Author [Matteo 'Peach' Pescarin <my@email.com>]:
Minimum Stability []: dev
License []: GPL-3.0
在这些中,值得注意的一个是Minimum Stability选项:它提供了一种控制包稳定性的方法。通过省略它,它默认为稳定。此选项与"prefer-stable": true(或如果你想有依赖项的开发版本,则为false)结合使用,将为你提供足够的权力来决定依赖项的稳定性策略,其中没有明确定义。
然后将进入设置依赖项的交互式过程,如下所示:
Define your dependencies.
Would you like to define your dependencies (require) interactively [yes]?
Search for a package []: twig
Found 15 packages matching twig
 [0] twig/twig
 ...
Enter package # to add, or the complete package name if it is not listed []: 0
Enter the version constraint to require []: @dev
搜索可以是任何内容,它的工作方式与你通过网站搜索相同(packagist.org)。如果你想对你将要安装的内容有一个更清晰的认识,你可能想看看网站:你需要了解依赖关系并浏览代码以检查它是否如它所说的那样工作。
知道如何使用版本约束在只有少量依赖的项目中可能非常重要。根据getcomposer.org/doc/01-basic-usage.md#package-versions,以下是你需要了解的可能的关键词:
- 
精确版本:例如, 1.0.23
- 
范围:例如, >=1.2或>=1.0,<2.0或使用管道作为逻辑或,如>=1.0,<2.0 | >=3.0
- 
通配符:例如, 1.2.*
- 
波浪号运算符:在这里, ~1.2等同于>=1.2,<2.0;~1.2.3等同于>=1.2.3,<1.3(语义上:[[[[...]c.]b.]a.]x,其中x是唯一的变量)
Composer 在选择特定包时提供了更细粒度的控制,特别是你可以通过添加 @dev(或 alpha、beta、RC 或 stable)来按稳定性进行筛选。
有时候,你被迫使用一个不稳定版本,要么是因为没有稳定版本,要么是因为稳定版本中包含了一个在 master(dev)分支中已修复的 bug!
与定义直接依赖的基本包列表的 require 相反,require-dev 定义了用于开发的次要包,例如用于运行测试、执行调试等的库。然而,这些对于应用程序的正常运行不是基本的,如下所示:
Would you like to define your dev dependencies (require-dev) interactively [yes]?
你也可以跳过为 require 添加包,然后使用以下命令稍后添加它们:
$ composer require
对于 require-dev,至少在我撰写这本书的时候安装的版本,你需要手动添加它们,如开头所示。
在这个过程的这个阶段,你将能够审查在确认之前将要写入的 JSON,如下所示:
{
 "name": "peach/composer",
 "description": "A Composer project",
 "require": {
 "twig/twig": "@dev"
 },
 "license": "GPL-3.0",
 "authors": [
 {
 "name": "Matteo 'Peach' Pescarin",
 "email": "my@email.com"
 }
 ],
 "minimum-stability": "dev"
}
Do you confirm generation [yes]?
Would you like the vendor directory added to your .gitignore [yes]?
$
一旦你创建了 composer.json 文件,你就可以编辑它并根据自己的喜好进行调整。还有许多其他可以指定的选项。请参阅 getcomposer.org/doc/04-schema.md。
通过编译你的 composer.json 文件,你实际上是在创建一个可以与其他开发者共享在 Packagist 上的包。
该过程本身并不特别困难,你只需添加一些额外的选项,如 JSON 模式文档中定义的那样(getcomposer.org/doc/04-schema.md#the-composer-json-schema),然后使用 Git、subversion 或 mercurial 仓库发布你的代码。你也可以选择只发布一个 dist 包。如果你想在这个方向上迈出一步,请参阅 getcomposer.org/doc/ 中的文档以获取更多信息。
一旦你创建了 composer.json 文件,你就可以开始按照以下方式安装所有依赖项:
$ composer install --prefer-dist
Composer 允许你决定如何获取所有需求,在这个特定的情况下,我们优先选择了可用的 dist 文件。结果是以下内容:
Loading composer repositories with package information
Installing dependencies (including require-dev)
 - Installing twig/twig (dev-master 72aa82b)
 Downloading: 100% 
Writing lock file
Generating autoload files
$
创建你的第一个 Web 应用
在这个阶段,你应该已经对 Composer 有足够的信心,可以开始进行下一步。但在这样做之前,忘记你所学的吧!
创建一个 composer.json 文件并添加一系列包,任何人都可以做到。使用 Composer,你可以从一个给定的包创建一个项目。这意味着该包将被提取到指定的目录中(不再是 /vendor)。这个新项目将检查并保存其所有依赖项在其作用域内,即在其自己的目录中。
我们将要使用的命令的语法来安装 Yii 2 并开始使用它是以下内容:
composer create-project vendor/project target-directory
在这里,vendor/project 是项目的 Packagist 名称,在我们的例子中,名称将是 yiisoft/yii2-app-basic,正如我们稍后将会看到的,而 target-directory 是你想要安装它的位置。这个命令不会创建 composer.json 文件,所以你可以在你的环境中的任何地方运行它,只需确保指定正确的目标路径。
Yii 2 开发者分享了两个包含你可以开始工作的初始应用的包:一个 基本 的和一个 高级 的。
两个之间的区别在于依赖的类型以及已经实现的内容。
这两个项目都附带了一个 Markdown 格式的 README.md 文件,你可以阅读它来了解详细信息。为了简洁起见:
- 
基本:正如其名,这是一个基本的实现,非常接近通过安装 Yii 1 得到的结果,可以与默认的 Apache 或 Nginx 安装一起使用。 
- 
高级:如果你需要构建多层应用,这是一个非常基本的配置。你将得到的高级应用程序包括一个前端、一个后端和一个控制台应用,所有这些都是作为单独的 Yii 应用程序,并带有一些共同组件。它需要一个特定的初始化,所以请参考 README.md文件以获取详细信息。
注意
高级应用程序包含一个名为 init 的附加脚本,它包装 Composer 并启用或禁用 require-dist 的安装。
对于更详细的指南,请查看www.yiiframework.com/doc-2.0/guide-tutorial-advanced-app.html上的文档。
考虑以下命令:
$ composer create-project --prefer-dist --stability=dev yiisoft/yii2-app-basic basic
我们现在正在将 yiisoft/yii2-app-basic 包安装到 /basic。有其他方法可以让你开始,但这绝对是我能想到的最干净的方法,因为你不会受到仓库或其他任何事物的限制。
在此命令之后不需要交互,因为它将继续安装所需的包,包括 require-dev。
可能在这个时候,Composer 会失败安装一些依赖项,或者你可能会在稍后遇到一些运行时错误,所以最好检查你的需求是否满足,方法是打开浏览器中的需求脚本,这将检查一切是否正常。该文件位于项目的根目录中,名为 requirements.php。
在 Ubuntu 上,你可能需要安装一些包,这些包是必需的,例如 php5-mcrypt、php5-xsl 和 php5-xdebug。每个 Linux 发行版以不同的方式提供这些 PHP 扩展,它们的命名可能也不同;如果你在如何查找、安装或配置它们方面遇到问题,请咨询你的 Linux 发行版文档。
在安装过程结束时,你会注意到一些额外的工作正在进行,如下所示:
Generating autoload files
Setting writable: runtime ...done
Setting writable: web/assets ...done
Setting executable: yii ...done
$
如果你记得 Yii 的上一个版本,这可能是许多人都在寻找的东西。
注意
请注意,如果你在刚刚检出应用时运行 Composer,这些步骤需要手动复制,或者如果你已经安装了高级应用,你需要运行init工具。
CLI 命令行
在 Yii 2 中,Composer 既被用作安装你的 Web 应用基本骨架的方式,这在 Yii 1 中你会使用 CLI 界面来完成,如下所示的一系列命令,也被用作管理项目依赖的方式:
$ cd protected/
$ ./yiic webapp ~/public_html/myproject
如你所想,命令行的范围和功能现在已经相当不同,并且已经得到了扩展。
首先,CLI 现在位于项目根目录,被称为yii,如下所示:
$ ./yii
只需运行前面的命令,你将得到一个可能的命令列表,如下所示:
- asset     Allows you to combine and compress your JavaScript and CSS files.
- cache     Allows you to flush cache.
- fixture   Manages loading and unloading fixtures.
- hello     This command echoes the first argument that you have entered.
- help      Provides help information about console commands.
- message   Extracts messages to be translated from source files.
- migrate   Manages application migrations.
To see the help of each command, enter:
 yii help <command-name>
$
你会从 Yii 1 中认出的有migrate和message,它们完成了你习惯的操作,尽管一些已经得到了改进。唯一的真正区别是你将如何调用其特定操作(例如,migrate/create)。
现在 shell 和 Web 应用命令已被一个名为cache的缓存管理工具、一个名为fixture的固定数据创建工具(我们稍后会看到)和一个名为hello的演示命令所取代,你可以用它作为编写自己代码的灵感(例如,创建 cron 作业任务)。
在 Yii 2 中寻找方向
现在你应该已经在你盒子上安装了所有你需要的东西,所以让我们开始四处看看,了解 Yii 2 是如何组织的,这样我们就会知道在需要的时候把手放在哪里。
记住,总有一个可以咨询的README.md文件:在高级应用中,它将向您展示各种目录的结构和使用方法。
只需列出项目根目录的内容,你将立即发现一个很大的不同:
$ tree -L 1 -d
.
├── assets
├── commands
├── config
├── controllers
├── mail
├── models
├── runtime
├── tests
├── vendor
├── views
└── web
11 directories
我已经自愿从tree命令的输出中排除了文件,只显示了目录。
看起来,曾经位于/protected中的所有内容现在都已移出文档根目录之外。
项目结构现在与 Django 或 Ruby on Rails 应用非常相似;项目根目录包含所有代码,其组织方式与protected文件夹中的方式相同(例如,控制器、模块、配置等),一些额外的目录,例如用于小部件的目录,以及你的 Web 服务器的文档根目录。
你需要配置 Apache 使用的目录被称为web,Yii 使用它来仅发送静态文件、资产和入口脚本,如下所示:
$ tree -L 2  web
web
├── assets
├── css
│   └── site.css
├── favicon.ico
├── index.php
├── index-test.php
└── robots.txt
2 directories, 5 files
我倾向于喜欢这种组织方式,因为它通过降低目录的嵌套级别,立即使用户对代码的组织有了直观的了解。
如果你热衷于使用 Nginx,那不是问题,你将在官方文档中找到所需的答案,该文档可在www.yiiframework.com/doc-2.0/guide-start-installation.html#configuring-web-servers找到。
需要解释的两个目录是 mail,它用于存储电子邮件的 HTML 模板(请参阅www.yiiframework.com/doc-2.0/guide-tutorial-mailing.html中的文档),以及可能还有 tests 目录,你很快就会学习到。
默认 Web 应用程序的结构
基本应用程序由一个SiteController和一些模块以及一个登录系统组成。
配置文件应该相当直观易懂,可以在/config目录中找到。我们将不时地涉及到它们,以便配置我们将要使用的某些方面和扩展。
无论你是使用手动安装的基本应用程序还是前面解释的 Composer 驱动方法,你都需要在你的环境中设置数据库并配置应用程序。
在配置文件web.php中,请确保已设置cookieValidationKey,而在db.php中,根据www.yiiframework.com/doc-2.0/guide-start-databases.html#configuring-a-db-connection中的文档设置你的数据库 DSN。
你还会在web.php文件的末尾注意到以下内容:
// config/web.php
if (YII_ENV_DEV) {
    // configuration adjustments for 'dev' environment
    $config['bootstrap'][] = 'debug';
    $config['modules']['debug'] = 'yii\debug\Module';
    $config['bootstrap'][] = 'gii';
    $config['modules']['gii'] = 'yii\gii\Module';
}
默认情况下,Yii 2 会为你提供一个YII_DEBUG全局常量定义,以及一个环境YII_ENV_<ENVIRONMENT>定义,这在某些情况下可能很有用。请注意,其使用应限制在无法找到替代方案的具体情况下,无论是通过重新审视实现还是初始要求。在生产环境中,YII_DEBUG应设置为false,YII_ENV设置为prod。
文档和示例代码
使用这个版本,Yii 现在在代码编写和分发的方式上遵循更严格的标准。
代码的文档和可读性至关重要,主要由 PHP-FIG(www.php-fig.org)发布的 PSR-1 和 PSR-2 编码风格指南决定(注意 PSR-2 明确依赖于 PSR-1)。
在 PHPStorm 中,设置代码风格相当容易。或者,你可以使用 PHP_Codesniffer (github.com/squizlabs/PHP_CodeSniffer)来完成相同的任务并验证你的代码:
随意浏览代码并检查其功能。除了使用 PHP 5.4 语法糖之外,它与 Yii 1 示例应用程序并没有太大不同。
定义我们的工作策略
现在我们已经知道了我们将要使用的多数工具,但我们仍然不知道我们将如何使用它们。
让我们看看我们想要实现到 Yii Playground 中的功能,并分析最终应用程序的端到端结构以及我们如何满足我们的质量保证要求。
需要实现的关键功能
根据前几节所看到的,Yii 提供的基座 Web 应用仅包含一个基本的基础设施,你可以从中开始尝试。为了本书的目的,我们将添加一些在现实世界中通常会被客户或项目利益相关者通过简报、讨论和分析后,由内部团队安排开发的功能。
我们将遵循这些步骤,概述为满足预期质量保证水平所需进行的必要工作。
如前所述,测试的目标首先是确保我们产生的代码符合预期的需求。通常情况下,我们不会测试代码之外的内容,但这里存在例外,这实际上归结为第三方代码的行为、整体质量和可靠性。
我们的目标是修改基本应用,以便能够从模式窗口登录。
一旦确定了业务需求,如果需要,我们会将这个功能分解为子功能。
实际上,我们将采取的路径,关于如何实现模式窗口及其底层基础设施,相当重要。
从客户端视角控制窗口的代码需要与后端通信以验证和认证用户。在非常基本的层面上,这可以通过调整已经存在的处理登录过程的控制器来实现。
但我们可以做得更好。我们可以决定在不改变现有系统的情况下推出新的登录系统,从而避免引入可能影响用户的破坏性变更。如果由于某种原因,一个错误滑过了我们的控制,我们只需禁用新功能,同时仍然允许用户登录系统。
这个特定功能还引发了一系列隐含的要求,例如我们代码的安全性、可移植性以及与现有和即将到来的功能的集成。我们希望客户端的用户登录应用尽可能自包含和可重用。后端认证系统也是如此。
提出的方法如下,包括我们需要满足的高级保证标准,这将更详细地概述实施工作时的工作范围,另一方面,将帮助我们创建所需的测试:
- 
用户 REST 接口用于认证用户。 
- 
模式登录窗口。 
用户认证 REST 接口
REST 接口将定义一些易于使用的应用程序入口点。然后 URL 将具有/resource/id/operation的语法。
GET 操作检索信息,POST 操作将存储信息。例如,通过 POST user/login 进行登录,通过 POST user/logout 进行注销,如果用户已登录,通过 POST user/update 更新一些字段,以及通过 GET user/details 显示用户信息。
所需的通信将使用 JSON 进行。
从模态窗口进行用户登录
现在让我们将我们用 REST 接口所做的工作拼凑起来,并编写打开模态窗口、验证表单、将登录凭据传达给后端以及保持用户登录直到浏览器窗口关闭的 JavaScript 代码。
如前所述,代码需要是自包含和可移植的,出于安全原因,它将在任何时刻都不会处理任何敏感信息,如实际的认证。
为我们的目的引入测试
现在我们已经定义了要做什么,我们需要讨论需要什么样的测试以及我们想要测试多少,这基于我们在第一章中概述的方法,即《测试心态》。
我们将涵盖以下测试领域:
- 
单元测试:这是为了实现隔离组件测试 
- 
集成测试:这是为了确保各个组件能良好地协同工作 
- 
验收测试:这些是从用户角度最相关的测试类型,因为它们试图满足最初定义的正确要求。 
显然,如果我们不知道应用程序的结构,就很难理解我们将要承受什么样的工作。
因此,在定义实际测试之前,我们需要开始将应用程序分解成几个模块,并从架构的角度来审视其结构。
进行架构分解有许多方法,有些可能更严格和详细,使用文本列表,而有些可能最终只是一个使用图表的粗略草图。这很大程度上取决于应用程序的大小和复杂性,在我们的情况下,一个图表似乎更适合我们的目的。
我们需要记住,我们始终希望平衡在这些初始阶段投入的努力和时间与任何给定时刻所需的详细程度。例如,我们可能不知道模态窗口登录将如何与应用程序的其余部分交互,我们是否需要开发一个比我们开始时更复杂的用户模型,或者将其拆分成不同的组件以提供前端的不同功能,或者这超出了我们想要做的工作范围,我们可以以自包含的方式完成它。
此外,图表可能会遗漏一些小细节,我们可能会忘记测试或在评估我们的测试计划时考虑。例如,我们应用程序的 JavaScript 部分可能包括几个小型实用函数集,这些函数集应被视为独立的模块,以便于管理和重用。

我们应用程序结构的部分视图
作为解决这些问题的方法,在接近特定功能的开发时,重新审视软件模块的结构及其自身的分解总是明智的。这一点我们将在下一章中详细看到。
在前面的图表中,我们可以看到我们的应用程序主要由三个主要区域组成,从底部开始:数据存储系统(数据库)、表示数据的模型和功能部分(应用程序的视图/控制器部分)。在所有这些之上是我们的主要交互部分,即用户浏览器。这并不代表整个应用程序,而只是我们将要工作的特定区域。
正如我们之前看到的,单元测试的目标是测试应用程序的原子部分,例如一个类或一组相关的函数:它们的目的是要小和隔离,这意味着它们应该没有外部依赖。请记住,在 Web 开发中实现完全隔离的测试是困难的,实际上我们不允许触及我们基础设施的某些部分,例如数据库交互。这些测试在谷歌的内部术语中实际上被称为小型测试,这立即表明了它们的范围和运行所需的时间。
注意
谷歌是目前公开知名的公司之一,它们将测试视为其核心价值观之一。他们的方法不断使用形容词来区分测试的类型。
想要了解更多关于谷歌测试方式的信息,你可能对《如何谷歌测试软件》这本书感兴趣,作者为Addison Wesley,作者是James Whittaker、Jason Arbon和Jeff Carollo。
在我们的应用程序中,单元测试可以表示如下:

单元测试覆盖率的图形表示
一个实际的例子是我们将要创建的用户模型,正如之前所述,我们可能还有其他单元测试想要编写,例如在前端 JavaScript 层中,如果我们处理的是一个客户端应用程序,其中部分业务逻辑位于用户浏览器中。
只需记住,覆盖用户模型的测试不应使用任何外部依赖(例如,外部助手,如安全模块),其次,它们可以避免触及我们没有控制的框架的部分,特别是那些可能已经被其他测试覆盖的部分。
当更多地关注全局图景时,我们现在可以看到事物是如何堆叠和相互作用的。在集成测试中,我们可能需要使用模拟和伪造,但这并不强烈推荐,因为它们通常用于单元测试。在谷歌的术语中,这些测试被称为中等测试,因为它们在执行时需要更多的时间,并且在某些情况下也容易开发。

集成测试覆盖率的图形表示。
最后的拼图碎片是验收测试,如下所示:

验收测试的图形概述。
验收测试类似于系统测试(或端到端测试),但它们针对的是用户,而不是从工程角度整体系统的一致性。验收测试接近于可能的真实世界使用:这些测试需要确保所有组件都能良好地协同工作,并满足最初定义的验收标准,即具体行动,概述用户与应用程序的交互。
验收标准是我们之前在概述我们的功能时定义的:用户应该能够使用模态窗口登录。
我故意避免使用业务领域语言,因为我们希望在这个初始部分尽可能保持广泛,相反,我们将在稍后深入探讨。
在谷歌,验收(和端到端)测试也被称为大型或巨大测试,因为它们需要更多的时间和资源来实现和执行。它们还需要一个能够模拟真实世界场景的基础设施,这可能并不容易设置。正因为如此,创建边缘情况可能相当困难,因为这意味着我们将只测试定义的场景和我们认为对我们正在测试的区域有意义的任何特定案例。
在我们的情况下,这可能是“当用户使用错误的凭据时,将收到错误信息。”
再次强调,我们将在本书的后面部分具体探讨这些细节。
自上而下方法与自下而上方法的比较
重要的是要重申,行为驱动开发(BDD)是为了改进测试驱动开发(TDD)而创建的,而且是一个相当重要的改进。它提供了一个更好、更灵活的语言来定义验收标准,这也有助于定义所需的测试范围。
我们有两种方式来定义我们的测试策略和测试计划:使用自下而上(或外部-内部)或自上而下(或内部-外部)的方法,如下面的图所示:

不同尺寸测试及其益处的比较。
对于机构和初创公司来说,在试图建立和改进他们的质量保证(QA)时,从底部开始,实现单元测试并试图获得良好的覆盖率,这并不新鲜。
鼓励使用 TDD(测试驱动开发),实际上它是进入测试心态的第一步,通过先编写测试,然后经历红色、绿色和重构阶段。但它的唯一焦点在于代码,而确保它们覆盖正确数量的代码的责任则落在开发者身上。
单元测试将帮助你专注于应用程序的小而原子化的部分,而测试由于执行速度较快,将帮助你频繁地发现错误并提高开发代码的质量。你的架构和设计技能也将显著提高。
在某个时候,你会发现还有一些东西没有被测试到。随着项目的增长,手动和探索性测试的数量也会随之增长。
集成测试可以帮助你减轻这个问题,但请避免产生大量的集成测试:这些测试可能会迅速变得脆弱且难以维护,尤其是在外部依赖可能变得不同步的情况下。
接受测试将保持一切井然有序,并消除在手动测试时可以执行的重复性任务的需求。再次强调,接受测试不是探索性测试的替代品,而应专注于定义的接受标准。
如你所想,自上而下的方法给你以下优势:
- 
一个完整的解决方案,具有足够的覆盖率 
- 
对测试基础设施的清晰全景图 
- 
努力与开发、测试之间的良好平衡 
- 
最重要的是,对系统稳固性的信心,如果不是坚如磐石的话。 
要测试什么,不要测试什么
测试覆盖率的分布可能会是 100%、20%、10%,分别对应单元测试、集成测试和接受测试。在面向用户的项目中,集成和接受测试的百分比可能会相当高。
在这个背景下,理解代码覆盖率的含义尤为重要。
如果你还没有,你可能会遇到一些软件工程师,他们会说服你 100%的覆盖率是必不可少的,没有达到它是一种你必须在整个项目中承担的耻辱,低头看着地面因为你不是一个值得尊敬的开发者。
实现全面覆盖是一个崇高的目标,这正是我们试图达到的目标,但我们也需要现实主义者,正如之前所强调的,理解在许多情况下这是不可能的。
“要测试什么”的问题,或者说测试的范围,由我们将要开发的每个功能的接受标准来定义。
使用自上而下的方法,我们还将能够突出哪些部分需要集成测试,同时试图实现单元测试的 100%。
主测试计划
在这项初步规划工作的最后,你将拥有定义主测试计划所需的一切。
主测试计划是记录需要测试的范围和细节的统一方式。
您不需要过于正式,也没有特定的要求或程序需要遵循,除非您在一家大公司工作,在那里,它被认为是项目开始时由利益相关者签署的可交付成果。
在我们的情况下,它将大致由以下内容定义:
- 
用户 API 实现: - 
尽可能地进行单元测试(目标为 100%,但在某些情况下,60%到 70%被认为是可接受的) 
- 
功能测试以覆盖应用程序的所有入口点 
- 
明确定义的边缘情况——不良参数和/或请求(例如,GET 而不是 POST)作为客户端错误,以及服务器端错误处理(50*错误和类似) 
 
- 
- 
从模态窗口进行用户登录: - 
功能测试以确保我们得到正确的标记 
- 
明确定义的边缘情况——例如,未指定电子邮件,没有 Gravatar 设置的电子邮件 
- 
接受测试——用户点击登录按钮,模态窗口显示,用户登录,用户看到自己已登录;用户登录后,点击注销按钮,用户看到自己已注销 
 
- 
如您所想象,测试计划应该是一份与项目共存并活的文档,在引入新功能或更改其他功能时,根据需要扩展和修改。这一要求决定了如果想要保持一个足够简单以便在短时间内(最多 10 分钟)更新的规范文档,并且一眼就能知道每个组件和功能所隐含的风险和重要性,应遵守的一些约束。
如果您想了解更多关于这个主题的内容,我强烈建议您从属性-组件-能力(ACC)开始阅读,请参阅code.google.com/p/test-analytics/wiki/AccExplained。
ACC 与风险评估和缓解相结合。通过将您的组件、它们的相关能力(或功能)以及它们应提供的属性(如“安全”、“稳定”、“优雅”等)放在一个网格中,您可以立即了解您应该将测试注意力集中在哪里。对于每一行,您可以给出一个风险值,相对于其他功能。我们希望保持这个值的相对性,以避免使其过于难以计算,同时也因为在这个上下文中它是有意义的。
摘要
在本章中,您看到了许多重要的事情,这些是我们将在下一章中工作的基础,也是从更广泛的角度进行测试的基础:您学习了我们工作流程和环境设置的重要性,您看到了如何使用 Composer 以及如何用它来安装 Yii,最后,我们已经详细介绍了第一章中提到的概念,“测试心态”,并将它们具体化,应用到我们的特定应用程序和将要实现的功能上。
现在,在我们深入实际应用实现之前,我们首先需要了解测试套件Codeception,它所使用的术语,以及它将提供的各种功能,这些功能我们将在接下来的章节中使用。
第三章。进入 Codeception
在上一章讨论的 Yii 2 安装之后,在这一章中,我们将介绍 Codeception 套件的安装(codeception.com)并遍历文件夹结构,描述 Codeception 是如何工作的,它的扩展,模块化,语法以及使用的术语。
我们需要对其概念和细节有一个很好的掌握,因为 Codeception 将成为我们在本书剩余部分与测试交互的主要工具。在这一章中,我们将涵盖以下主题:
- 
开始使用 Codeception 
- 
在 Yii 2 中安装 Codeception 
- 
在 Codeception 中找到你的路径 
- 
与 Codeception 交互 
注意
请记住,当 Yii 2 达到稳定版本时(可能是在本书发布之后),其文件夹结构可能会发生变化,以及用于组织测试的结构。始终尝试做笔记并理解你所看到的内容,因为 Codeception 的工作方式和与 Yii 的交互方式不会发生重大变化,如果不是改善的话。
开始使用 Codeception
并非每个人都接触过测试。真正接触过的人都知道他们所使用的测试工具的怪癖和限制。有些可能比其他更有效率,但在任何情况下,你都必须依赖于你所面对的情况:遗留代码、难以测试的架构、没有自动化、工具没有支持,以及其他设置问题,仅举几例。只有某些公司,因为它们拥有正确的技能集或预算,才会投资于测试,但大多数公司没有能力看到质量保证的重要性。在让开发者对自己的代码负责并进行测试之后,建立测试基础设施和工具是紧接着的下一步。
即使在编程世界中,测试并不是什么特别新的东西,PHP 在这方面一直有一个弱点。它的历史并不是一个纯种编程语言,没有所有那些细微的细节,而且 PHP 直到最近才找到了一个更好的位置,开始受到更多的重视。
因此,唯一且最重要的工具就是 PHPUnit,它是在 10 年前,2004 年发布的,归功于 Sebastian Bergmann 的努力。
PHPUnit 曾经是,有时至今仍然难以掌握和理解。它需要时间和投入,尤其是如果你是从非测试经验过来的。PHPUnit 仅仅提供了一个低级框架来实施单元测试,以及在一定范围内,集成测试,并在需要时能够创建模拟和伪造对象。
尽管它仍然是发现 bug 的最快方式,但鉴于我们在前几章中看到的限制,它并没有涵盖所有内容,使用它来创建大型集成测试最终将是一项几乎不可能完成的任务。
此外,从 3.7 版本开始,PHPUnit 切换到不同的自动加载机制,并远离了 PEAR,这导致了许多头痛的问题,使得大多数安装变得无法使用。
自那时以来开发的其他工具大多来自其他环境和需求,编程语言和框架。其中一些工具非常强大且构建良好,但它们带来了自己声明测试和与应用程序交互的方式,一套规则和配置细节。
一个模块化框架,而不仅仅是另一个工具
显然,掌握所有这些工具需要一定的理解,而且学习曲线并不保证在所有工具中都是相同的。
那么,如果这是当前的格局,为什么还要创建另一个工具,如果你最终会陷入我们之前所处的相同境地呢?
好吧,关于 Codeception 需要理解的最重要的事情之一是,它不仅仅是一个工具,而是一个完整的栈,正如 Codeception 网站所注明的,一套框架,或者如果你愿意更元地看待它,一个框架的框架。
Codeception 通过尽可能使用相同的语义和逻辑来设计不同类型的测试,从而提供了一种使整个测试基础设施更加一致和易于接近的方法。
阐述 Codeception 背后的概念
Codeception 的创建是基于以下基本概念:
- 
易于阅读:通过使用接近自然语言的声明性语法,测试可以很容易地阅读和解释,这使得它们成为用作应用程序文档的理想候选者。任何接近项目的利益相关者和工程师都可以确保测试被正确编写并覆盖所需的场景,而无需了解任何特殊术语。它还可以从代码测试用例生成 BDD 风格的测试场景。 
- 
易于编写:正如我们之前强调的,每个测试框架都使用自己的语法或语言来编写测试,这导致在从一个套件切换到另一个套件时存在一定程度的难度,而没有考虑到每个套件的学习曲线。Codeception 试图通过使用一种常见的声明性语言来弥合这种知识差距。此外,抽象提供了一个舒适的环境,使得维护变得简单。 
- 
易于调试:Codeception 天生具有在不乱动配置文件或在你代码周围随机使用 print_r的情况下查看幕后情况的能力。
在所有这些之上,Codeception 还考虑了模块化和可扩展性,这使得组织代码变得简单,同时也在测试中促进了代码的重用。
但让我们更详细地看看 Codeception 提供了什么。
测试类型
正如我们所见,Codeception 提供了三种基本的测试类型:
- 
单元测试 
- 
功能测试 
- 
接受测试 
每一个都包含在其自己的文件夹中,你可以在这里找到所需的一切,从配置和实际测试到任何有价值的信息,例如夹具、数据库快照或要提供给测试的特定数据。
为了开始编写测试,你需要初始化所有必要的类,这将允许你运行测试,你可以通过使用带有build参数的codecept来做到这一点:
$ cd tests
$ ../vendor/bin/codecept build
Building Actor classes for suites: functional, acceptance, unit
\FunctionalTester includes modules: Filesystem, Yii2
FunctionalTester.php generated successfully. 61 methods added
\AcceptanceTester includes modules: PhpBrowser
AcceptanceTester.php generated successfully. 47 methods added
UnitTester includes modules:
UnitTester.php generated successfully. 0 methods added
$
注意
每次当你修改 Codeception 拥有的任何配置文件,添加或删除任何模块时,都需要运行codecept build命令,换句话说,每次你修改/tests文件夹中可用的.suite.yml文件中的任何内容时。
你可能已经注意到的前一个输出中存在一个非常独特的测试类命名系统。
Codeception 引入了被 Yii 术语重命名为测试者的Guys,如下所示:
- 
AcceptanceTester:这用于验收测试
- 
FunctionalTester:这用于功能测试
- 
UnitTester:这用于单元测试
这些将成为你与(大多数)测试的主要交互点,我们将看到原因。通过使用这样的命名法,Codeception 将注意力从代码本身转移到那些将要执行你将要编写的测试的人。
这样我们就会更加熟练地以 BDD 思维模式思考,而不是试图找出所有可能被覆盖的解决方案,同时失去我们试图实现的目标的焦点。
再次强调,BDD 比 TDD 是一个改进,因为它以更详细的方式声明了需要测试的内容以及不需要测试的内容。
AcceptanceTester
AcceptanceTester可以看作是一个对所使用的技术一无所知的人,试图验证最初定义的验收标准。
如果我们想要以前定义的验收测试以更标准化的 BDD 方式进行重写,我们需要记住所谓的用户故事的结构。故事应该有一个清晰的标题,一个简短介绍,指定参与获得一定结果或效果的角色,以及这将反映的价值。随后,我们需要指定各种场景或验收标准,这些通过概述初始场景、触发事件和预期结果在一个或多个子句中定义。
让我们讨论使用模态窗口进行登录,这是我们将在应用程序中实现的两个功能之一。
故事标题 – 成功的用户登录
我,作为一个验收测试者,希望从任何页面登录到应用程序。
- 
场景 1:从主页登录 - 
我在主页上。 
- 
我点击登录链接。 
- 
我输入我的用户名。 
- 
我输入我的密码。 
- 
我点击提交。 
- 
登录链接现在显示为“注销 (<用户名>)”,而我仍然在主页上。 
 
- 
- 
场景 2:从次要页面登录 - 
我在二级页面上。 
- 
我点击了登录链接。 
- 
我输入我的用户名。 
- 
我输入我的密码。 
- 
我按下提交按钮。 
- 
登录链接现在显示为“注销 (<用户名>)”,我仍然在二级页面上。 
 
- 
正如你可能注意到的,我正在将前面的例子限制在成功案例中。还有更多,我们将在本书进一步实现实际功能之前,更详细地讨论所有相关的故事和场景。
前面的故事可以立即翻译成以下代码:
// SuccessfulLoginAcceptanceTest.php
$I = new AcceptanceTester($scenario);
$I->wantTo("login into the application from any page");
// scenario 1
$I->amOnPage("/");
$I->click("login");
$I->fillField("username", $username);
$I->fillField("password", $password);
$I->click("submit");
$I->canSee("logout (".$username.")");
$I->seeInCurrentUrl("/");
// scenario 2
$I->amOnPage("/");
$I->click("about");
$I->seeLink("login");
$I->click("login");
$I->fillField("username", $username);
$I->fillField("password", $password);
$I->click("submit");
$I->canSee("logout (".$username.")");
$I->amOnPage("about");
如你所见,这完全直截了当且易于阅读,以至于业务中的任何人都能编写任何案例场景(这是一个夸张的说法,但你应该明白这个意思)。
显然,我们需要理解的是AcceptanceTester能够做什么:由codecept build命令生成的类可以在tests/codeception/acceptance/AcceptanceTester.php中找到,其中包含所有可用方法。如果你需要了解如何断言特定条件或对页面执行操作,你可能想浏览一下。codeception.com/docs/04-AcceptanceTests上的在线文档也会以更易读的方式提供这些信息。
不要忘记,在最后AcceptanceTester只是一个类的名称,它在 YAML 文件中定义为特定测试类型:
$ grep class tests/codeception/acceptance.suite.yml
class_name: AcceptanceTester
验收测试是测试的最高级别,作为一种高级用户导向的集成测试。正因为如此,验收测试最终会使用几乎真实的测试环境,其中不需要任何模拟或伪造。显然,我们需要某种初始状态,我们可以回退到这个状态,尤其是如果我们执行的动作会修改数据库的状态。
根据 Codeception 文档,我们本可以使用数据库快照在每次测试开始时加载。不幸的是,我没有找到这个功能正常工作的。所以后来,我们将被迫使用固定值。然后一切都会更有意义。
当我们编写验收测试时,我们还将探索你可以与之一起使用的各种模块,例如 PHPBrowser 和 Selenium WebDriver 及其相关配置选项。
FunctionalTester
正如我们之前所说的,FunctionalTester代表我们在处理功能测试时的角色。
你可以将功能测试视为从更高角度利用实现正确性的方式。
实现功能测试的方式与验收测试的结构相同,以至于我们为 Codeception 中的验收测试编写的代码大多数时候可以轻松地与功能测试的代码交换,所以你可能自己会问:“差异在哪里?”
必须注意的是,功能测试的概念是 Codeception 的特定概念,可以被认为是与应用程序中中间层集成测试几乎相同。
最重要的是,功能测试不需要 web 服务器来运行,并且被称为 无头测试:因此,它们不仅比验收测试更快,而且由于在特定环境中运行的所有影响,它们也更不“真实”。并且默认情况下由基本应用程序提供的验收测试几乎与功能测试相同。
由于这个原因,并且正如在第二章中强调的,“为测试做准备”,我们将最终拥有更多的功能测试,这些测试将涵盖我们应用程序特定部分更多的用例。
FunctionalTester 以某种方式设置了 $_GET、$_POST 和 $_REQUEST 变量,并在测试中运行应用程序。因此,Codeception 随带模块,允许它与底层框架交互,无论是 Symfony2、Laravel4、Zend,还是在我们的案例中,Yii 2。
在配置文件中,你会注意到为 Yii 2 启用的模块:
# tests/functional.suite.yml
class_name: FunctionalTester
modules:
    enabled:
      - Filesystem
      - Yii2
# ...
FunctionalTester 对所使用的技术的理解更好,尽管他可能对将要测试的各种功能如何详细实现一无所知;他只知道规格说明。
这正是功能测试应由开发者或任何接近了解如何将各种功能公开给大众的人拥有或编写的一个完美案例。
通过 API 暴露的 REST 应用程序的基本功能也将进行大量测试,在这种情况下,我们将有以下场景:
- 
我可以使用 POST 发送正确的认证数据,并将收到包含成功认证的 JSON 
- 
我可以使用 POST 发送错误的认证数据,并将收到包含失败认证的 JSON 
- 
经过正确的认证后,我可以使用 GET 来检索用户数据 
- 
经过正确的认证后,当进行 GET 请求以获取用户信息并指出是我时,我会收到一个错误信息 
- 
我可以使用 POST 发送我的更新后的哈希密码 
- 
没有正确的认证,我无法执行前面的任何操作 
需要记住的最重要的事情是,在每个测试结束时,你有责任保持内存清洁:PHP 应用程序在处理请求后不会终止。在同一个内存容器中发生的所有请求都不是隔离的。
小贴士
如果你看到你的测试在某些未知原因下失败,而它们本不应该失败,尝试单独执行一个测试。
单元测试器
我将UnitTester放在最后,因为它是一个非常特殊的存在。据我们所知,到目前为止,Codeception 可能已经使用了一些其他框架来覆盖单元测试,我们相当确信 PHPUnit 是唯一能够实现这一目标的候选框架。如果你已经使用过 PHPUnit,你会记得学习曲线以及理解其语法和执行最简单任务时的初始问题。
我发现大多数开发者对 PHPUnit 有着爱恨交加的关系:要么你学会了它的语法,要么你花了一半的时间查阅手册才能找到一点线索。我不会责怪你。
我们将看到,如果我们遇到测试难题时,Codeception 将再次伸出援手:记住,这些单元测试是我们将要测试的工作中最简单、最原子化的部分。与之相伴的是集成测试,它们覆盖了不同组件的交互,很可能是使用模拟数据和固定值。
正如我们在第四章中将要看到的,使用 PHPUnit 进行隔离组件测试,如果你习惯于使用 PHPUnit,你不会在编写测试时遇到任何特别的问题;否则,你可以使用UnitTester并通过使用 Verify 和 Specify 语法来实现相同的测试。
UnitTester假设对签名和基础设施以及框架的工作有深入的理解,因此这些测试可以被认为是测试的基石。
与其他任何类型的测试相比,它们运行得超级快,而且它们也应该相对容易编写。
你可以从足够简单的断言开始,在需要处理固定值之前转向数据提供者。更多内容将在下一章中介绍。
Codeception 提供的其他功能
除了测试类型之外,Codeception 还提供了一些辅助工具,帮助你组织、模块化并扩展你的测试代码。
正如我们所看到的,功能测试和验收测试具有非常简单和声明性的结构,所有与特定验收标准相关的代码和场景都保存在同一文件中,并且这些测试是线性执行的。
在大多数情况下,就像在我们的例子中一样,这已经足够好了,但是当你的代码开始增长,组件和功能的数量变得越来越复杂时,执行验收或功能测试的场景列表和步骤可能会相当长。
此外,一些测试可能最终会依赖于其他测试,因此你可能需要开始考虑编写更紧凑的场景,并在你的测试中推广代码重用,或者将测试拆分为两个或更多个测试。
如果你觉得你的代码需要更好的组织结构,你可能想要开始生成CEST类而不是普通的测试,这些普通的测试被称为CEPT。
CEST类将所有场景作为一个方法分组,如下面的代码片段所示:
<?php
// SuccessfulLoginCest.php
class SuccessfulLoginCest
{
    public function _before(\Codeception\Event\TestEvent $event) {}
    Codeception\Event\TestEvent $event 
     public function _fail(Codeception\Event\TestEvent $event) {}
    // tests
    public function loginIntoTheApplicationTest(\AcceptanceTester $I)
    {
        $I->wantTo("login into the application from any page");
        $I->amOnPage("/");
        $I->click("login");
        $I->fillField("username", $username);
        $I->fillField("password", $password);
        $I->click("submit");
        $I->canSee("logout (".$username.")");
        $I->seeInCurrentUrl("/");
        // ...
    }
}
?>
任何不以下划线开头的函数都被视为测试,而保留方法_before和_after分别在测试类中测试列表的开始和结束时执行,而_fail方法在失败时用作清理方法。
仅此可能还不够,你可以使用文档注释来创建可重用的代码,在测试前后使用@before <methodName>和@after <methodName>来运行这些代码。
你也可以更加严格,要求在运行任何其他测试之前先通过特定的测试,可以使用文档注释@depends <methodName>来实现。
我们将使用其中的一些文档注释,但在开始安装 Codeception 之前,我想强调两个额外的功能:PageObjects和StepObjects。
- 
PageObject 是测试自动化工程师中的一种常见模式。它将网页表示为一个类,其中其 DOM 元素是类的属性,而方法则提供与页面的一些基本交互。使用 PageObjects 的主要原因是为了避免在测试中硬编码 CSS 和 XPATH 定位器。Yii 在 /tests/codeception/_pages中提供了一些 PageObjects 的示例实现。
- 
StepObject 是提高测试中代码复用性的另一种方式:它将定义一些可以在多个测试中使用的常见操作。与 PageObjects 一起,StepObjects 可以变得相当强大。StepObject 扩展了 Tester类,并可以用来与 PageObject 交互。这样,你的测试将减少对特定实现的依赖,并在标记和与页面中每个组件交互的方式发生变化时节省重构的成本。
为了将来参考,你可以在 Codeception 文档的“高级使用”部分找到所有这些内容,包括其他功能,如分组和一个交互式控制台,你可以用它来在运行时测试你的场景。codeception.com/docs/07-AdvancedUsage
在 Yii 2 中安装 Codeception
现在我们已经看到了理论上我们可以用 Codeception 做什么,让我们继续并安装它。
Yii 自带了它的 Codeception 扩展,该扩展提供了一个单元测试的基础类(yii\codeception\TestCase),一个需要数据库交互的测试类(yii\codeception\DbTestCase),以及 Codeception 页面对象的基础类(yii\codeception\BasePage)。
如同往常,我们首选的方法是使用 Composer:
$ composer require "codeception/codeception: 2.0.*" --prefer-dist --dev
使用–prefer-dist有特定的原因;如果你使用 Git,你可能会因为 Git 子模块而陷入困境(但再次排除/vendor文件夹应该解决这些问题)。为了避免每次使用 Composer 时都重复,只需将以下内容添加到你的composer.json文件中:
// composer.json
{
    "config": {
        "preferred-install": "dist"
    }
}
此外,请记住,如果您已手动将组件添加到您的 composer.json 文件中,则使用 composer install 将不会工作,因为它会将其视为不匹配并引发错误。要安装包,您需要运行 composer update,无论是针对您安装的所有包,还是专门针对此包:
$ composer update codeception/codeception
如前所述,您可能还对两个额外的包 codeception/specify 和 codeception/verify 感兴趣。这两个包提供了一层额外的抽象,允许您使用面向业务的语言编写更易于阅读的测试,接近 BDD 定义的外观。
您的 composer.json 文件将包含以下内容:
// composer.json
{
    "require-dev": {
        "yiisoft/yii2-codeception": "*",
        "yiisoft/yii2-debug": "*",
        "yiisoft/yii2-gii": "*",
        "codeception/codeception": "2.0.*",
        "codeception/specify": "*",
        "codeception/verify": "*"
    }
}
在 Codeception 中找到您的路径
我们的所有测试都位于 /tests/codeception 文件夹中。在 2.0 版本中,此文件夹直接包含所有套件及其所需的配置文件以及 Codeception 本身。以下配置步骤基于此结构。
通过列出 /tests 文件夹的内容,我们将看到主要的 Codeception 配置文件,而每个单独的套件在 /tests/codeception 文件夹内都有自己的配置文件,我们可以相应地修改以覆盖或进一步配置我们的测试。从我们的 /tests 文件夹开始,以下是我们将处理的配置文件:
- 
codeception.yml:这是用于所有套件和 Codeception 的通用配置
- 
codeception/acceptance.suite.yml:这是用于验收测试的
- 
codeception/functional.suite.yml:这是用于功能测试的
- 
codeception/unit.suite.yml:这是用于单元测试的
与这些文件一起,还有一些额外的配置文件,这些文件主要用于 Yii:_bootstrap.php 和 config/ 文件夹的内容。一些文件使用下划线前缀仅是为了让 Codeception 忽略它们。如果您需要在各种套件文件夹中创建新文件,请记住这一点。
在 /tests/codeception 文件夹内,您将找到包含每个单独测试套件测试的文件夹,unit/、functional/ 和 acceptance/。每个文件夹都将包含套件的自定义 _bootstrap.php 文件、实际测试以及其他用于示例的文件夹。
/tests/codeception 中包含的其他几个文件夹如下:
- 
bin/:它包含用于对测试数据库运行迁移的测试-boundyiiCLI 命令,我们将使用它。
- 
_data/:它包含数据库的快照(dump.sql),通常用于将数据库恢复到初始状态以进行验收测试,但它可以包含任何内容,例如,此文件夹将由 Codeception 使用,如果您希望它从您用纯英语创建的测试中生成(并发布)各种场景(运行codecept help generate:scenarios命令以获取更多信息)。
- 
_output/:这个文件夹将非常有用,因为它将包含在您的验收或功能测试失败时获取的页面输出,这为您提供了另一种检查和理解问题的方式。
- 
_pages/:这是 Codeception 页面对象存储的地方。基本应用程序已经提供了三个页面对象,分别是AboutPage.php、ContactPage.php和LoginPage.php。我们将在稍后进一步探讨这部分,因为它们将证明极其有用,因为它们极大地简化了我们的生活,并促进了代码的模块化和重用。
- 
_support/:这部分用于存放额外的支持文件,目前包含用于用提供的 fixtures 填充数据库的FixtureHelper类。
配置 Codeception
现在,我们基本上应该知道所有配置文件的位置,因此在我们开始与 Codeception 交互并首先运行所有提供的测试以及我们自己的测试之前,我们将审查它们的内容并对其进行调整。
让我们从不同套件的 YAML 配置文件开始。
默认情况下,验收测试配置为使用 PHPBrowser。我们将看到如何调整以使用 Selenium WebDriver,但一般来说,这两个工具都需要至少一个 URL 来访问我们的应用程序:
# tests/codeception/acceptance.suite.yml
class_name: AcceptanceTester
modules:
    enabled:
        - PhpBrowser
    config:
        PhpBrowser:
            url: 'http://basic.yii2.sandbox'
默认 URL 是 http://localhost:8080,当您使用 Vagrant 或 PHP 内置服务器时,您不需要更改它。在先前的示例中,我已经设置了一个自定义域名;为了运行您的测试,这并不是必需的,因为它可能需要额外的配置步骤,而这些步骤通常是不需要的,除非您在一个更大的环境中,并且您的配置稍微复杂一些(例如,如果您的测试是在远程执行的)。我们将在最后一章中看到更多关于这方面的内容。
请注意,您不需要指定入口文件index-test.php,因为您希望 Yii 为您解析路由。
对于我们的功能测试,我们的应用程序的基本 URL 不是必需的,正如我之前指出的。事实上,Codeception 对于我们的功能测试所关心的是应用程序的入口脚本:所有功能都是由 yii2-codeception 包提供的(这个包应该已经预安装在您的应用程序中),因此,在配置文件中,您只有一个指向测试应用程序配置的引用:
# tests/codeception/functional.suite.yml
...
modules:
    config:
        Yii2:
            configFile: 'codeception/config/functional.php'
转到这个文件,我们会发现一开始就设置了一些 $_SERVER 变量:
// tests/codeception/config/functional.php
...
// set correct script paths
$_SERVER['SCRIPT_FILENAME'] = YII_TEST_ENTRY_FILE;
$_SERVER['SCRIPT_NAME'] = YII_TEST_ENTRY_URL;
这两个常量已在 Yii 启动文件中定义:
// tests/codeception/_bootstrap.php
defined('YII_TEST_ENTRY_URL') or define('YII_TEST_ENTRY_URL', parse_url(\Codeception\Configuration::config()['config']['test_entry_url'], PHP_URL_PATH));
defined('YII_TEST_ENTRY_FILE') or define('TEST_TEST_ENTRY_FILE', dirname(__DIR__) . '/web/index-test.php');
换句话说,入口文件始终是位于 /web/index-test.php 的那个,而 URL 可以在主配置文件中进行配置:
// tests/codeception.yml
...
config:
    test_entry_url: https://basic.yii2.sandbox/index-test.php
注意
请记住将主机名调整为您将使用的名称,或者保留默认值,即 localhost:8080。
对于单元测试,没有太多需要配置的,因为 Codeception 只是围绕 PHPUnit 进行封装,而 verify 和 specify 这两个包将直接使用。
剩下的唯一要更新的是数据库的配置:正如我们之前所说的,目前,你只需在/tests/codeception/config/config.php中更新 DSN,就像定义主 Yii 数据库配置一样。
Yii 2 中可用的测试
与 Yii 1 提供的相比,Yii 2 现在为任何测试套件提供了工作测试的示例。这是一件好事,因为它将帮助我们了解如何构建和实现我们的测试。
一旦服务器运行正常并且所有配置都设置妥当,我们就可以运行以下命令:
$ cd tests
$ ../vendor/bin/codecept run
这将运行所有测试并查看它们通过。最后,你将看到一个漂亮的总结:
...
Time: 6.92 seconds, Memory: 35.75Mb
OK (12 tests, 60 assertions)
$
提供的验收和功能测试的测试相当直观;它们基本上确保了四个页面,即主页、关于页面、联系页面和登录页面,在基本应用程序中按预期工作。
这些测试完全相同,唯一的区别是验收测试考虑了您通过 Selenium 运行测试的能力,并包含针对它的特定指令:
// tests/functional/ContactCept.php
...
if (method_exists($I, 'wait')) {
    $I->wait(3); // only for selenium
}
...
这只是一个例子,现在对你来说可能并不重要,因为我们将在第七章中详细探讨 Selenium WebDriver 的工作原理,享受浏览器测试的乐趣。
Yii 2 提供的单元测试与预期不同;它们主要覆盖各种组件之间的集成测试,例如登录表单和联系表单,而将用户测试的实现负担留给了我们。我们将在下一章中实现这一点。
在单元测试中唯一值得注意的事情是,它们使用Specify以更声明性的方式编写单元测试,而不是更常见的 PHPUnit 语法。再次强调,这只是一个语法糖,它可能对你开始时更容易。
与 Codeception 交互
到目前为止,我们已经看到了codecept命令的两个参数:
- 
build: 这个命令用于构建“测试器”以及在使用任何附加模块时所需的任何附加代码
- 
run: 这个命令用于执行测试
有几个参数你可以与run一起使用,我想提醒你注意,因为这些在运行和调试测试时将很有用。run命令的语法如下:
$ vendor/bin/codecept run [options] [suite] [test]
首先,你可以运行特定的测试套件,例如单元测试、验收测试或功能测试,或者更具体地运行单个测试文件,例如:
$ ../vendor/bin/codecept run acceptance LoginCept.php
…
Time: 3.35 seconds, Memory: 13.75Mb
OK (1 test, 5 assertions)
在前面的命令中,你还可以使用--steps选项,这是一种更详细地显示测试运行时所有单个步骤的方法。
或者,你也可以使用--debug选项,它不仅会显示应用程序执行的步骤,还会显示幕后发生的事情,例如向特定 URL 发送数据 POST 请求、页面加载或设置的 cookie 列表。
创建测试
当您编写了测试并看到它们通过时,您可能只会关心这些,但在编写测试之前,您首先需要编写它们。
Codeception 通过在命令行上提供代码生成参数来帮助我们开始:
- 
generate:cept:此命令用于生成 CEPT 测试
- 
generate:cest:此命令用于生成 CEST 测试
- 
generate:phpunit:此命令用于生成 PHPUnit 测试,不包含 Codeception 的附加功能
- 
generate:test:此命令用于生成单元测试
所有的先前参数都需要参数套件名称和要创建的文件名称:
$ ../vendor/bin/codecept generate:cept acceptance ModalLoginCept
Test was created in ModalLoginCept.php
您可以通过运行不带参数的codecept来查看这些命令以及更多命令。
测试数据库上的迁移
我发现特别方便并且我们将广泛使用的一项功能是能够在您的测试数据库上运行与我们为应用程序创建的相同迁移。
注意
迁移这个概念并不仅限于 Yii,您可以在www.yiiframework.com/doc-2.0/guide-db-migrations.html的文档中了解更多相关信息。
在/tests/codeception/bin/文件夹中,您将找到可以用于之前配置的测试数据库的yii CLI 命令行,以运行迁移。
假设您位于项目的根目录,以下命令序列将向您展示如何运行迁移:
$ cd tests/codeception
$ php bin/yii migrate/up
Yii Migration Tool (based on Yii v2.0.0-dev)
Creating migration history table "migration"...done.
No new migration found. Your system is up-to-date.
yii CLI 与位于项目根目录的主程序完全相同,唯一的区别是它将读取测试配置,特别是关于数据库的那部分。
摘要
在本章中,我们体会到了 Codeception 的广度和质量。
我们已经看到了三种测试类型,即单元测试、功能测试和验收测试,这些测试我们将贯穿整本书。我们还接触了一些由工具和 Yii 2 Codeception 模块提供的附加功能。我们学习了如何与之交互、生成测试以及处理调试和保持测试数据库与主应用程序数据库同步。
在下一章中,我们将开始重构我们的User类,首先添加测试,然后逐步通过所有最重要的 PHPUnit 特性。
小贴士
下载示例代码
您可以从www.packtpub.com下载您购买的所有 Packt Publishing 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
第四章:使用 PHPUnit 进行隔离组件测试
在本章中,我们将更深入地探讨 PHPUnit 以及它是如何被 Codeception 处理的。
我们将简要介绍在开始实际测试之前需要进行的更改,然后从那里开始,通过红色、绿色和重构阶段来实现测试和我们的代码,并在需要时进行重构。
我们将介绍基本主题,如隔离测试、组件的集成测试,以及更高级的主题,如 数据提供者。
本章涵盖了以下主题:
- 
理解需要完成的工作 
- 
使用 User模型
- 
实现第一个单元测试 
- 
模型的组件测试 
- 
实现 ActiveRecord类及其方法
- 
看到测试通过 
理解需要完成的工作
在我们的工作范围内,我们将首先讨论 User 模型,如何在 Yii 中实现认证方法,以及它将如何在我们特定的案例中使用。
然后,我们将绘制测试草图以涵盖 User 类的所有可能用途,重构模型,然后努力通过测试。
我们安装的框架当前状态不足以实现我们想要的功能。
如前几章所述,我们将遵循 TDD 方法进行这一部分。
使用 User 模型
让我们先看看 User 模型在 Yii 中的使用方式。
你可以打开位于 /models/User.php 的文件。
首先要注意的是,User 类继承自通用的 Yii Object 类并实现了 IdentityInterface:
// User.php
namespace app\models;
use yii\base\Object;
use yii\web\IdentityInterface;
class User extends Object implements IdentityInterface
{
    // ...
yii\base\Object 类是所有类的父类,它实现了虚拟属性的概念,通过动态调用的 getter 和 setter 来实现,而 yii\web\IdentityInterface 提供了我们需要在我们的类中实现的方法签名,以提供认证机制。
你也会注意到通过私有属性 $users,该模型并没有连接到数据库;相反,它将所有认证数据都存储在类本身中。这是 Yii 开发者有意为之,以便无需额外努力就能使一切正常工作。这不仅减轻了如果你在应用程序中未使用任何认证时的大规模重构问题,而且如果你需要学习认证的工作原理,这也是一个好的起点。
Yii 中的认证并不特别直接,认证用户的大部分机制对我们来说是隐藏的;所以,除非你需要在你应用程序中实现某种程度的健壮性,否则你通常不必过于担心。
相反,重要的是要注意,认证信息被保存在一个对象中,与User模型分开。这种机制提供了一个独立且干净的安保层。从这里,认证状态被保存在一个动态加载的\yii\web\User类型的类中,整个应用程序的生命周期内都可以通过Yii::$app->user访问。例如,要检查用户是否已登录,我们可以执行以下操作:
use Yii;
// check the user is logged in
if (!Yii::$app->user->isGuest) {
    // do something
}
这实际上在几个视图中都有使用,并且显然与 Yii 1 中的情况相似。
类似于User类中的$users变量,既有静态又有私有属性,可能会使测试我们的类变得非常困难,有时甚至不可能。
这是我们需要完全修改其定义方式的原因之一,因此User类从ActiveRecord类扩展,并直接与数据库交互。这样,我们可以利用我们可以控制的固定值,而无需在我们的测试中硬编码配置设置或参数,这可能导致测试难以维护,甚至毫无意义。
实现第一个单元测试
Yii 为我们提供了一个空的UserTest类,因此我们将从这里开始工作。前往tests/codeception/unit/models/并打开UserTest.php文件。
因此,我们现在的问题是:我们在这个阶段要实现什么?好吧,一旦我们理解了单元测试的目标,答案将会非常简单。
单元测试,以及功能测试和验收测试,是一个黑盒测试系统:测试将简单地使用对象提供的接口,并确保输出符合预期。由于实现细节的微小变化或根本性的变化并不影响接口,因此只要接口保持不变,测试仍然应该通过。
白盒测试,通过代码覆盖率提供,将确保我们覆盖了代码的所有可能分支。我们将在第八章分析测试信息中进一步讨论这一点。
小贴士
单元测试还提供了对用例的支持,这可以有效地记录你的接口的使用,无论是团队内部还是外部的人。
因此,无论我们是从头开始,添加新的测试,还是重构一些现有的测试,我们有一些规则可以帮助我们尽可能多地覆盖测试。
- 
修复现有的损坏测试(如果不涉及我们的代码或工作最终超出了范围,则提出相关票据)。 
- 
为新的最小代码单元实现测试。 
- 
使测试相互独立。 
- 
正确命名测试。我开始使用长名称来准确理解可能出错的地方,例如, testMyMethodThrowsAnExceptionWhenInvokedWithNoParameters();你可以清楚地使用任何其他命名标准,例如,使用_作为单词分隔符而不是驼峰式;目的是保持可读性和可维护性。
我们还希望有一些基本规则,可以保证 360 度的使用概述,这样我们就可以看到如何使用我们的组件,并立即发现其使用是否被禁止、无用或其他情况。这些规则如下:
- 
覆盖类/方法/什么的正常使用(正面测试)。 
- 
覆盖你正在测试的任何异常功能,例如,当它返回异常时(换句话说,当它应该失败时)(负面测试)。 
这可能有点令人却步,而且这个第一步可能是我所见证的最困难的一步,无论是对我自己还是对我的同事。无数次的我看到负面测试缺失,造成潜在漏洞和脆弱性的巨大差距。
不要让自己失望;正如我们在第一章《测试心态》中看到的,奖励是无价的。
一旦开始,就把自己当作测试人员,这是确保你发布的代码质量的第一步和最重要的一步,你可以通过提高你对代码的信心来看到你所取得的成就。
对他人代码的关心程度
你将要测试的所有代码并不都是你努力的结果。
当与 Yii 一起工作时,我们将开始测试来自 Yii 本身的代码或集成,或者更有可能的是,在现实世界的项目中,来自团队内部或外部的人。
有时候,可以说你不需要测试超出你范围的东西,有很多原因。但是,了解不测试这些功能所隐含的风险也很重要。
例如,考虑一下User模型的密码验证,我们将在几页后讨论:无法验证保存的密码的可能性是我们需要避免的,因为其风险可能会损害我们应用程序的整体功能,并导致用户无法登录我们的应用程序。
注意
如在第一章《测试心态》和第二章《为测试做准备》中所述,属性-组件-能力(ACC)可能是如果你需要了解你构建的功能相关的风险,你应该开始了解的东西。
在我们具体的情况下,我们的测试将集中在父类和接口提供的功能片段上,例如以下内容:
- 
验证 User模型(这显然是必要的,因为它是立即由save()方法触发的功能)。
- 
将 User模型保存到数据库中。
- 
覆盖我们将从接口实现的基本函数用法。 
以下内容就足够说明,在某些情况下,这可能会超出范围。如果我们从 BDD 视角的更高层次抽象考虑,我们感兴趣的测试将是与 User 类的交互,例如从数据库中读取以及它将被其他组件如何使用。
模型的组件测试
测试模型的验证以及任何进一步的数据操作,直到它到达数据库并返回,是 Yii 确保模型实现了清晰和明确验证规则的基本步骤。这在防止客户端在与系统交互时传递额外或错误的数据时非常有用。
如果你关心安全性,这可能需要你进一步调查,如果你还没有这样做的话。
小贴士
我想强调我们在前面的声明中采取的立场:我们采取的是消费者/客户端视角。在这个特定时刻,我们不知道事情将如何实现,所以最好专注于模型的使用。
因此,让我们回到 /tests/codeception/unit/models/UserTest.php:该文件应该已经存在,它大致是你运行以下命令得到的内容:
$ ../vendor/bin/codecept generate:phpunit unit models/UserTest
Test was created in /var/www/vhosts//htdocs/tests/unit/UserTest.php
目前,如果你运行这个命令,你将得到一个需要稍作修改的测试,以便我们能够使用 Yii 基础设施。特别是,你需要将你的测试扩展的类更改为以下内容:
// tests/codeception/unit/UserTest.php
namespace tests\codeception\unit\models;
use yii\codeception\TestCase;
class UserTest extends TestCase
{
}
换句话说,我们需要使用提供的 \yii\codeception\TestCase 类,而不是 PHPUnit 默认的 \PHPUnit_Framework_TestCase 类。
因此,让我们首先在我们的 tests\codeception\unit\models\UserTest 类中草拟一些测试:
// tests/codeception/unit/models/UserTest.php
public function testValidateReturnsFalseIfParametersAreNotSet() {
    $user = new User;
    $this->assertFalse($user->validate(), "New User should not validate");
}
public function testValidateReturnsTrueIfParametersAreSet() {
    $configurationParams = [
        'username' => 'a valid username',
        'password' => 'a valid password',
        'authkey' => 'a valid authkey'
    ];
    $user = new User($configurationParams);
    $this->assertTrue($user->validate(), "User with set parameters should validate");
}
如你所见,知道要测试什么需要对 Yii 的工作方式有所了解。所以,如果你不知道事情应该如何工作,实际上第一个测试草图完全错误可能是完全正常的。
在前面的代码片段中,我们定义了两个测试;第一个是我们称之为 负面 的,第二个是 正面 的。
小贴士
请注意,向各种断言命令传递第二个参数将有助于你在测试失败时进行调试。编写一个描述性和有意义的消息可以节省时间。
注意
在本书的代码片段中,各种断言方法的第二个参数将不会传递,以使代码片段更加紧凑。
PHPUnit 的测试内容
在我们继续进行其他测试之前,让我们回顾一下到目前为止我们所拥有的内容:测试文件是一个以 <Component>Test 格式命名的类,它收集了我们想要测试的组件的所有测试;类的每个方法是对特定功能的测试,无论是正面还是负面。
类中的每个方法/测试至少应包含一个断言,PHPUnit 提供了一系列断言语句,你可以触发它们来断言实际值与预期值匹配,以及用于期望特定异常的方法。
这些方法由父类 TestCase 提供。你可以在 phpunit.de/manual/current/en/appendixes.assertions.html 获取完整的列表。
一些基本的断言如下:
- 
assertTrue(actualValue)和其对立面assertFalse(...)
- 
assertEquals(expectedValue, actualValue)和其对立面assertNotEquals(...)
- 
assertNull(actualValue)
你的测试结果基于这些方法的输出。你也应尽量避免将一些断言包裹在一个或多个条件中。仔细思考你试图实现的目标以及你实际上在测试的内容。
对于异常,你需要使用一些文档注释:
注意
PHPUnit 广泛使用文档注释来覆盖通常无法通过测试断言实现的功能。
除了我们将要看到的内容之外,还有很多其他功能,例如使用 @depends、@before 和 @after 测试依赖项或使用 @group 进行分组。
你可以使用 phpunit.de/manual/current/en/appendixes.annotations.html 查看你可以使用的完整注释列表。
考虑以下示例:
/**
 * @expectedException yii\base\InvalidParamException
 */
public function 
testValidatePasswordThrowsInvalidParamExceptionIfPasswordIsIncorrect() {
    $user = new User;
    $user->password = 'some password';
    $user->validatePassword('some other password');
}
除了 @expectedException 之外,你还可以使用 @expectedExceptionCode 和 @expectedExceptionMessage,以防你需要确保异常的内容是你期望的。
另一种实现方式是使用 setExpectedException() 方法,当需要处理更复杂的异常情况时,这可能会提供更高层次的可灵活性。
小贴士
虽然非常通用,但在将不同类型传递给具有类型形式参数的方法或尝试使用 @expectedException PHPUnit_Framework_Error 包含一个不存在的文件时,我们也可以期望出现特定于语言的错误。
一旦掌握了你的类、模型和方法的使用方式,PHPUnit 中的断言测试就相当直接。
此外,PHPUnit 还提供了一些巧妙的功能来帮助我们加快测试速度并解决一些复杂性。数据提供者、固定值、存根和模拟将在后续章节和 第五章 中介绍,召唤测试替身。
测试继承自 IdentityInterface 的方法
现在我们知道了启动所需的一切,我们通常会决定实现规则,使 testValidateReturnsTrueIfParametersAreSet() 和 testValidateReturnsTrueIfParametersAreNotSet() 测试通过,尽管在这个场合,似乎继续草拟我们稍后需要实现的其他方法要容易得多,例如 getId()、getAuthKey()、validateAuthKey()、findIdentity() 和 findIdentityByAccessToken(),以及两个已经实现并使用的方法,即 validatePassword() 和 findByUsername(),这两个方法都由 LoginForm 模型使用。
我们可以立即决定去除最简单的方法来覆盖。我们不会使用访问令牌,并且通常,如果我们不是被迫通过接口实现该方法,我们就可以避免这部分。在这种情况下,相反,我们需要整理并最好通过从方法中抛出 NotSupportedException 并期望这种异常来记录缺失的功能:
/**
 * @expectedException yii\base\NotSupportedException
 */
public function testFindIdentityByAccessTokenReturnsTheExpectedObject()
{
    User::findIdentityByAccessToken('anyAccessToken');
}
按照这种方法,我们测试 getId():
public function testGetIdReturnsTheExpectedId() {
    $user = new User();
    $user->id = 2;
    $this->assertEquals($expectedId, $user->getId());
}
我们可以使用完全相同的逻辑来测试 $user->getAuthkey()。
对于 findIdentity(),我们可以做以下操作:
public function testFindIdentityReturnsTheExpectedObject() {
    $expectedAttrs = [
        'username' => 'someone',
        'password' => 'else',
        'authkey' => 'random string'
    ];
    $user = new User($expectedAttrs);
    $this->assertTrue($user->save());
    $expectedAttrs['id'] = $user->id;
    $user = User::findIdentity($expectedAttrs['id']);
    $this->assertNotNull($user);
    $this->assertInstanceOf('yii\web\IdentityInterface', $user);
    $this->assertEquals($expectedAttrs['username'], $user->username);
    $this->assertEquals($expectedAttrs['password'], $user->password);
    $this->assertEquals($expectedAttrs['authkey'], $user->authkey);
}
使用 findIdentity(),我们想确保返回的对象是我们期望的,因此我们的断言确保:
- 
已检索到一条记录 
- 
它属于正确的类别( IdentityInterface是在认证时与用户交互的大多数方法期望它成为的类型)
- 
它包含我们在创建时传递的内容 
使用数据提供者以获得更多灵活性
findIdentity() 的负面测试相当直接:
public function testFindIdentityReturnsNullIfUserIsNotFound() {
    $this->assertNull(User::findIdentity(-1));
}
实现这样的测试可能会引起一些疑问,因为我们硬编码了一个值 -1,这可能不代表任何实际的真实世界案例。
最好的方法就是使用 数据提供者,它可以为我们提供一系列值,这些值应该能让测试通过。这非常方便,因为我们可以在进行回归测试现有功能时定制边缘情况:
/**
 * @dataProvider nonExistingIdsDataProvider
 */
public function testFindIdentityReturnsNullIfUserIsNotFound(
    $invalidId
) {
    $this->assertNull(User::findIdentity($invalidId));
}
public function nonExistingIdsDataProvider() {
    return [[-1], [null], [30]];
}
在数据提供者中,每个二级数组是对请求函数的调用,这些数组的内容是方法的实际参数的有序列表。
因此,在我们的前一个例子中,测试将连续调用时接收到 -1、null 和 30。
如果我们要为我们的初始测试 testFindIdentityReturnsTheExpectedObject() 使用数据提供者,我们可以测试用户名是否包含 UTF-8 或无效字符,例如。
因此,使用数据提供者是一件好事!它赋予我们使用单个测试来检查更复杂情况的能力,这些情况需要一定程度的灵活性。
但问题来了:在所有测试中使用的数据库(使用 $user->save())将继续增长,因为没有指令告诉它这样做。
因此,我们可以在 setUp() 函数中添加以下内容:
// tests/codeception/unit/models/UserTest.php
protected function setUp()
{
    parent::setUp();
    // cleanup the User db
    User::deleteAll();
}
记得清理自己的工作:您可能会影响其他人的测试。目前,由于 deleteAll() 调用的存在,我们一切正常。
setUp() 函数在每个测试开始之前被调用。PHPUnit 提供了多层方法来在单个或多个测试之前设置一些东西,并在测试之后撤销它们。调用顺序可以用以下内容总结:
tests\codeception\unit\models\UserTest::setUpBeforeClass();
   tests\codeception\unit\models\UserTest::_before();
      tests\codeception\unit\models\UserTest::setUp();
         tests\codeception\unit\models\UserTest::testSomething();
      tests\codeception\unit\models\UserTest::tearDown();
   tests\codeception\unit\models\UserTest::_after();
tests\codeception\unit\models\UserTest::tearDownAfterClass();
在这里,setUpBeforeClass() 是在类实例化之前可能的最外层调用。请注意,_before 和 _after 是 Codeception TestCase 方法,而其余的是标准的 PHPUnit 方法。
既然我们在这里,我们也可以添加一个测试范围的 User 类,它将在每个测试之前实例化;它可以被我们的任何测试使用。为了实现这一点,我们需要添加一个私有变量,并在需要的地方添加相关语句:
// tests/codeception/unit/models/UserTest
/** @var User */
private $_user = null;
protected function setUp()
{
    parent::setUp();
    // setup global User
    $this->_user = new User;
    // cleanup the User db
    User::deleteAll();
}
现在,我们只需要修改相关的测试,在需要时使用 $this->_user。
小贴士
尽量使私有变量和方法清晰可见;这也有助于您避免命名冲突,正如我们在介绍固定配置时将看到的。
使用固定配置准备数据库
正如我们所看到的,数据提供者解决方案可以帮助您每次运行相同的测试时使用不同的数据集,这最终变得极其有用。另一个可能互补的解决方案是使用固定配置,让您预加载一些定义良好的数据,并使测试更加简单。这意味着您可以在不依赖于 $user->save()(这不是测试本身的一部分)的情况下测试 User::findIdentity() 方法。
固定配置用于将数据库设置在固定/已知的状态,以便您的测试可以在受控环境中运行。在这个过程中,我们也将消除在 setUp 函数中删除所有用户或依赖于可能受先前运行测试影响的静态值的需求。
固定配置只是一个在 setUp() 方法中动态加载的类,您只需创建固定配置类和数据库的实际内容即可。
让我们从创建固定配置类开始:
// tests/codeception/unit/fixtures/UserFixture.php
namespace app\codeception\tests\unit\fixtures;
use yii\test\ActiveFixture;
class UserFixture extends ActiveFixture
{
    public $modelClass = 'app\models\User';
}
在这种情况下,我们扩展了 ActiveFixture,因为它将提供一些可能有用的附加功能,所以我们唯一需要做的是定义它将模拟的模型。对于登录表单或其他自定义模型,另一种选择是扩展 yii\test\Fixture,在那里您必须使用公共属性 $tableName 定义表名。使用 ActiveFixture,只需定义 $className,固定配置就会自己确定表名。
下一步是定义实际要填充到数据库中的内容所对应的固定配置。默认情况下,Yii 将尝试在 fixtures/data/ 文件夹中查找名为 <table_name>.php 的文件。固定配置只是一个返回数组的语句,例如以下内容:
// tests/codeception/unit/fixtures/data/user.php
return [
    'admin' => [
        'id' => 1,
        'username' => 'admin',
        'password' => Yii::$app->getSecurity()->generatePasswordHash('admin'),
        'authkey' => 'valid authkey'
    ]
];
固定装置的每个条目都可以被键索引,以便在测试中快速引用。你通常也不需要指定主键,因为它们将自动创建,就像在ActiveRecord的情况下。
作为最后一步,我们需要实现fixtures()方法来定义我们想要在测试中使用的固定装置。为此,我们可以使用以下代码:
// tests/codeception/unit/models/UserTest.php
public function fixtures() {
    return [
        'user' => UserFixture::className(),
    ];
}
通过这样做,我们的setUp()方法将使用我们刚刚定义的固定装置的内容初始化数据库。如果我们需要为同一个固定装置类使用多个固定装置,那么我们可以在当前测试中指定要加载哪个固定装置,同时返回一个dataFile键,指定固定装置的路径,如下例所示:
public function fixtures()
{
    return [
        'user' => [
            'class' => UserFixture::className(),
            'dataFile' => '@app/tests/codeception/unit/fixtures/data/userModels.php'
        ]
    ];
}
现在我们已经定义了固定装置并准备好使用,我们可以通过$this->user变量(现在你可以看到为什么最好将私有和公共变量定义得很好并分开)来访问其内容。你可以通常将其用作数组并访问所需的索引或键,或者让它返回一个ActiveRecord对象,就像$this->user('admin')。
现在,我们可以通过重构我们之前实现的测试来看到它的实际效果:
public function testFindIdentityReturnsTheExpectedObject() {
    $expectedAttrs = $this->user['admin'];
    /** @var User $user */
    $user = User::findIdentity($expectedAttrs['id']);
    $this->assertNotNull($user);
    $this->assertInstanceOf('yii\web\IdentityInterface', $user);
    $this->assertEquals($expectedAttrs['id'], $user->id);
    $this->assertEquals($expectedAttrs['username'], $user->username);
    $this->assertEquals($expectedAttrs['password'], $user->password);
    $this->assertEquals($expectedAttrs['authkey'], $user->authkey);
}
这样,我们就可以继续我们的测试,而不用担心每次需要确保记录在数据库中时都要调用save()。
这也意味着我们不需要清理数据库,因为固定装置会为我们完成这项工作:
protected function setUp()
{
    parent::setUp();
    $this->_user = new User;
}
根据我们刚才说的,实现findByUsername()的测试应该相当简单,就像我们为findIdentity()做的那样。所以,我将把它留给你作为练习。
添加剩余的测试
到目前为止,我们应该已经创建了几乎所有测试,除了覆盖validateAuthKey()的测试,你应该能够没有特别的问题实现它,以及validatePassword(),我们将在第五章中更详细地探讨,召唤测试替身。
实现 ActiveRecord 类及其方法
现在,我们可以在通过类的实现之前尝试测试,并看到它们没有通过。所以,只需运行以下命令,就像我们在上一章中学到的那样:
$ cd tests
$ ../vendor/bin/codecept run unit
很可能,前面的命令会失败,并出现以下错误:
PHP Fatal error:  Call to undefined method app\models\User::tableName()
这是因为我们的类尚未被重新生成为ActiveRecord。
在下一节中,我们将开始使测试通过的工作,从迁移开始,将一些信息移动到数据库中,并从这里继续前进。
处理迁移
因此,最好的下一步是定义一个用户表,填充我们所需的数据,然后在上面实现用户模型,并使用接口中所需的方法。
注意
关于迁移还有很多要说的,关于它的文档正在不断改进和扩展。请确保前往并亲自阅读www.yiiframework.com/doc-2.0/guide-db-migrations.html。
让我们从创建迁移开始:
$ ./yii migrate/create table_create_user
Yii Migration Tool (based on Yii v2.0.0-dev)
Create new migration '/var/www/vhosts/htdocs/migrations/m140906_172836_table_create_user.php'? (yes|no) [no]:yes
New migration created successfully.
$
现在我们有了迁移,让我们根据需要实现up()和down()方法:
// migrations/m140906_172836_table_create_user.php
class m140906_172836_table_create_user extends Migration
{
    public function up()
    {
        $this->createTable('user', [
            'id' => 'pk',
            'username' => 'varchar(24) NOT NULL',
            'password' => 'varchar(128) NOT NULL',
            'authkey' => 'varchar(255) NOT NULL',
            'accessToken' => 'varchar(255)'
        ]);
        $this->insert('user', [
            'username' => 'admin',
            'password' => Yii::$app->getSecurity()->generatePasswordHash('admin'),
            'authkey' => uniqid()
        ]);
    }
    public function down()
    {
        $this->dropTable('user');
    }
}
我们使用 Yii 提供的 Security 组件来创建密码。还有很多其他相当方便的功能,可以避免重新发明轮子。
请注意,正确实现down()方法并在推送更改之前进行测试非常重要,因为如果你需要回滚到应用程序的先前状态,即所谓的回滚,它将是基础。
小贴士
除了up()和down()之外,你还可以使用safeUp()和safeDown(),这将允许你使用事务来运行迁移上下文,这意味着在出现错误的情况下,所有先前的操作将自动回滚。
迁移的实现和使用方式与 Yii 1 的情况相同,如果你以前从未使用过它们,它们是伟大的工具,因为它们让你能够定义在部署应用程序时容易错过的特定步骤。所使用的语法也应该相当直观易懂,方法也是自我解释的:createTable()、renameColumn()、addForeignKey()等等。
现在我们已经设置了迁移,是时候通过运行以下命令来应用它:
$ ./yii migrate/up
Yii Migration Tool (based on Yii v2.0.0-dev)
Creating migration history table "migration"...done.
Total 1 new migration to be applied:
 m140906_172836_table_create_user
Apply the above migration? (yes|no) [no]:yes
*** applying m140906_172836_table_create_user
 > create table user ... done (time: 0.022s)
 > insert into user ... done (time: 0.008s)
*** applied m140906_172836_table_create_user (time: 0.585s)
Migrated up successfully.
现在我们已经在数据库中有了结构和数据,我们可以开始根据需要重构模型。
Gii 代码生成工具
Yii 持续提供并改进其代码生成工具系统,特别是Gii。Gii 是一个代码生成器,它可以帮助你创建模型、控制器、CRUD、模块等的基本代码结构,这样你就不必过多考虑需要做什么,而是尽可能快地进入实现部分。
我们所使用的基本应用程序包含 Gii(并且它被定义为require-dev包)。由于在我们的情况下我们在(虚拟)托管环境中运行测试,我们需要稍微调整一下配置;我们需要允许我们的客户端 IP 访问该工具:
// config/web.php
if (YII_ENV_DEV) {
    // ...
    $config['bootstrap'][] = 'gii';
    $config['modules']['gii'] = [
        'class' => 'yii\gii\Module',
        'allowedIPs' => ['127.0.0.1', '::1', '192.168.56.*'],
    ];
}
如果你使用 VirtualBox,则192.168.56.*应该是默认情况。
现在我们已经做出了这个改变,我们可以将浏览器导航到http://basic.yii2.sandbox/gii。从那里,我们可以点击模型生成器部分,在那里我们可以创建新的模型,如下面的截图所示:

模型生成器接口
当点击 预览 按钮时,Gii 会首先检查要生成的文件是否已经存在,并给我们机会在真正点击 生成 按钮之前查看差异并决定是否要覆盖文件。
由于我们的 User 模型目前非常简单,我们不会在覆盖它并重新实现所需的方法时遇到任何问题。只需记住勾选 覆盖 复选框并点击 生成。否则,你可以根据以下段落中给出的提示相应地调整它。
点击 生成 后,你应该能够在页面底部看到 代码已成功生成 的提示。
现在,让我们回到我们的 User.php 类,看看有什么变化,并完善实现。
首先,我们会注意到该类现在继承自 ActiveRecord 类;这是面向数据库的模型的默认类。已经实现了一系列默认方法,我们不需要修改。我们真正需要的是使该类实现 IdentityInterface,如下所示:
// models/User.php
use Yii;
use yii\db\ActiveRecord;
use yii\web\IdentityInterface;
class User extends ActiveRecord implements IdentityInterface
{
现在,在类的末尾实现 IdentityInterface 的五个必需方法:
// models/User.php
/**
 * @inheritdoc
 */
public static function findIdentity($id) {
    return self::findOne($id);
}
如我们所见,在数据库中查找记录的方式非常直接,因为 ActiveRecord 提供了一些非常巧妙且易于理解的方法来与数据库交互。我们将在接下来的几页中看到许多这样的用例。
值得注意的是,findIdentity() 返回一个 IdentityInterface 对象。我们之前覆盖的实现调用了 new static(),这反过来触发了来自 yii\base\Object 类的魔法方法 __construct()。
注意
new static() 方法自 PHP 5.3 起就可用,提供了一种从父类静态实例化子类的方法,这在之前是不可能的。这种方法被称为 后期静态绑定。
更多信息可以在 PHP 手册中找到,链接为 php.net/manual/en/language.oop5.late-static-bindings.php。
如前所述,findIdentityByAccessToken 不需要,因为 accessToken 在我们的代码中不会用到,所以让我们来实现它:
public static function findIdentityByAccessToken($token, $type = null) {
    throw new NotSupportedException('Login by access token not supported.');
}
接口中的剩余三个方法应该很容易实现和理解,为此,我们可以使用以下代码:
public function getId() {
    return $this->id;
}
public function getAuthKey() {
    return $this->authkey;
}
public function validateAuthKey($authKey) {
    return $this->authkey === $authKey;
}
接口中没有包含的明显方法中,有几个在 LoginForm.php 中使用;其中一个是 findByUsername,具体如下:
/**
 * Finds user by username
 *
 * @param  string      $username
 * @return static|null
 */
public static function findByUsername($username)
{
    return self::findOne(['username' => $username]);
}
另一个方法是 validatePassword,具体如下:
/**
 * Validates password
 *
 * @param  string  $password password to validate
 * @return boolean if password provided is valid for current user
 */
public function validatePassword($password)
{
    return Yii::$app->getSecurity()->validatePassword($password, $this->password);
}
在这里,我们再次使用来自安全组件的 validatePassword() 方法,这使得使用加密和任何我们想要添加的额外安全级别对用户来说变得透明。
看到测试通过
如你所猜,又是时候运行 Codeception 对我们的 UserTest 类进行测试了:
$ ../vendor/bin/codecept run unit models/UserTest.php
Codeception PHP Testing Framework v2.0.6
Powered by PHPUnit 4.4-dev by Sebastian Bergmann.
Unit Tests (14) -----------------------------
Trying to test validate returns false if parameters are not set (tests\codeception\unit\models\UserTest::testValidateReturnsFalseIfParametersAreNotSet)                              Ok
[snip]
Time: 3.61 seconds, Memory: 28.75Mb
OK (27 tests, 53 assertions)
你应该能够通过所有测试而没有任何问题,并且你应该也能够在出现错误时修复它们。
如果我们决定运行所有测试,包括那些已经存在的测试,我们可能会看到一些测试不再通过。不用担心,这是很正常的,因为我们已经改变了 User 模型的工作方式和内部行为。特别是,我得到的错误是关于 LoginFormTest 的,但 Codeception/PHPUnit 在告诉我们出了什么问题方面非常迅速:
There was 1 error:
---------
1) tests\unit\models\LoginFormTest::testLoginCorrect | user should be able to login with correct credentials
yii\base\InvalidParamException: Hash is invalid.
FAILURES! 
Tests: 31, Assertions: 64, Errors: 1.
正如我之前强调的,修复任何表现不佳的测试非常重要。这将使我们了解是否触及了不应该破坏或在我们提交更改时可能破坏的内容。
使用全局固定文件
在这种情况下,很明显我们的测试已经影响了数据库的状态:解决方案将能够创建一个新的固定文件,其中包含管理员用户的预期数据,这复制了迁移所做的工作,并更新 LoginFormTest 和 UserTest。
我们现在将使用默认的固定文件 user.php 作为具有管理员用户的全局固定文件,如下所示:
// tests/codeception/unit/fixtures/data/user.php
return [
    'admin' => [
        'id' => 1,
        'username' => 'admin',
        'password' => Yii::$app->getSecurity()->generatePasswordHash('admin'),
        'authkey' => 'valid authkey'
    ]
];
之前的固定文件将被重命名为 userModels.php;它包含我们可能最终添加到应用程序中的额外用户。执行此操作的代码如下:
return [
    'user_basic' => [
        'username' => '-=[ valid username ]=-',
        'password' => 'This is a valid password!!!',
        'authkey' => '00%am|%lk;@P .'
    ],
    'user_accessToken' => [
        'username' => '-=[ valid username ]=-',
        'password' => 'This is another valid password!!! :) <script></script>',
        'authkey' => uniqid()
    ],
    'user_id' => [
        'id' => 4,
        'username' => '-=[ valid username ]=-',
        'password' => 'This is another valid password!!! :)',
        'authkey' => uniqid()
    ],
];
我们本可以陷入仅仅修改初始固定文件以包含管理员用户的陷阱,这会解决问题但会使多个测试依赖于为特定测试设计的固定文件。所以,让我们尽量保持事物尽可能分离和独立。
现在,我们可以在 LoginFormTest 中将之前提到的固定文件作为全局固定文件加载,如下所示:
// tests/codeception/unit/models/LoginFormTest.php
public function globalFixtures()
{
    return [
        'user' => UserFixture::className(),
    ];
}
此外,我们可以修改之前实现的方法,在 UserTest 中加载固定文件,如下所示:
// tests/codeception/unit/models/UserTest.php
public function globalFixtures()
{
    return [
        'user' => UserFixture::className(),
    ];
}
public function fixtures()
{
    return [
        'user' => [
            'class' => UserFixture::className(),
            'dataFile' => '@app/tests/codeception/unit/fixtures/data/userModels.php'
        ]
    ];
}
我们新的 fixtures() 实现需要扩展传递的参数并定义 class 和 dataFile;否则,它将无法正确加载。
globalFixtures() 方法在 fixtures() 方法之前运行,这意味着 $this->user 变量将只包含最新的固定文件,而不是管理员。
摘要
我们讨论了许多关于 PHPUnit 的事情,例如断言、数据提供者和固定文件。我们看到了如何使测试通过以及如何预防性地捕获可能导致更大问题的错误。
你可以在 Codeception 单元测试和 PHPUnit 中发现更多东西,但到目前为止我们所看到的应该足以给你开始创建清晰测试所需的信心。
在下一章中,我们将看到如何测试依赖于外部代码和类的组件,以便使用存根和模拟来获得最佳控制环境。
第五章. 调用测试替身
在本章中,我们将仔细研究测试替身,以便更精确地控制我们的测试,并避免依赖于我们一无所知的接口。
我们将完成上一章开始的工作,并了解如何处理外部依赖,特别是存根和模拟之间的区别。
然后,我们将花费本章的剩余时间来了解如何组织我们的测试,以提高可读性和可维护性,使用我们在书中早期介绍的一些面向 BDD 的工具,例如Specify和Verify。
在高层次上,这些是我们将在本章中涉及的主题:
- 
处理外部依赖 
- 
使用存根隔离组件 
- 
使用观察者监听调用 
- 
编写可维护的单元测试 
处理外部依赖
我们将我们的单元测试套件留在了几乎完成的状态。我们剩下要测试的是来自我们的User模型类的validatePassword()方法。
这个方法给我们带来的问题是,我们计划使用我们喜爱的由 Yii 提供的、友好提供的安全组件来加密和解密密码,并验证其正确性。该组件在整个应用程序的生命周期中通过Yii::$app->getSecurity()可用。
yii\base\Security类公开了一系列方法来帮助您加强您的应用程序。我们将对其的使用相当有限,但我建议您阅读一下官方文档,该文档可在www.yiiframework.com/doc-2.0/guide-security-authentication.html找到,并阅读以下部分,这些部分将涵盖所有关于身份验证、加密等方面的内容。
让我们定义我们认为这个方法应该如何工作的实现方式。验证密码的文档化用途如下:
public function testValidatePasswordReturnsTrueIfPasswordIsCorrect() {
    $expectedPassword = 'valid password';
    $this->_user->password = Yii::$app->getSecurity()->generatePasswordHash($expectedPassword);
    $this->assertTrue($this->_user->validatePassword($expectedPassword));
}
这意味着我们首先需要使用前面提到的辅助类来创建密码散列,然后在用户中设置它,然后可以使用$user->validatePassword()方法来检查实际传入的明文密码是否与内部密码匹配。幕后应该发生某种加密/解密操作,理想情况下是通过使用安全组件中的Security::validatePassword()。
用户模型中User::validatePassword()的一个可能实现可以是以下内容:
// models/User.php
/**
 * Validates password
 *
 * @param  string  $password password to validate
 * @return boolean if password provided is valid for current user
 */
public function validatePassword($password)
{
    return Yii::$app->getSecurity()->validatePassword($password, $this->password);
}
如果我们尝试运行测试,这个特定的方法将无问题通过。
这可能是一个不错的解决方案,但我们需要非常清楚,这并不是一个真正的单元测试;它更像是一个集成测试,因为我们仍然依赖于安全组件。
使用存根隔离组件
我们目前面临的问题是我们真的不想使用实际的安全组件,因为它不是测试本身的一部分。记住,我们正在一个黑盒环境中工作,我们不知道安全组件未来可能有什么其他依赖。我们只需要确保我们的实现方法在(假的)对象接口按预期工作的情况下将表现正确。我们可以在以后添加一个集成方法来确保安全组件实际上可以工作,但这完全是另一回事。
为了做到这一点,PHPUnit 提供了一个有趣的系统来模拟和存根类,并将它们注入到你的应用程序中以提供一个更受控的环境。一般来说,这些通常被称为测试替身,创建它们的方法是通过 Mock Builder。
PHPUnit 的最新版本(4.x 或更高版本)建议使用 Mock Builder 来配置存根和行为。以前,这是通过传递给它的一系列长参数来完成的。我不会沉迷于说如果你正在使用 PHPUnit 3.x 或更早版本,可能到了升级的时候了!
注意
请注意,final、private和static方法不能被存根或模拟,因为 PHPUnit 测试替身功能不支持这一点。
特别是存根指的是用可能返回配置值的测试替身替换对象的实践。
那么,我们是如何做到这一点的呢?我决定采用使用一个单独的私有函数来将存根逻辑委托给可重用代码块的方法:
/**
 * Mocks the Yii Security module,
 * so we can make it return what we need.
 *
 * @param string $expectedPassword the password used for encoding
 *                                 also used for validating if the
 *                                 second parameter is not set
 */
private function _mockYiiSecurity($expectedPassword)
{
    $security = $this->getMockBuilder(
'yii\base\Security')
        ->getMock();
我们首先通过使用getMockBuilder()创建我们的安全类的存根。默认情况下,Mock Builder 将用返回null的测试替身替换所有类方法。
我们也可以选择性地指定要替换哪些方法,通过在数组中指定它们,然后传递给setMethods();例如:setMethods(['validatePassword', 'generatePasswordHash'])。
我们还可以传递null给它;我们可以避免任何方法有测试替身,但没有它,我们无法设置任何期望。
提示
如果你正在模拟的类在__constructor()方法中执行了不必要的初始化,你可以通过使用disableOriginalConstructor()或传递自定义参数使用setConstructorArguments()来禁用它。还有更多方法可以应用于修改 Mock Builder 的行为;请参阅以下文档:phpunit.de/manual/current/en/test-doubles.html#test-doubles.stubs。
任何是测试替身的方法都可以使用expects()进行配置和监控:
    $security->expects($this->any())
        ->method('validatePassword')
        ->with($expectedPassword)
        ->willReturn(true);
    $security->expects($this->any())
        ->method('generatePasswordHash')
        ->with($expectedPassword)
        ->willReturn($expectedPassword);
    Yii::$app->set('security', $security);
}
这看起来相当容易阅读:任何(any())时候方法(method())'validatePassword'被调用(with())带有$expectedPassword,它将返回(willReturn())true。
你可以以多种方式配置你的替换函数:让它们在连续调用中返回特定的值,或者不同的值,或者当被调用时抛出异常。
注意
更多信息和文档可以在官方 PHPUnit 文档中找到,该文档位于phpunit.de/manual/current/en/test-doubles.html。
然后,我们可以实现负测试(negative test)来覆盖传递给validatePassword()方法的错误密码,使用我们想要的逻辑:
/**
 * @expectedException yii\base\InvalidParamException
 */
public function testValidatePasswordThrowsInvalidParamExceptionIfPasswordIsIncorrect() {
    $password = 'some password';
    $wrongPassword = 'some other password';
    $this->_mockYiiSecurity($password, $wrongPassword);
    $this->_user->password = $password;
    $this->_user->validatePassword($wrongPassword);
}
为了实现这一点,我们需要稍微重构我们刚刚实现的私有方法:
/**
 * Mocks the Yii Security module,
 * so we can make it returns what we need.
 *
 * @param string $expectedPassword the password used for encoding
 *                                 also used for validating if the
 *                                 second parameter is not set
 * @param mixed $wrongPassword  if passed, validatePassword will
 *                              throw an InvalidParamException
 *                              when presenting this string.
 */
private function _mockYiiSecurity($expectedPassword, $wrongPassword = false)
{
    $security = $this->getMockBuilder(
'yii\base\Security')
        ->getMock()
    );
    if ($wrongPassword) {
        $security->expects($this->any())
            ->method('validatePassword')
            ->with($wrongPassword)
            ->willThrowException(new InvalidParamException());
    } else {
        $security->expects($this->any())
            ->method('validatePassword')
            ->with($expectedPassword)
            ->willReturn(true);
    }
    $security->expects($this->any())
        ->method('generatePasswordHash')
        ->with($expectedPassword)
        ->willReturn($expectedPassword);
    Yii::$app->set('security', $security);
}
在这里,我们终于可以看到如何配置我们替换的方法,使用willThrowException()抛出异常。有了它,我们可以确保方法抛出了异常;因此,检查异常的测试应该与其他测试分开。
注意
官方文档提供了对 Mock Builder API 使用的更详细解释,并且可以在phpunit.de/manual/current/en/test-doubles.html找到。
使用观察者监听调用
由于User::validatePassword()现在以透明的方式在其实现中使用了Security::validatePassword(),我们不想在设置密码时向使用 User 模型的人暴露任何这些内容。
因此,我们希望认为在设置密码时,我们的实现将以某种方式使用Security::generatePasswordHash(),这样当调用User::validatePassword()时,我们就能闭合这个循环,并且一切应该都能正常工作,无需过多担心加密方案等问题。
实现一个可以覆盖这部分内容的测试的一个直接、逻辑上合理但相当滥用的方法是以下内容:
public function testSetPasswordEncryptsThePasswordCorrectly()
{
    $clearTextPassword = 'some password';
    $encryptedPassword = 'encrypted password';
    // here, we need to stub our security component
    $this->_user->setPassword($clearTextPassword);
    $this->assertNotEquals(
        $clearTextPassword, $this->_user->password
    );
    $this->assertEquals(
        $encryptedPassword, $this->_user->password
    );
}
让我们停下来片刻,理解我们在做什么:理想情况下,我们想要设置一个密码,并加密后读取它,进行相关的和必要的断言。这意味着我们在同一个测试中同时测试密码的设置器和获取器,这再次违反了单元测试的基本原则。
作为旁注,无论我们如何实现安全组件的存根(stubbing),我们的逻辑都不会与本章开头时的初始实现有很大不同。
引入模拟
模拟(Mocking)指的是用一个测试替身(test double)替换对象,以验证预期行为,例如确保某个方法已被调用。这似乎正好符合我们的需求。
在一个合适的黑盒场景中,我们不知道setPassword()做什么,我们需要完全依赖代码覆盖率来理解我们是否覆盖了编程流程的任何可能分支,正如在第四章中所述,使用 PHPUnit 进行隔离组件测试。
仅作为我们目的的示例,我们想确保在调用setPassword()时,至少调用一次Security::generatePasswordHash(),并且将参数无修改地传递给该方法。
让我们尝试以下方法来测试这个:
public function testSetPasswordCallsGeneratePasswordHash()
{
    $clearTextPassword = 'some password';
    $security = $this->getMockBuilder(
'yii\base\Security')
        ->getMock(
);
    $security->expects($this->once())
        ->method('generatePasswordHash')
        ->with($this->equalTo($clearTextPassword));
    Yii::$app->set('security', $security);
    $this->_user->setPassword($clearTextPassword);
}
如你所注意到的,这个测试中没有任何特定的断言。我们的模拟类将在其方法被调用并传入指定值后,简单地标记测试为通过。
了解 Yii 虚拟属性
在我们刚才讨论的例子中,如果我们能从用户那里隐藏将明文密码转换为哈希的功能,那将是非常棒的。
这没有发生的原因有很多,但其中最重要的原因是 Yii 已经提供了一个非常有趣且做得很好的虚拟属性系统。这个系统自 Yii 1 以来就存在了,一旦你习惯了它,你就可以取得令人满意的结果。
通过实现一个继承自yii\base\Component的模型,例如ActiveRecord或Model,你也将继承已经实现的魔法函数__get()和__set(),这些函数可以帮助你处理虚拟属性。当你需要不费吹灰之力创建额外属性时,这尤其有用。
让我们看看一个更实际的例子。
假设我们的用户模型在数据库中有一个名字字段和一个姓氏字段,但我们需要创建全名属性,而不在用户表中添加新列:
namespace app\models;
class User extends ActiveRecord
{
    /**
     * Getter for fullname
     */
    public function getFullname()
    {
        return $this->firstname . ' ' . $this->lastname;
    }
    // rest of the class
}
因此,我们可以像访问类的正常属性一样访问字段:
public function testGetFullnameReturnsTheCorrectValue()
{
    $user = new User;
    $user->firstname = 'Rainer';
    $user->lastname = 'Wolfcastle';
    $this->assertEquals(
        $user->firstname . ' ' . $user->lastname,
        $user->fullname
    );
}
简单明了的公共属性按预期工作。在下面的代码片段中,我引入了一个新的类Dog,仅用于示例目的,它继承自Model:
namespace app\models;
use Yii;
use yii\base\Model
class Dog extends Model
{
    public $age;
}
因此,我们的测试将毫无问题地通过:
public function testDogAgeIsRecordedCorrectly()
{
    $expectedAge = 7;
    $dog = new Dog;
    $dog->age = $expectedAge;
    $this->assertEquals($expectedAge, $dog->age);
}
这一点对你来说应该毫不奇怪,但让我们看看如果我们同时有:
namespace app\models;
class Dog extends ActiveRecord
{
    const AGE_MULTIPLIER = 7;
    public $age;
    public function setAge($age){
        // let's record it in dog years
        $this->age = $age * self::AGE_MULTIPLIER;
    }
    // rest of the class
}
现在,我们期待在赋值时触发setAge(),而直接读取公共属性的值:
public function testDogAgeIsRecordedInDogYears()
{
    $dog = new Dog;
    $dog->age = 8;
    $this->assertEquals(
        56, 
        $dog->age
    );
}
然而,运行这个测试只会揭示一个令人沮丧的事实:
$ ../vendor/bin/codecept run unit models/DogTest.php
1) tests\codeception\unit\models\DogTest::testAgeIsRecordedInDogYears
Failed asserting that 8 matches expected 56.
是的,测试确实是相同的。
让我们的类自动处理 getter 和 setter 是有代价的。魔法 setter 执行的检查顺序可以用以下内容来概括:
BaseActiveRecord::__set($name, $value)
  if (BaseActiveRecord::hasAttribute($name))
      $this->_attributes[$name] = $value;
  else
      Component::__set($name, $value)
        if (method_exists($this, 'set'.$name))
            $this->'set'.$name($value);
        if (method_exist($this, 'get'.$name))
            throw new InvalidCallException(...);
        else
            throw new UnknownPropertyException(...);
如果你实现了一个继承自yii\base\ActiveRecord的模型,其基类将首先检查该属性是否已经作为表列存在;如果没有,它将调用Component::__set()。这个方法不仅适用于继承自yii\base\Model的模型,也适用于任何隐式继承自yii\base\Component的其他类,例如行为和事件。
接着,我们可以看到 setter 将确保我们的类中存在'set'.$name方法,如果只有 getter,则将引发异常。
在我们最初定义的 firstname 获取器中,我们可以有以下的附加测试:
/**
 * @expectedException yii\base\InvalidCallException
 */
public function testSetFullnameThrowsException()
{
    $user = new User;
    $user->firstname = 'Fido';
    $user->lastname = 'Smith';
    // setter not available
    $user->fullname = 'Something Else';
}
关于在设置器中执行的事件和行为,有一些事情需要注意,但我们现在不会触及它们。
因此,回到我们的 setPassword() 困境,我们需要意识到,如果我们通过使用 $user->password 进行左侧赋值来触发魔法方法,这将不会触发我们的方法。
因此,理想的最佳解决方案可能是以更声明性的方式调用存储的密码,例如 password_hash。
编写可维护的单元测试
在离开单元测试之前,作为最后一部分,我想展示一些 Codeception 提供的附加功能,这些功能已经在 第一章 中介绍过,测试心态。
Codeception 是以模块化和灵活性为设计理念创建的,因此其他任何事情都是你的责任。特别是,你可能已经注意到我们的 UserTest 类已经增长了很多。
那么,如果接口或我们的模型工作方式的变化破坏了我们的测试,会发生什么呢?
这非常清楚,特别是如果你在一个团队中工作,或者如果你的代码被其他人接手维护,那么你需要明确的规则,以便每个人都同意如何编写代码,作为一个起点,以及测试。
我已经在 第四章 中突出显示过,使用 PHPUnit 进行隔离组件测试,我与我合作过的团队以及我自己的代码开始做的非常基本的事情之一,就是定义精确且简单的规则,这些规则旨在提高代码的清晰度和可读性。我见过太多的“开发者摇滚明星”炫耀他们如何擅长编写压缩代码、嵌套变量和隐藏多个赋值。如果代码是废弃的,编写最终变得难以理解的代码可能很有趣。
代码可读性最终成为我看到公司选择候选人的方式之一,一个非常快速的测试就是让某人阅读你的代码,并能够理解它做什么,而无需询问。
测试不应该比你的应用程序代码得到更少的关注:如果做得恰当,测试代表了一种记录事物应该如何工作以及应该如何使用的方法。一旦你的类提供了更多和更多的功能,你的测试类将开始增长,你需要准备好面对重构并在应用程序发生变化或引入错误时引入回归测试。
使用 BDD 规范测试
Codeception 提供了一个叫做 Specify 的好用的紧凑工具,我们之前已经介绍过。
仅使用 PHPUnit,我们只有方法来分割我们的测试;使用 Specify,我们有了另一层组织:方法成为我们的 故事,我们的规范块成为我们的 场景。
仅出于文档目的,应指出 PHPUnit 具有自己的 BDD 兼容的语法糖,即given()、when()和then()方法,如phpunit.de/manual/3.7/en/behaviour-driven-development.html中所述。如果您更习惯于这种语法,您仍然可以使用它。
作为更清晰的例子,我们可以在同一个测试中分组所有验证规则,并通过使用 Specify 块来分割我们正在执行的定义,如下所示:
use Specify;
public function testValidationRules()
{
    $this->specify(
        'user should not validate if no attribute is set',
        function () {
 verify_not($this->_user->validate());
 }
    );
    $this->specify(
        'user should validate if all attributes are set', 
        function () {
            $this->_user->attributes = [
                'username'=>'valid username',
                'password'=>'valid password',
                'authkey' =>'valid authkey'
            ];
            verify_that($this->_user->validate());
        }
    );
}
如我们所见,我们现在正在将两个先前的测试聚合到一个单独的方法中,并将它们分组在两个specify()调用块内。
Specify 被定义为一种特质;这就是为什么您需要在最外层的全局作用域中使用命名空间,并在测试类中加载它的原因:
namespace tests\codeception\unit\models;
use Codeception\Specify;
use yii\codeception\TestCase;
// other imported namespaces
class UserTest extends TestCase
{
    use Specify;
    // our test methods will follow
    // we can now use $this->specify()
}
如您所见,specify()只需要两个参数:即将定义的场景的简单描述,以及包含我们想要执行的断言的匿名函数。
到目前为止,我们可以继续使用我们一直使用的 PHPUnit 经典断言,或者尝试启用 BDD 风格的断言。Verify是一个小巧而实用的包,它将提供这种能力,让您可以使用verify()、verify_that()和verify_not()等方法。
从先前提到的场景中,考虑以下代码行:
verify_not($this->_user->validate());
这与使用 PHPUnit 断言完全相同:
$this->assertFalse($this->_user->validate());
为了执行更复杂的断言,我们可以使用以下方式使用verify():
$this->specify(
    'user with username too long should not validate',
    function () {
        $this->_user->username = 'this is a username longer than 24 characters';
        verify_not($this->_user->validate('username'));
        verify($this->_user->getErrors('username'))->notEmpty();
    }
);
小贴士
在项目主页github.com/Codeception/Verify上,还有许多其他断言可以使用,并且可以找到。
摘要
在本章中,我们介绍了备受期待的模拟和存根,这将允许您执行适当的组件测试。在最后一部分,我们更好地探讨了测试的代码组织,以及通过使用 Specify 和 Verify 来编写类似 BDD 的方式。
在下一章中,我们将探讨实现功能测试的下一步,这些测试应该定义我们用户的 REST 接口。
第六章。测试 API – PHPBrowser 来拯救
我们现在将深入研究功能测试。在上一章中,我们创建了处理用户模型的初始步骤,但现在我们将创建处理用户的 REST 接口。
在我们开始担心 REST 接口及其测试之前,我们将分析 Yii 基本应用程序中已经可用的内容,并在此基础上扩展主题,创建更多精彩的内容。
因此,本章分为三个部分,难度逐渐增加,所以请保持警惕,并随时多次回顾,直到你理解每个部分,它们是:
- 
Yii 2 的功能测试 
- 
REST 接口的功能测试 
- 
使用 Yii 2 创建 RESTful Web 服务 
Yii 2 的功能测试
正如你在第三章中看到的,进入 Codeception,我们在基本应用程序中预加载了一些基本的函数测试。
让我们开始深入挖掘,一旦你掌握了所需的知识,我们就会继续进行 REST 接口的测试。
如你所知,基本应用程序由几个页面、登录系统和联系表单组成。
功能测试几乎涵盖了所有内容,所以让我们开始看看我们有哪些文件以及它们的内容。
理解和改进现有的 CEPTs
包含在 codeception/functional/HomeCept.php 中的测试非常容易理解。多亏了 Codeception 使用的语法,你可以轻松理解测试的意图,所以让我们分解一下,看看每一部分的作用:
$I = new FunctionalTester($scenario);
你将首先初始化将在其中执行测试的演员。Yii 使用与 Codeception 文档和指南中官方使用的名称略有不同,即 TestGuy,所以当你遇到 Yii 之外的其他文档时,请记住这一点。
小贴士
记住,你可以随意命名演员,他们的配置可以在套件 YAML 文件中找到,对于功能测试,它位于 tests/codeception/functional.suite.yml。
此类位于与其他功能测试相同的文件夹中,并且通过运行 codecept build 自动生成:
$I->wantTo('ensure that home page works');
第一步是简洁但详细地声明测试的范围;这将帮助你和非技术人员了解出了什么问题,以及测试是否有效地以强烈和全面的方式执行了其预期要执行的操作。wantTo() 方法应该只调用一次,因为任何后续调用都将覆盖之前设置的值:
$I->amOnPage(Yii::$app->homeUrl);
我们的测试需要一个起点;amOnPage() 方法除了加载实际测试将进行的给定 URL 外,什么都不做:
$I->see('My Company');
$I->seeLink('About');
在 Codeception 中,断言是通过 see* 和 dontSee* 动作执行的,确保特定的文本或链接在页面中存在或不存在。
这些操作可以根据需要描述得尽可能详细,在前面的示例中,使用see('My Company')我们只是检查文本是否存在于标记中,而不是特定的标签中,而seeLink('About')则等同于编写see('About', 'a')。我们很快就会看到,我们可以向seeLink()传递第二个参数,这将允许我们检查链接应指向的 URL。
与页面的交互,如触发、使用click()点击链接、使用fillField()、checkOption()、submitForm()等填充字段,这些都是 Codeception 功能测试所能做到的。任何更复杂的事情都必须仔细重新评估,因为你实际上可能需要将其移动到验收测试中:
$I->click('About');
$I->see('This is the About page.');
在前面的行中,我们触发了“关于”页面的链接,并期望结果页面包含特定的副本。这个特定的测试只是说明了使用链接在我们的应用程序中导航的要点,因为它可以像前面描述的那样使用seeLink('About', '/about')完成,并且可以将关于页面的任何断言留在其自己的测试中。
我们不妨将测试扩展得更多一些,使其与我们试图测试的内容更相关;我们想要确保存在的功能部分是什么,没有它们我们可以认为页面“非功能”?在我们的例子中,我们谈论的是页面的标题(因为它已经被处理过),菜单,以及我们总是希望在那里出现的任何其他链接:
$I = new FunctionalTester($scenario);
$I->wantTo('ensure that home page works');
$I->amOnPage(Yii::$app->homeUrl);
开始是相同的,但然后我们确保页面的标题包含我们期望的内容:
$I->expect('the title to be set correctly');
$I->seeInTitle('My Yii Application');
下一个部分确保菜单包含所有必要的链接到各个页面:
$I->expectTo('see all the links of the menu');
$I->seeLink('Home', '/');
$I->seeLink('About', '/about');
$I->seeLink('Login', '/login');
$I->seeLink('Contact', '/contact');
你必须记住,链接不是严格检查的;这意味着如果你有$I->seeLink('Something', '/something'),它将匹配任何包含Something的链接;例如,它可以是指Something Else的任何href属性,如/something/else,甚至http://something.com。
在我们的情况下,这显然使检查主页链接的相关性变得有些不相关,所以我们很可能会获取当前的 URL,并以下述方式对其进行检查:
$url = $I->grabFromCurrentUrl();
$I->seeLink('Home', $url);
有不同的方法可以抓取要在测试的其余部分动态重用的内容,例如grabAttributeFrom()、grabCookie()、grabResponse()等。再次强调,如果你的 IDE 不支持代码提示,你的FunctionalTester类将包含这些方法的详细信息。
我们可以为指向主页的任何其他链接做同样的事情:
$I->expectTo('see a self-referencing link to my company homepage');
$I->seeLink('My Company', $url);
对于其余的链接,检查我们的路由是否配置良好也可能很有用;例如,你需要检查控制器的名称是否没有显示:
$I->dontSeeLink('About', 'site/about');
$I->dontSeeLink('Login', 'site/login');
$I->dontSeeLink('About', 'site/contact');
我们最后想要确保的是,Home链接被标记为选中。
对于这个测试,我们需要使用一个非常具体的选择器,因为标识我们链接状态的激活类位于实际锚点的父级中,而且没有简单的方法可以断言这一点,所以使用 XPath 表达式特别有用:
$I->expectTo('see the link of the homepage as selected');
$I->seeElement('//li[@class="active"]/a[contains(.,"Home")]');
大多数需要上下文选择器的可用方法,如 click()、see() 和 seeElement(),可以接受此参数的多种格式,主要是作为 CSS 选择器、XPath 查询或定位器,这些都是 Codeception 提供的特定对象。
在其最简单的形式中,选择器可以只是一个简单的单词或句子,这意味着“找到这个单词/句子首次出现的地方”。如您之前所见,see("Something") 将返回第一个包含 Something 作为其值的元素(例如,Something Else)。
CSS 选择器可能是你更熟悉的一种,但对于更复杂的内容,XPath 通常更胜一筹。
在上述示例中,XPath 查询 //li[@class="active"]/a[contains(.,"Home")] 可以按以下方式阅读:
- 
在任何级别找到所有的 li节点(//li)
- 
通过特定的类属性过滤它们( [@class="active"]);请注意,这是字面意义和区分大小写的
- 
在这些中找到直接的子节点 a节点(/a)
- 
如果它们包含特定的文本,则过滤它们( [contains(.,"Home")])
注意
XPath 2.0 自 2010 年 12 月以来一直是 W3C 建议的标准,您可以在 www.w3.org/TR/xpath20/ 上了解更多信息。
定位器可以简化在您的 DOM 中编写更复杂查询的过程,并允许您通过 OR 组合 CSS 和 XPath 查询:
use \Codeception\Util\Locator;
$I->see('Title', Locator::combine('h1','h2','h3'));
通过前面的声明,我们可以检查任何 h1、h2 或 h3 标签中是否存在 Title 字符串。
另一个可能有用的功能是定位器中可用的一种方法,您可以使用它通过 tabIndex 浏览页面:
<?php
use \Codeception\Util\Locator;
$I->fillField(Locator::tabIndex(1), 'davert');
$I->fillField(Locator::tabIndex(2) , 'qwerty');
$I->click('Login');
注意
上述示例是故意从定位器的文档页面中选取的,该页面可在 codeception.com/docs/reference/Locator 找到。
编写可重用的页面交互
测试表单可能是任何开发人员和测试人员可能做过的最艰巨的任务之一。如果您将表单视为跨越多个页面的多个单选和复选问题的问卷,您会感受到这种痛苦。
您可以清楚地看到使用功能测试自动化的直接好处。
可用的两个示例,LoginCept.php 和 ContactCept.php,是一个很好的起点。让我们更仔细地看看 LoginCept.php;如果您浏览测试的内容,您会立即注意到 fillField() 方法从未被调用,取而代之的是以下命令:
$loginPage = LoginPage::openBy($I);
$I->see('Login', 'h1');
$I->amGoingTo('try to login with empty credentials');
$loginPage->login('', '');
实际上,页面是跨测试重用组件的最简单方法之一。在同一个测试中多次重复执行的操作可能被提取并放入页面中,就像我们在测试中使用的页面一样:
namespace tests\codeception\_pages;
use yii\codeception\BasePage;
/**
 * Represents login page
 * @property \AcceptanceTester|\FunctionalTester $actor
 */
class LoginPage extends BasePage
{
    public $route = 'site/login';
    /**
     * @param string $username
     * @param string $password
     */
    public function login($username, $password)
    {
        $this->actor->fillField(
            'input[name="LoginForm[username]"]', $username
        );
        $this->actor->fillField(
            'input[name="LoginForm[password]"]', $password
        );
        $this->actor->click('login-button');
    }
}
所需的只有与之关联的路由,然后你可以实现所需的方法,以实现任何需要的功能,在先前的案例中就是登录过程。
在 Page 类中,$this->actor 是对当前在测试中使用的演员的引用。
你有两种使用页面的方式;第一种是立即打开页面并将其与当前演员关联,就像之前在 LoginPage::openBy($I) 中看到的那样,否则,你可以在需要时简单地调用其构造函数并加载页面(也可以使用不同的参数):
$loginPage = new LoginPage($I);
$loginPage->getUrl();
现在,正如你在与单元测试一起工作时所看到的,能够保持数据库内容处于受控状态是非常有用的。而且, fixtures 再次为我们提供了帮助,甚至在这里。
实现固定装置
在 第四章 中,使用 PHPUnit 进行隔离组件测试,你看到了如何实现一个固定装置。在功能测试中,可以使用相同的类;唯一的区别是,Codeception 的 PHPBrowser 及其底层基础设施不知道如何加载固定装置,因此使用 Codeception 的每个框架,如 Yii 所做的那样,需要提供桥梁来填补这个差距。
高级应用为 FixtureHelper 提供了实现,它实现了 Codeception Module 类并从 FixtureTrait 导入方法:
<?php
namespace tests\codeception\_support;
use tests\codeception\fixtures\UserFixture;
use Codeception\Module;
use yii\test\FixtureTrait;
/**
 * This helper is used to populate database with needed 
 * fixtures before any tests should be run.
 * For example - populate database with demo login user 
 * that should be used in acceptance and functional tests.
 * All fixtures will be loaded before suite will be
 * started and unloaded after it.
 */
class FixtureHelper extends Module
{
    /**
     * Redeclare visibility because Codeception includes
     * all public methods that not starts from "_"
     * and not excluded by module settings, in actor class.
     */
    use FixtureTrait {
        loadFixtures as protected;
        fixtures as protected;
        globalFixtures as protected;
        unloadFixtures as protected;
        getFixtures as protected;
        getFixture as public;
    }
    /**
     * Method called before any suite tests run. 
     * Loads User fixture login user
     * to use in acceptance and functional tests.
     * @param array $settings
     */
    public function _beforeSuite($settings = [])
    {
        $this->loadFixtures();
    }
    /**
     * Method is called after all suite tests run
     */
    public function _afterSuite()
    {
        $this->unloadFixtures();
    }
    /**
     * @inheritdoc
     */
    public function fixtures()
    {
        return [
            'user' => [
                'class' => UserFixture::className(),
                'dataFile' => '@tests/codeception/fixtures/data/init_login.php',
            ],
        ];
    }
}
上述代码相当简单,唯一重要的是在 FixtureHelper 中实现 fixtures() 方法,该方法返回处理模型及其包含我们想要在数据库中的所有行的数据文件的列表。与高级应用中找到的原始代码的唯一区别是导入 getFixture() 方法作为公共的,我们将在稍后看到为什么这样做。
以下代码是针对 init_login.php 文件的:
<?php
return [
    'basic' => [
        'username' => 'user',
        'authkey' => uniqid(),
        'password' => Yii::$app->security->generatePasswordHash(
            'something'
        ),
    ],
];
由于我们已将特性 getFixture() 作为公共的导入,我们可以通过 $I->getFixture('user') 以类似我们在单元测试中所做的方式访问固定装置。
提示
如果需要加载额外的固定装置,你可以类似地公开 FixtureTrait 特性中的 loadFixtures() 方法,并在你的测试中直接使用它。
最后一步是在 Codeception 配置中加载模块:
# tests/codeception/functional.suite.yml
modules:
    enabled:
      - ...
      - tests\codeception\_support\FixtureHelper
在运行 codecept build 之后,当在 _beforeSuite() 和 _afterSuite() 方法中运行测试时,固定装置将被自动加载。
功能测试的陷阱
一句话建议是,官方文档中关于功能测试以及不能测试的内容有很多信息。
最重要的是了解用于执行测试的底层技术;PHPBrowser 实际上是一个强大的工具,但整个功能测试不依赖于像正常客户端-服务器情况那样存在的 Web 服务器,因此你的应用程序和功能测试将在相同的内存空间中运行。
提示
通常情况下,内存会在_after()方法执行期间被清理,但请记住,如果您看到任何测试失败,请记住在开始怀疑自己的理智之前,单独执行测试文件。
对 REST 接口的功能测试
到目前为止,您已经看到了已经实现的内容,可以开箱即用的功能,以及一些额外的功能,如固定装置。
现在让我们看看测试 REST 接口涉及什么;Codeception 中可用的默认功能测试是由 PHPBrowser 执行的,与之交互的接口相当有限,只能用来处理和与由 Web 服务器输出的标记进行交互。Codeception 提供的 REST 模块是我们所希望的。
仅举几个可用的功能为例,您将拥有设置和读取头部的函数,例如seeHttpHeader()和haveHttpHeader(),以及调用针对我们接口的 HTTP 请求的特定方法,例如sendGET()、sendPUT()和sendOPTIONS()。
特别针对我们的用户接口,我们的测试将分为两部分:
- 
对实际功能——认证和与应用程序的交互进行的测试 
- 
一些额外的测试以确保我们公开了正确的端点 
现在,考虑到这一点,让我们开始查看配置部分;在functional.suite.yml文件中,只需添加 REST 模块并按照以下代码进行配置:
# tests/codeception/functional.suite.yml
modules:
    enabled:
      - Filesystem
      - Yii2
      - REST
      - tests\codeception\_support\FixtureHelper
    config:
        Yii2:
            configFile: 'codeception/config/functional.php'
        PhpBrowser:
            url: 'http://basic-dev.yii2.sandbox'
        REST:
            url: 'http://basic-dev.yii2.sandbox/v1/'
最后一行非常重要,因为我们最终将只通过指定端点来调用,而不需要命名模块基本路径。显然,如果您需要测试多个 REST 端点,您需要相应地调整。
现在,我们再次需要运行codecept build命令,以便在开始运行测试之前准备好一切。这个命令,如之前所见,将合并所有模块的方法到我们的 actor 类中(在这种情况下是FunctionalTester)。
让我们使用以下命令生成我们的新测试文件:
$ cd tests/
$ ../vendor/bin/codecept generate:cept functional UserAPICept
Test was created in UserAPICept.php
现在我们有了文件,我们可以开始实现我们的测试:
<?php
// tests/codeception/functional/UserAPICept.php
$I = new FunctionalTester($scenario);
$I->wantTo('test the user REST API');
我们从初始化FunctionalTester和定义测试范围开始文件。
定义 API 端点
现在是时候实现我们的 API 端点测试了,我们需要定义这些测试将是什么样子,并做出我们的架构决策,如果在此之前还没有做出这些决策。
我们希望提供给与我们的 API 交互的客户的基礎交互能力是获取用户信息,并具有修改信息的能力,特别是更改密码的能力。
客户通常会知道用户名和密码。由于我们的更新方法将利用用户的 ID,我们需要找到一种方法让客户提前获取它。根据您决定使用的认证协议类型,您可以选择在认证发生后立即返回它,否则您需要找到不同的方法。
正如你稍后将会看到的,你将使用可用的最简单的认证方法,即 HTTP 基本认证,这意味着我们所有的请求都需要在头中发送用户名和密码。通过这样做,我们显然不能在响应中返回用户 ID,因为这应该包含对调用的答案,而不是认证头,因此我们可以决定提供一个“按用户名搜索”的端点。这将清楚地使用户名成为数据库中的唯一字段,但这不是一个问题,而是一个如果你提供用户创建界面时需要考虑的问题。
现在,我们有以下端点需要测试:
- 
GET users/search/<username>:这个端点用于检索用户的 ID。
- 
GET users/<id>:这个端点用于检索与用户关联的任何其他信息。
- 
PUT users/<id>:这个端点用于更新密码。
实现 API 测试
由于我们的密码在 fixtures 中以加密形式传递,我们需要在测试中硬编码它们,以便正确地进行认证。
这不是一种好的做法,因为我们将会使维护变得更加困难。另一方面,如果事情变得更加复杂,我们可能想要重构代码并找到一个更好、更统一的解决方案:
$userFixtures = $I->getFixture('user');
$user = $userFixtures['basic'];
$userPassword = 'something';
现在我们已经了解了一些关于用户的基本信息,我们可以尝试获取其 ID 并检查其认证是否完全有效:
$I->amGoingTo('authenticate to search for my own user');
$I->amHttpAuthenticated($user['username'], $userPassword);
$I->sendGET('users/search/'.$user['username']);
第一步是准备请求,它由Authorization头和实际请求组成。我们不需要显式生成Authorization头,因为我们有一个由amHttpAuthenticated()提供的抽象,它会为我们完成这项工作。
然后将头与 GET 请求一起发送到我们的端点;注意 URL 省略了通常用于前缀 API 的/v1/部分:
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
$I->seeResponseContains($user['username']);
$I->seeResponseContains('password');
$I->seeResponseContains('id');
一旦我们发送了请求,我们就可以开始分析响应并对它进行各种断言:
$userId = $I->grabDataFromJsonResponse('id');
最后,我们从响应中获取用户 ID,以便之后可以重用它。
下一步是获取用户自己的信息,已知他们的 ID,这看起来特别简单易行:
$I->amGoingTo('ensure I can fetch my own information while being authenticated');
$I->amHttpAuthenticated($user['username'], $userPassword);
$I->sendGET('users/'.$userId);
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
$I->seeResponseContains($user['username']);
$I->seeResponseContains('password');
$I->seeResponseContains('id');
作为最后一步,我们保留了更新密码和确保新密码按预期工作的测试:
$I->amGoingTo('update my own password');
$I->amHttpAuthenticated($user['username'], $userPassword);
$newPassword = 'something else';
$I->sendPUT(
    'users/' . $userId,
    ['password' => $newPassword, 'authkey' => 'updated']
);
$I->seeResponseIsJson();
$I->seeResponseContains('true');
$I->seeResponseCodeIs(200);
$I->amGoingTo('check my new password works');
$I->amHttpAuthenticated($user['username'], $newPassword);
$I->sendHEAD('users/'.$userId);
$I->seeResponseIsJson();
$I->seeResponseContains($user['username']);
$I->seeResponseCodeIs(200);
注意
请注意,由于测试的长度,我们将把它们全部放在一个文件中,因为这不会影响它们的可读性,但你当然可以将它们分成更多的 CEST 文件,以更简洁和逻辑的方式聚合它们。
这应该是你需要了解的所有内容。我们可以检查在这个阶段,没有任何测试会通过,并在章节结束时,我们将确保所有测试最终都能通过。
注意
还要注意,没有必要每次都调用amHttpAuthenticated()来发送认证头,因为它在 CEPT 文件中的第一次调用后将被缓存,并且只有在需要更新头时才需要。
现在我们已经看到编写功能测试是多么容易,我可以将创建额外测试的任务留给你。如果你想,你可以先检查其他接口是否未被暴露,例如请求所有用户的列表以及检索或更改他们的密码的能力。
在本章的下一节中,我们将通过查看 Yii 2 提供的一些新特性来关注事物的实现方面。
使用 Yii 2 创建 RESTful Web 服务
需要记住的是,根据定义,REST Web 服务是一种无状态服务,这将在我们测试事物和处理我们需要 POST 或 GET 的信息时产生一些要求。
Yii 2 版本带来的重大进步体现在内置的 REST 类上,这些类可以立即提供第三方实现曾经提供的解决方案。
这意味着我们将不得不对我们迄今为止所取得的成果进行一些修改;应用程序的 REST 部分将作为一个独立的模块来开发,这将使我们能够扩展它并包含其逻辑。因此,路由也将相应地重新排列。
在查看 Yii REST 功能能够做什么之前,我们首先需要快速了解 Yii 中的模块,我们将使用它来开发要测试的 API。
在 Yii 中编写模块化代码
如果你自从开始使用 Yii 以来从未使用过模块,那么我认为现在是时候了。目前,模块的使用非常简单直接,并且它们将帮助你保持代码在架构上的良好组织,并与其他应用程序组件分离。
模块是包含模型、视图、控制器和其他软件组件的独立软件单元,一旦安装到主应用程序中,最终用户就可以访问控制器。因此,模块被认为是微型应用程序,唯一的区别是它们不能独立存在。例如,论坛或管理区域可以开发为模块。
模块也可以由子模块组成;一个论坛可能有一个包含所有逻辑和接口以管理和定制主论坛模块的管理子模块。
模块的结构可能相当复杂;我总是强烈建议在决定将所有内容都放在同一个模块下之前进行架构分析,就像你需要质疑将所有代码都放在同一个控制器下的选择一样。始终牢记,你应该能够在一年后理解你的代码。
使用 Gii 创建模块
使用 Yii 模块开发 REST 接口是实现 API 版本化的最简单方法。这样,我们可以轻松地切换并制作 API 的改进版本,同时仍然以最小的维护支持旧版本,直到完全弃用。
因此,我们将从创建模块开始,使用 Gii 代码生成器的 Web 界面。如果您跳过了一些页面,该配置在第四章中可用,使用 PHPUnit 进行隔离组件测试,您在那里看到了如何使用它创建模型。
现在,我们将看到如何创建一个模块,以及这在生成代码方面意味着什么。
因此,前往 Gii 应用程序,在我的情况下是http://basic.yii2.sandbox/gii,如果配置了登录,请登录,然后单击模块生成器按钮。
我们必须填写的只有两个字段:
- 
模块类:这代表模块的主要命名空间类名,将被设置为 app\modules\v1\Module。
- 
模块 ID:这将(自动)设置为 v1。
看看下面的截图:

Gii 代码生成工具中的模块生成页面
您可以通过取消选择相关的复选框来避免创建视图,因为我们不需要它。我们将对已生成的内容进行更多修改。
准备好后,单击生成按钮。
备注
如果您的应用程序最终比这里更复杂,您仍然有一些选择。
您可以简单地调整模块的路由,正如在www.yiiframework.com/doc-2.0/guide-runtime-routing.html#adding-rules-dynamically文档中所述。
否则,您可以在模块内创建一个模块(例如,一个名为api的容器模块,它将包含各种版本作为模块,如v1、v2等)。只需记住在创建时正确命名空间即可。这通常是我在代码组织方面推荐的方法。
下一步是配置模块以便使用它,然后我们将看到如何将其转换为 REST 模块。
在 Yii 2 中使用模块
现在我们已经准备好了模块的基本代码,我们需要看看我们如何使用它。
理想情况下,创建的模块可以立即使用,无需太多麻烦,这在您希望创建可重用和当然模块化代码的环境中非常有帮助。
真正需要的唯一步骤是指导 Yii 存在一个新的模块,然后它将负责在正确的时间自动加载和调用我们的模块控制器。
因此,让我们转到位于/config/web.php的配置文件,并添加以下代码:
// /config/web.php
$config = [
    // ...
    'modules' => [
        'v1' => [
            'class' => 'app\modules\v1\Module',
        ],
    ],
    // ...
];
这样,您就准备就绪了。为了将新创建的模块转换为 REST 控制器,需要一些额外的更改,我们将立即探讨。
将我们的控制器转换为 REST 控制器
这个备受期待的 Yii 2 功能让您能够以清晰和简单的方式创建 REST 接口。
我们将要继承的 REST 控制器将处理我们的模型,无需太多配置,即使需要配置,也是相当直接且容易记住的。
我们的第一步是创建UserController,它将处理User模型。
让我们先定义命名空间和我们将在新控制器中使用的基本类:
// /modules/v1/controllers/UserController.php
namespace app\modules\v1\controllers;
use app\models\User;
use yii\rest\ActiveController;
如我们明显看到的,我们将使用User模型,并在其之上使用 REST ActiveController。这个控制器是魔法发生的地方,我们将在稍后展示它是如何工作的。
现在,让我们实现实际的类:
// /modules/v1/controllers/UserController.php
class UserController extends ActiveController
{
    public $modelClass = 'app\models\User';
}
在这一点上,你需要做的只是定义 REST 控制器将要管理的模型类,然后就可以了。
yii\rest\ActiveController是处理 Active Records 模型(如我们的User模型)的控制器。如果你要管理自定义类(非活动记录),这些类不连接到数据库或连接到自定义数据源(例如,在线服务),你可以使用ActiveController继承的类,即yii\rest\Controller。
ActiveController的美丽之处在于它提供了已经实现并立即可用的操作,包括:
- 
index,通过GET或HEAD访问,返回模型及其(数据库绑定)属性的列表
- 
view,通过GET和HEAD访问,返回单个模型的详细信息
- 
create,只能通过POST访问,并允许你创建一个新的模型
- 
update,通过PUT或PATCH访问,并执行其名称所表示的操作
- 
delete,用于删除模型,可以使用DELETE调用
- 
OPTIONS,最后,你可以调用它来查看所有允许的 HTTP 方法
在你可以自己实现的操作中,你将处理原始模型,这些模型默认以 XML 和 JSON 格式渲染(取决于随请求发送的Accept头)。
我们知道我们需要修改公开端点的列表,我们将在稍后展示如何做到这一点。
在达到那里之前,还有一些其他问题需要先解决,特别是访问凭证,因为我们不希望任何人未经认证就能访问我们的端点。
添加访问检查和安全层
你可能已经问过自己如何防止未经认证的用户使用应用程序的某些端点。例如,我们可能只想在客户端认证并授权的情况下,允许客户端访问用户端点。
授权和认证发生在两个不同的阶段。
授权在控制器级别通过简单地重写checkAccess()方法并执行适当的检查来完成,这可能包括确定用户是否已认证以及他/她是否活跃,如果用户模型中存在此标志的话。
在我们的情况下,我们可以在控制器中简单地添加以下方法:
// /modules/v1/controllers/UserController.php
public function checkAccess($action, $model = null, $params = [])
{
    if (\Yii::$app->user->isGuest) {
        throw new UnauthorizedHttpException;
    }
}
这意味着如果用户是访客,我们将引发一个401响应。
Yii 会自动在每次请求中调用该方法,正如我们可以在其父类\yii\rest\ActiveController中的actions()方法中看到的那样:
class ActiveController extends Controller
{
    // ...
    public function actions()
    {
        return [
            'index' => [
                'class' => 'yii\rest\IndexAction',
                'modelClass' => $this->modelClass,
                'checkAccess' => [$this, 'checkAccess'],
            ],
            // ...
        ];
    }
    // ...
}
相反,认证是以完全不同的方式进行的,并且取决于实现和你在应用程序中想要实现的安全级别。
就其本身而言,如果你没有深入接触这个参数,你有不同的可能性,它们是:
- 
HTTP 基本认证:这基本上是你通过使用 htpasswd 并相应地配置 Apache 所拥有的相同认证方式,是可用的最简单的一种,但需要将用户名和密码以每个请求的头部信息发送。这要求通信通过 HTTPS 进行,这是显而易见的。 
- 
查询参数:在这里,客户端已经拥有一个访问令牌,它将以查询参数的形式发送到服务器,作为 https://server.com/users?access-token=xxxxxxx,如果你没有能力在请求中发送额外的令牌,这相当方便。
有一些其他方法,它们结合了不同的技术以及非对称和对称加密或不同类型的手 shake 来认证客户端。其中最著名的一个,尽管可能很复杂,是OAuth 2,由于它被视为一个框架而不是一个定义良好的协议,因此有不同的实现。大多数知名的社会网站,如 Google、Twitter、Facebook 等,都实现了它。它的维基百科页面,可在en.wikipedia.org/wiki/OAuth找到,提供了一些有用的链接和参考资料,可以帮助你进一步探索。
由于加密和认证协议超出了本书的范围,我决定使用最简单的解决方案,这无论如何都会给我们足够的提示,告诉我们如何在需要实现更健壮或更复杂的解决方案时着手。
构建认证层
由于 Yii 默认使用会话,这会违反 RESTful 服务器无状态约束(根据 Fielding 论文www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm#sec_5_1_3),我们希望在模块的init()方法中禁用会话:
// /modules/v1/Module.php
public function init()
{
    parent::init();
    // custom initialization code goes here
    // disable the user session
    \Yii::$app->user->enableSession = false;
}
在 Yii 中,实际的认证是通过可用的认证器行为完成的。
Yii 提供了四种不同的认证器,它们是:
- 
HttpBasicAuth:这用于 HTTP 基本认证,我们将在这里使用
- 
QueryParamAuth:这用于查询参数认证
- 
HttpBearerAuth:这用于 OAuth 和类似方法
- 
CompositeAuth:这是一种使用多个级联认证方法的方式
再次打开我们的UserController,让我们定义我们想要使用的方法:
// /modules/v1/controllers/UserController.php
public function behaviors()
{
    $behaviors = parent::behaviors();
    $behaviors['authenticator'] = [
        'class' => HttpBasicAuth::className(),
    ];
    return $behaviors;
}
如果你针对这个实现运行测试,你将遇到问题,使它们通过;默认实现将使用findIdentityByAccessToken()并使用头部的$username部分作为访问令牌。所以,实际上并没有进行密码检查。
HTTP 基本认证定义了,除了你的请求外,你还需要发送一个包含'Basic '.base64($username.':'.$password);的Authorization头。
如在HttpBasicAuth类的文档github.com/yiisoft/yii2/blob/master/framework/filters/auth/HttpBasicAuth.php#L55中所述,你需要覆盖$auth属性,以便以你想要的方式执行密码认证。
正如你所看到的,findIdentityByAccessToken()不是我们需要的那个方法,我们也有单元测试明确地说明了这一点。解决这个问题的最佳方式是在以下方式中直接在行为的定义中添加我们的认证器方法:
// modules/v1/controllers/UserController.php    
public function behaviors()
{
    $behaviors = parent::behaviors();
    $behaviors['authenticator'] = [
        'class' => HttpBasicAuth::className(),
        'auth' => function ($username, $password) {
                /** @var User $user */
                $user = User::findByUsername($username);
                if ($user && $user->validatePassword($password)) {
                    return $user;
                }
            }
    ];
    return $behaviors;
}
如文档中所述,auth属性应该是一个函数,它期望$username和$password作为实际参数,并在认证验证通过时返回用户身份。
使用这种方法,我们认证和授权方案的实现应该就完成了。
修改现有操作
现在我们已经限制了其他用户的访问,我们需要重新实现视图和更新操作,以便只允许当前登录的用户查看他/她的详细信息,并允许他/她更新密码。如果你已经开始实现这些操作,这还不够,因为父类yii\rest\Controller已经实现了所有默认操作,所以我们需要重新定义它们的配置,这发生在actions()方法中:
public function actions()
{
    $actions = parent::actions();
    unset($actions['view'], $actions['update']);
    return $actions;
}
一旦我们取消这两个操作的设置,我们的覆盖方法将自动被选中,无需做太多其他事情:
public function actionView($id)
{
    if ($id == Yii::$app->user->getId()) {
        return User::findOne($id);
    }
    throw new ForbiddenHttpException;
}
视图操作只是对用户的 ID 进行检查,并返回 403 错误,而更新操作可以类似于以下代码:
public function actionUpdate($id)
{
    if (! Yii::$app->request->isPut) {
        return new HttpRequestMethodException();
    }
    /** @var User $user */
    $user = User::findIdentity($id);
    if (Yii::$app->request->post('password') !== null) {
        $user->setPassword(Yii::$app->request->post('password'));
    }
    return $user->save();
}
在更新中,我们只允许更改密码,之后我们返回save方法的价值。我们本可以返回一个更全面的状态,但对我们来说,这已经足够好了。
注意
实际上,如果请求不是 PUT,我们就不需要添加检查,因为当前内部实现默认就限制了它。我们将在第八章中看到,如何使用覆盖率报告信息来解决这个问题,即分析测试信息。
添加带有参数的新端点
在我们完成所有这些之后,如果我们尝试在UserAPICept上运行测试,我们将看到它将在第一个sendGET('user/search')命令时立即失败。
实现actionSearch()新方法不会有问题,并且可以按以下方式实现:
public function actionSearch($username)
{
    /** @var User $user */
    $user = User::findByUsername($username);
    if ($user && $user->id === Yii::$app->user->getId()) {
        return $user;
    }
    throw new ForbiddenHttpException;
}
重要的是要注意我们将如何定制路由以“合规”的方式添加这个新操作。
切换到位于 config/web.php 的配置文件,让我们首先将搜索操作添加到允许的方法列表中:
'only' => ['view', 'update', 'search', 'options']
用于创建路由的 UrlRule 类公开了一些你可以配置的变量,既可以扩展也可以完全重新定义模式和令牌的结构。首先是 extraPatterns 和 patterns。令牌可以在模式中使用,并代表传递给操作的参数。
在 Yii 术语中,一个模式是由允许的 HTTP 方法、实际要识别的资源结构以及要调用的相应操作组成的元组。以下是一个示例:
'GET search/{username}' => 'search'
令牌是一个或多个参数,它可以像正则表达式一样复杂。在先前的示例中,{username} 是一个令牌,可以按照以下代码所示进行定义:
'{username}' => '<username:\\w+>'
我们最终的规则列表将最终看起来像以下代码:
// config/web.php
'rules' => [
    [
        'class' => 'yii\rest\UrlRule',
        'controller' => 'v1/user',
        'tokens' => [
            '{id}' => '<id:\\d[\\d,]*>',
            '{username}' => '<username:\\w+>'
        ],
        'extraPatterns' => [
            'GET search/{username}' => 'search',
        ],
        'only' => ['view', 'update', 'search', 'options']
    ],
    '/' => 'site/index',
    '<action:\w+>' => 'site/<action>',
    '<controller:\w+>/<id:\d+>' => '<controller>/view',
    '<controller:\w+>/<action:\w+>/<id:\d+>' => '<controller>/<action>',
    '<controller:\w+>/<action:\w+>' => '<controller>/<action>',
]
首先要注意的是,我们不得不重新定义所有令牌,而不是像我们在 extraPatterns 中所做的那样添加它们。
在规则列表中,我们首先定义了 REST 接口规则,然后是其他任何规则,因为规则是从上到下读取的,第一个找到的匹配规则将被捕获。这意味着特定的规则必须放在顶部,而通用的捕获所有规则则放在底部。
上述配置将被传递给 urlManager,如官方指南www.yiiframework.com/doc-2.0/guide-runtime-routing.html#using-pretty-urls中所述:
'urlManager' => [
    'enablePrettyUrl' => true,
    'showScriptName' => false,
    'enableStrictParsing' => true,
    'rules' => [ ... ]
]
现在,我们可以使用以下命令来检查测试是否通过:
../vendor/bin/codecept run functional UserAPICept.php
Codeception PHP Testing Framework v2.0.8
Powered by PHPUnit 4.5-ge3692ba by Sebastian Bergmann and contributors.
Functional Tests (1) ------------------------------------------------
Trying to test the user REST API (UserAPICept)                     Ok
---------------------------------------------------------------------
Time: 9.8 seconds, Memory: 14.50Mb
OK (1 test, 18 assertions)
摘要
在本章中,你看到了许多内容,例如如何编写基本的功能测试、如何测试 REST 接口以及实现方面的内容。鉴于这里浓缩的知识量,你可能以后会再次阅读本章,给自己足够的时间更详细地实验单个功能,并适应你的喜好。
在下一章中,你将看到如何为你的接口创建验收测试,这将克服使用 PHPBrowser 的一些限制。
第七章。享受浏览器测试的乐趣
我们终于到达了测试的最后阶段:验收测试。这是使用 Codeception 在 Yii 中测试应用程序的最高方式。
正如我们在初始章节中看到的,功能测试和验收测试在形式和实现上相当相似,所以你在这个章节中不会看到任何特别新的内容。
掌握我们将要创建的测试的本质非常重要。我们可以从第六章,测试 API – PHPBrowser 来拯救中回忆起,功能测试用于确保我们从更高的角度对所构建的内容进行技术正确性验证,这比单元测试更为重要。而验收测试是确保在所有内容实施并整合后,最初定义的验收标准仍然有效度的最佳方式。
在本章中,我们将回顾现有的测试,安装和配置 Selenium,然后实现一个小功能,将所有已经完成的工作结合起来。
在本章中,我们将讨论两个主要主题:
- 
介绍 Selenium WebDriver 
- 
创建验收测试 
介绍 Selenium WebDriver
Yii 随带了一些验收测试。它们涵盖了我们已经看到的相同元素。这两个测试之间的唯一区别是技术性的,通过查看配置,你可以看到它们已经被配置为使用 PHPBrowser 运行。这种设置可能对你来说已经足够好,或者甚至更好,因为 PHPBrowser 的运行速度比可用的验收测试套件要快。
除了我们在第六章,测试 API – PHPBrowser 来拯救中已经介绍过的 PHPBrowser 之外,Codeception 可以与其他测试套件一起使用,这些套件可以执行更真实的端到端测试,包括 JavaScript 交互。
你可以选择的两个选项是 Selenium WebDriver 和 PhantomJS。我们不会涉及 PhantomJS,但你应该知道它是一个无头浏览器测试套件,它使用 WebDriver 接口定义。
Selenium WebDriver 也被称为 Selenium 2.0 + WebDriver。与 Cucumber 一起,它可能是最知名的端到端测试工具。它们的使用已经被一些大公司,如 Google,所改进。它们稳定且功能丰富。
这是对 Selenium 1.0 的某种自然演变,Selenium 1.0 存在一些限制,例如使用 JavaScript 与网页交互。因此,它运行在 JavaScript 沙盒中。这意味着,为了绕过同源策略,它必须与 Selenium RC 服务器一起运行,这导致了一些与浏览器设置相关的问题。
现在 Selenium 服务器已经取代了 RC,同时保持向后兼容并原生支持 WebDriver。
WebDriver 使用浏览器的本地实现与之交互。这意味着它可能并不总是适用于特定的语言/设备组合。然而,它提供了在不进行模拟的情况下控制页面的最佳灵活性。
Codeception 使用由 Facebook 开发的 PHP 实现,称为 php-webdriver;其源代码和问题跟踪器可以在 github.com/facebook/php-webdriver 找到。
在其最简单实现和配置中,Selenium 服务器仅作为特定机器上的服务监听调用,并在请求执行测试时启动浏览器。
因此,作为第一步,我们需要安装 Selenium 服务器,运行它,在 Codeception 中配置它,调整现有的测试以便它们能够与之协同工作,然后向其中添加新的测试。
安装和运行 Selenium 服务器
从 1.7 版本开始,Codeception 包含了现成的 php-webdriver 库。
如文档所述,这些文档可以从 Codeception (codeception.com/11-20-2013/webdriver-tests-with-codeception.html) 或 Selenium 的官方网站 (docs.seleniumhq.org/docs/03_webdriver.jsp) 获取,您需要下载服务器二进制文件,然后在您打算运行浏览器的机器上运行它。
访问 www.seleniumhq.org/download/ 并下载软件的最新版本。在我的情况下,它将是 selenium-server-standalone-2.44.0.jar。
你保存它的位置并不重要,因为一旦它启动,它的服务器将监听任何网络接口:
$ java -jar selenium-server-standalone-2.44.0.jar
22:03:31.892 INFO - Launching a standalone server
22:03:31.980 INFO - Java: Oracle Corporation 24.65-b04
22:03:31.980 INFO - OS: Linux 3.17.1 amd64
22:03:32.002 INFO - v2.44.0, with Core v2.44.0\. Built from revision 76d78cf
...
配置 Yii 以与 Selenium 协同工作
为了让 Codeception 自动获取并使用 WebDriver,我们需要调整我们的验收套件配置:
# tests/codeception/acceptance.suite.yml
class_name: AcceptanceTester
modules:
    enabled:
        - WebDriver
    config:
        WebDriver:
            url: 'http://basic-dev.yii2.sandbox'
            browser: firefox
            host: 192.168.56.1
            restart: true
            window_size: 1024x768
这是一个简单的过程。我们需要用 WebDriver 替换 PHPBrowser 模块,并对其进行配置。
- 
url(必需):这是用于连接到您的应用程序以执行测试的主机名。
- 
browser(必需):这将指定您想要使用的浏览器。还有一些其他驱动程序适用于手机(Android 和 iOS),更多关于这些的信息可以从在线 Selenium 文档中获得,该文档可在docs.seleniumhq.org/docs/03_webdriver.jsp#selenium-webdriver-s-drivers找到。
- 
host:此键指定将运行 Selenium 服务器的机器。默认情况下,它将连接到您的本地主机。例如,我正在使用 VirtualBox 主机机的 IP 地址。您也可以指定端口号,默认情况下,它将使用4444。
- 
restart:这告诉 WebDriver 在执行测试时重置会话。如果你不希望状态从一个测试传递到另一个测试,这特别有用。例如,当你需要(重新)设置 cookies 来测试自动登录功能时,可以使用此功能。
- 
window_size:这仅指定窗口的大小。
还有其他一些选项,其中一些在测试多个浏览器时非常有用。特别是,你可以设置 Selenium 2.0 的期望能力,例如能够传递浏览器的特定配置文件(在执行回归测试时非常有用)等等。有关 WebDriver 模块的信息,尽管不是我想看到的那么多,可以在 Codeception 文档页面上找到,网址为 codeception.com/docs/modules/WebDriver。
实现由 WebDriver 引导的测试
在我们开始实现将挂钩到 API 的接口之前,该 API 我们在 第六章 中实现,测试 API – PHPBrowser 来拯救,查看现有的验收测试并查看是否有任何新内容需要我们考虑将会非常有用。
你将找到四个测试:HomeCept、AboutCept、LoginCept 和 ContactCept。
如前所述,语法并不罕见,我们可以看到对底层结构的了解程度比我们已涵盖的功能测试要有限。
我们需要再次强调的重要事情是,我们的 AcceptanceTester 可以在页面上执行的所有操作,例如 click()、fillField(),以及它可以执行的所有断言,例如 see()、seeLink() 等,都接受所谓的定位器作为其实际参数之一。
定位参数可以是字符串或数组。
当作为字符串或所谓的模糊定位器(在 Codeception 术语中称为)传递时,它将通过正式走过一系列步骤来猜测你正在寻找的内容。如果你编写 click('foo'),那么它将执行以下操作:
- 
它试图找到具有 ID #foo的元素。
- 
它试图找到类 .foo。
- 
然后,它将其解释为 XPath 表达式。 
- 
最后,它将抛出 ElementNotFound异常。
当使用数组表示法或严格定位器时,你可以更加具体。
- 
['id' => 'foo']匹配<div id="foo">
- 
['name' => 'foo']匹配<div name="foo">
- 
['css' => 'input[type=input][value=foo]']匹配<input type="input" value="foo">
- 
['xpath' => "//input[@type='submit'][contains(@value, 'foo')]"]匹配<input type="submit" value="foobar">
- 
['link' => 'Click here']匹配<a href="google.com">Click here</a>
- 
['class' => 'foo']匹配<div class="foo">
以下示例取自 Codeception 文档,可以在codeception.com/docs/modules/WebDriver找到。
这清楚地说明了如何与网页进行交互。现在,其他重要的部分可以在现有的测试中找到,例如LoginCept和ContactCept。在这里,在断言验证错误的存在之前,我们有以下条件语句:
if (method_exists($I, 'wait')) {
    $I->wait(3); // only for selenium
}
Selenium 引入了两种等待类型:隐式等待和显式等待。这会导致从服务器获取信息,然后对这些信息进行解释和渲染。
隐式等待可以在acceptance.suite.yml文件中进行配置,并且它会静默地告诉 Selenium 在它寻找的元素不可立即获得时,每隔X秒进行轮询。默认情况下,没有设置隐式等待。
显式等待与前面的代码片段类似。执行简单的$I->wait(X)会触发sleep(),并允许浏览器执行所需的操作。例如,它可以帮助浏览器完成动画或完成从服务器端获取和操作数据的操作。
您还有其他等待某种东西的方法,其中一些方法可能更加积极主动,例如waitForElement()、waitForElementChange()、waitForElementVisible()和waitForElementNotVisible()。所有这些方法都接受一个定位器,使用上述格式,以及以秒为单位的超时作为参数。我们将在稍后看到如何使用这些方法。
WebDriver Codeception 模块还提供了其他方法,您可以在调试测试的同时使用这些方法,以防事情没有按照您的预期进行。
现在,让我们尝试运行可用的测试并查看它们通过:
$ ../vendor/bin/codecept run acceptance
Codeception PHP Testing Framework v2.0.9
Powered by PHPUnit 4.6-dev by Sebastian Bergmann and contributors.
Acceptance Tests (5) --------------------------------------------------------------
Trying to ensure that about works (AboutCept) Ok
Trying to ensure that contact works (ContactCept) Ok
Trying to ensure that home page works (HomeCept) Ok
Trying to ensure that login works (LoginCept) Ok
--------------------------------------------------------------
Time: 38.54 seconds, Memory: 13.00Mb
OK (4 tests, 23 assertions)
注意
根据您将用于运行这些测试的机器,它们的速度将会有所变化,尽管它永远不会像执行单元测试那样快,这主要是因为整个浏览器堆栈必须启动才能运行这些测试。
在执行测试的过程中,您将简要看到浏览器打开和关闭几次。一切应该在最后看起来都很好,如果不好,那么请查看tests/codeception/_output/文件夹。在这里,您将找到在失败时捕获的页面标记和截图。这种调试行为在使用 PHPBrowser 进行功能测试时也存在。
创建验收测试
现在我们已经看到了使用 Selenium WebDriver 的验收测试的结构,我们可以开始整合上一章完成的工作,并开始添加我们想要的测试。
对于这类测试,通常标记的定义留给实现布局的人,您需要定义您界面的功能,实现测试,然后实现标记,并在需要时添加 JavaScript 功能。在完成这些操作后,您将添加 DOM 交互的具体细节。
了解有多少开发者将前端功能定义留到最后一刻,"测试先行"将迫使您改变工作方式,尽可能详细地预测前方的情况,并立即发现设计的任何关键方面。
我们将尝试实现一些足够简单的东西,让您可以从头开始,然后您可以稍后对其进行改进或扩展。我们知道我们使用的 HTTP Basic Auth 不允许有状态的登录。因此,我们将在 JavaScript 中保持某种类型的会话对象来模拟它。如何实现这一点可以从我们为用户 API 编写的测试中得知。这是最佳的实际文档。
因此,我们的场景可以这样描述:
I WANT TO TEST MODAL LOGIN
I am on homepage
I see link 'Login'
I don't see link 'Logout' # i.e. I'm not logged
I am going to 'test the login with empty credentials'
.I click 'Login'
I wait for modal to be visible
I submit form with empty credentials
I see 'Error'
I am going to 'test the login with wrong credentials'
I submit form with wrong credentials
I see 'Error'
I am going to 'test the login with valid credentials'
I submit form with valid credentials
I don't see the modal anymore
I see link 'Logout'
I don't see link 'Login'
I am going to 'test logout'
I click 'Logout'
I don't see link 'Logout'
I see link 'Login'
上述语法是明确从我们的测试生成的文本版本中提取的。
实现模态窗口
通过 Bootstrap,这是一个与基本应用程序捆绑的默认框架,实现模态窗口的工作将变得简单。
模态窗口由几乎预先确定的标记和额外的 JS 组成,它提供了交互部分,从而将其连接到界面的其余部分。我希望其实现的简单性能让您专注于其背后的目标。
以下代码是故意从 Bootstrap 文档中提取的,该文档可以在getbootstrap.com/javascript/#modals找到。由于模态窗口可以从网站的任何部分打开而不必转到登录页面,因此我们必须将其添加到整体布局模板中:
    <!-- views/layouts/main.ph -->
    </footer>
    <div class="modal fade" id="myModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">×</span><span class="sr-only">Close</span></button>
                    <h4 class="modal-title">Login</h4>
                </div>
                <div class="modal-body">
                    <?= $this->render('/site/login-modal.php', ['model' => Yii::$app->controller->loginForm]); ?>
                </div>
            </div>
        </div>
    </div>
<?php $this->endBody() ?>
<!-- ... -->
如您所见,我们已经将模态形式移动到了一个独立的模板中,该模板将作为变量接收表单的模型,保持一切自我包含和组织。请注意模型从哪里获取其值。我们将在实现控制器时讨论这个问题。
login-modal.php 模板只是原始 login.php 模板的复制品,可以在同一目录下找到,标题中没有 H1,也没有“记住我”复选框。我们只需要添加一个占位符来显示来自 API 的错误。这样做是为了通知和调试。
<!-- views/site/login-modal.php
<!-- ... -->
<div class="alert alert-danger alert-dismissible fade in">
    <button type="button" class="close" data-dismiss="alert"><span aria-hidden="true">×</span><span class="sr-only">Close</span></button>
    <p class="error"></p>
</div>
<!-- ... -->
我们可以将这个片段放在复制的标记的第一段之后。
使服务器端工作
正如我们之前所说的,我们希望模态窗口在所有地方都可用。我们将通过在 SiteController 中保存一个公开可访问的属性来实现这一点,这样我们就可以从视图中检索它。记住,如果你是从 Yii 1 过来的,那么现在视图是独立的对象,而不是控制器的一部分。
让我们使用 init() 方法来这样做:
// controllers/SiteController.php
public $loginForm = null;
public function init()
{
    $this->loginForm = new LoginForm();
}
一旦完成,我们可以无错误地加载我们的页面。
在下一步中,我们将添加与 JavaScript 的交互。
添加 JavaScript 交互
在本节中,我们将介绍一些内容。我们将讨论与模态的基本功能交互,与表单的交互,然后学习如何通过边缘情况和错误场景关闭一切。
与模态的交互将通过重用已经存在的位于菜单右上角的登录按钮来实现。我们将禁用它,但请记住,如果出现问题,它将提供回退兼容性。
模态窗口的基本打开和关闭是开箱即用的。我们只会在需要时触发它,例如在认证成功时。
让我们添加 JS 模块的第一个基本框架:
// web/js/main.js
var YII = YII || {};
对于我们应用的这部分,我们需要模块模式来创建一个自包含的应用。
YII.main = (function ($) {
    'use strict';
让我们先缓存所有我们在旅途中将要需要的 jQuery 元素:
    var $navbar = $('.navbar-nav.nav'),
        $modal = $('#myModal'),
        $modalBody = $modal.find('.modal-body'),
        $modalAlertBox = $modal.find('.alert').hide(),
        $modalError = $modalAlertBox.find('.error'),
        $CTALogin = $navbar.find('.login'),
一旦我们登录,我们将交换链接以一个“假”注销按钮:
        $CTALogout = $('<li class="logout"><a href="#">Logout</a></li>'),
我们需要一些数据字段来存储我们的登录信息并创建某种形式的会话:
        authorization = null,
        username = null,
        userID = null;
现在是脚本的主要部分,我们将初始化我们的点击和提交动作的事件监听器:
    /**
     * initialise all the events in the page.
     */
    (function init() {
        $navbar.append($CTALogout);
        $CTALogout.hide();
让我们先添加并隐藏我们的注销按钮;我们只会在登录成功时显示它,并定义它应该有的点击动作:
        $navbar.on('click', '.logout a', function (e) {
            e.preventDefault();
            // unset the user info
            authorization = null;
            username = null;
            userID = null;
            // restore the login CTA
            $CTALogout.hide();
            $CTALogin.show();
        });
我们需要禁用登录按钮的点击事件。否则,我们将被带到登录页面,而不是打开模态窗口:
        $navbar.on('click', '.login a', function (e) {
            e.preventDefault();
        });
模态触发事件是通过修改登录按钮的标记自动完成的。因此,导航到 views/layouts/main.php 并按照以下方式调整:
'label' => 'Login',
'url' => ['/site/login'],
'options' => [
    'data-toggle'=>'modal',
    'data-target'=> '#myModal',
    'class' => 'login'
]
接下来,我们将处理表单提交:
        $modalBody.on('submit', '#login-form', function (e) {
            e.preventDefault();
在禁用默认提交事件后,我们需要捕获用户名和密码,并将其保存以供将来使用:
            username = this['loginform-username'].value;
            // we don't care to store the password... sorta
            authorization = btoa(username + ':' + this['loginform-password'].value);
authorization 变量将保存我们准备发送的授权头:
            $.ajax(
                {
                    method: 'GET',
                    url: '/v1/users/search/' + username,
                    dataType: 'json',
                    async: false,
                    beforeSend: authorize,
                    complete: function (xhr, status) {
                        if (status === 'success') {
                            // save the user ID
                            userID = xhr.responseJSON.id;
                            // set the logout button
                            $CTALogin.hide();
                            $CTALogout.show();
                            // clear the status errors
                            $modalError.html('');
                            $modalAlertBox.hide();
                            // close the modal window
                            $modal.modal('hide');
                        }
                        else {
                            // display the error
                            $modalError.html('<strong>Error</strong>: ' + xhr.statusText);
                            $modalAlertBox.show();
                        }
                    }
                }
            );
        });
    })();
})(jQuery);
这段代码足够简单。在成功的情况下,我们保存用户 ID 以供后续调用,隐藏登录按钮并显示注销按钮,清除错误信息并隐藏模态窗口。否则,我们只显示错误信息。
beforesend 选项将由 authorize 函数初始化,该函数定义如下:
    /**
     * modifies the XHR object to include the authorization headers.
     *
     * @param {jqXHR} xhr the jQuery XHR object, is automatically passed at call time
     */
    function authorize(xhr) {
        xhr.setRequestHeader('Authorization', 'Basic ' + authorization);
    }
在这样做之后,我们不再需要其他任何与页面交互的东西。所以,让我们把所有东西都放在一起。
将一切整合起来
到目前为止,我们只需将我们的 JS 添加到页面中,然后完成我们的测试。为了将我们的文件添加到页面中,我们需要了解资产和资产包是什么。
处理 Yii 2 资产包
Yii 2 根本改变了处理资产的方式。它引入了资产包的概念。
资产包是脚本和样式的集合,与过去相比,它们具有更高的可配置性。
这个基本应用已经有了基本的实现。所以,让我们导航到 /assets/AppAsset.php 并看看内容是如何组织的:
<?php // assets/AppAsset.php
namespace app\assets;
use yii\web\AssetBundle;
class AppAsset extends AssetBundle
{
    public $basePath = '@webroot';
    public $baseUrl = '@web';
    public $css = [
        'css/site.css',
    ];
    public $js = [];
    public $depends = [
        'yii\web\YiiAsset',
        'yii\bootstrap\BootstrapAsset',
    ];
}
AppAsset 从 yii\web\AssetBundle 类扩展而来,它仅仅定义了一系列公共属性。
前两个属性,$basePath 和 $baseUrl,是最重要的。$basePath 定义了资产在公开可访问位置的位置,而 $baseUrl 定义了它们的资源如何链接到网页,即它们的 URL。
通过使用这两个属性,这个资产定义了所谓的“已发布资产”。换句话说,它定义了一个资产包,这些资产在公开可访问的位置可用。
您可以有“外部资产”,这些资产由外部位置的资源组成,以及“源资产”,这些资产不包含来自公开位置的资源。这些资产仅定义了一个 $sourcePath 属性,Yii 将它们复制到公开可访问的资产文件夹中,并相应地命名。
源资产通常由库和小部件提供,因此我们在这里不会涉及它们。建议将已发布的资产放入 web/ 文件夹中以将资产合并到页面或页面上。
在前面的示例中,您看到我们定义了资产依赖项,在我们的情况下,这是通过 jQuery 和 Bootstrap 来实现的。这正是我们为什么使用它们来开发主要的 JavaScript 模块。
最后,我们需要了解如何使用资产包来处理我们的标记。这可以通过查看模板视图的顶部来实现。为此,导航到 /views/layouts/main.php。在这里,我们可以看到这两行:
// views/layouts/main.php
use app\assets\AppAsset;
AppAsset::register($this);
请记住,虽然不特别建议,但将任何资产与特定布局关联的旧方法尚未被移除。这和 Yii 1 中的工作方式相同,即通过使用 registerCssFile() 和 registerJsFile()。
资产还有许多其他选项,例如压缩和编译 SASS 和 LESS 文件,使用 Bower 或 NPM 资产等。请访问文档页面,目前它处于良好的状态,并且相当全面,链接为 www.yiiframework.com/doc-2.0/guide-structure-assets.html。
在我们的工作中,我们需要稍微调整由添加 JS 文件提供的资产包,并调整它将要添加到页面上的位置,否则在页面解析之前运行它时可能会遇到一些问题。考虑以下代码片段:
    public $js = [
        'js/main.js',
    ];
    public $jsOptions = [
        'position' => \yii\web\View::POS_END
    ];
一旦你将前面的行添加到资产包中,你需要回到模态中包含的表单模板。实际上,这会引发一些问题,因为它需要将一些脚本注入页面以使客户端验证工作。这是一个主要问题;大多数时候,你将不得不覆盖ActiveForms的工作方式,所以你应该学习如何做到这一点。
// views/site/login-modal.php
    <?php $form = ActiveForm::begin([
        'id' => 'login-form',
        'options' => ['class' => 'form-horizontal'],
        'enableClientScript' => false,
        'enableClientValidation' => false,
        'fieldConfig' => [
            'template' => "{label}\n<div class=\"col-lg-4\">{input}</div>\n<div class=\"col-lg-5\">{error}</div>",
            'labelOptions' => ['class' => 'col-lg-offset-1 col-lg-2 control-label'],
        ],
    ]); ?>
这里显示的两个选项将禁用客户端验证和任何额外的脚本功能。仅禁用其中一个选项不起作用。
我们现在可以加载页面,并且控制台不会显示任何错误消息。
完成测试
到目前为止,我们只需将我们的场景转换为实时代码。
让我们以创建单元测试和功能测试相同的方式开始创建测试:
$ ../vendor/bin/codecept generate:cept acceptance LoginModalCept
Test was created in LoginModalCept.php
导航到文件,让我们首先断言初始语句:我们所在的位置,并确保我们没有登录:
<?php
// tests/codeception/acceptance/LoginModalCept.php
$I = new AcceptanceTester($scenario);
$I->wantTo('test modal login');
$I->amOnPage(Yii::$app->homeUrl);
$I->seeLink('Login');
$I->dontSeeLink('Logout');
虽然这看起来像是一种确定用户是否登录的简单方法,但它达到了目的。如果我们发现需要更复杂的方法,我们总是可以在以后添加:
$I->amGoingTo('test the login with empty credentials');
$I->click('Login');
$I->waitForElementVisible('.modal');
$I->fillField('#loginform-username', '');
$I->fillField('#loginform-password', '');
$I->click('#login-form button');
$I->see('Error', '.alert .error');
这个测试最重要的部分是使用显式等待,waitForElementVisible()。它做了它所说的:等待直到具有类.modal 的 DOM 元素渲染并可见。
最后的断言并没有检查任何特定的错误。所以你可以在这里添加任何级别的定制,因为我已经尽量保持尽可能通用。
下一个测试也是同样的情况:
$I->amGoingTo('test the login with wrong credentials');
$I->fillField('#loginform-username', 'admin');
$I->fillField('#loginform-password', 'wrong password');
$I->click('#login-form button');
$I->see('Error', '.alert .error');
测试的这个有趣部分出现在我们尝试使用有效凭据访问时。实际上,正如我们在之前创建的脚本中所看到的,模态窗口将被关闭,登录按钮将被注销链接替换:
$I->amGoingTo('test the login with valid credentials');
$I->fillField('#loginform-username', 'admin');
$I->fillField('#loginform-password', 'admin');
$I->click('#login-form button');
$I->wait(3);
$I->dontSeeElement('.modal');
$I->seeLink('Logout');
$I->dontSeeLink('Login');
为了做到这一点,我们需要为 AJAX 调用完成添加另一个显式等待,然后窗口将消失。使用waitForElementNotVisible()可能不起作用,因为它涉及动画。它还取决于你正在测试的系统的响应性,因为它可能不会按预期工作,有时会失败。所以,wait()似乎是解决问题的最简单方法。考虑以下代码片段:
$I->amGoingTo('test logout');
$I->click('Logout');
$I->dontSeeLink('Logout');
$I->seeLink('Login');
最后一个测试不需要太多关注,你应该能够理解它而不会遇到任何问题。
现在我们已经编写了测试,让我们运行它们:
$ ../vendor/bin/codecept run acceptance
Codeception PHP Testing Framework v2.0.9
Powered by PHPUnit 4.6-dev by Sebastian Bergmann and contributors.
Acceptance Tests (6) ---------------------------------------------
Trying to ensure that about works (AboutCept) Ok
Trying to ensure that contact works (ContactCept) Ok
Trying to ensure that home page works (HomeCept) Ok
Trying to ensure that login works (LoginCept) Ok
Trying to test modal login (LoginModalCept) Ok
------------------------------------------------------------------
Time: 47.33 seconds, Memory: 13.00Mb
OK (5 tests, 32 assertions)
测试多个浏览器
从版本 1.7 开始,Codeception 还提供了一种测试多个浏览器的方法。这并不特别困难,并且可以确保实现跨浏览器兼容性。
这通常是通过在 acceptance.suite.yml 文件中配置环境来完成的,在文件底部添加类似以下内容:
env:
    chrome39:
        modules:
            config:
                WebDriver:
                    browser: chrome
    firefox34:
        # nothing changed
    ie10:
        modules:
            config:
                WebDriver:
                    host: 192.168.56.102
                    browser: internetexplorer
env 变量下的每个键都代表你想要在测试上运行的特定浏览器,这是通过覆盖我们已定义的默认配置来实现的。
小贴士
在 env 中,你可以覆盖 YAML 配置文件中指定的任何其他键。
你可以拥有几台机器,每台机器上运行不同版本的浏览器,Selenium 服务器在这些机器上监听,这样你还可以在决定为最近引入的新功能使用哪些 polyfills 时进行回溯兼容性测试,同时也取决于你的浏览器支持图表。
为了触发各种环境,只需在 run 命令后附加 --env <环境> 参数:
$ ../vendor/bin/codecept run acceptance --env chrome39 --env firefox34 --env ie10
Internet Explorer 需要在主机机器上安装自己的驱动程序,并执行一些额外的步骤来正确设置,这可以在 Selenium 文档中找到,文档地址为 code.google.com/p/selenium/wiki/InternetExplorerDriver。
理解 Selenium 的限制
到现在为止,你可能已经看到了 Selenium 的强大之处。通过使用浏览器原生功能,你最终可以以编程方式与网站交互。这将节省人类在执行重复性任务上花费的大量时间。重复性只有在人类手中才会成为问题,所以这实际上是一件好事。
不幸的是,Selenium 不能做任何事情,如果你已经开始研究它的全面使用和潜力,那么你可能已经注意到它的一些使用限制。
显然,任何类型的“像素完美”测试几乎都不可能使用 Selenium 来重现,尽管可以创建一些针对设计的测试,特别是针对响应式设计的测试。其他框架,如 Galen,覆盖了这一功能(galenframework.com/)。
需要花一些时间讨论悬停效果,因为它们可能相当难以实现,你可能需要使用 moveMouseOver() 方法来触发它。
摘要
我们在本章中已经涵盖了测试的最后一个方面。我们回顾了提供的测试。我们还理解了任何额外的语法,配置了 Selenium,运行了第一批测试,然后继续将之前开发的 API 实现并绑定到具有模态登录功能的界面中。
在下一章中,我们将学习大量的日志以及我们的测试如何生成信息以更好地理解测试。我们还将看看我们是否遗漏了什么。
第八章:分析测试信息
在最后三章中,我们讨论了在不同级别编写测试的主题:单元、功能和验收。到目前为止,我们已经测试了我们创建的新接口,并学会了应用所有新方法。这是一个相对简单的工作,但我们不知道我们在测试中做得如何。有一些特定的指标我们可以分析,以生成关于测试质量的直接和即时报告。这些报告将帮助我们做出有关代码架构的明智决策。
Codeception 捆绑了大多数这些报告生成工具,而且到目前为止它相当简单。
在本章中,我们将主要介绍代码覆盖率指标,并简要介绍一些其他指标,这些指标可以通过各种软件获得。
- 
提高你测试的质量 
- 
使用额外的工具来改进我们的代码 
提高你测试的质量
从编程开始,特别是测试开始以来,许多程序员开始质疑自己编写良好测试的含义,或者换句话说,我如何知道我编写的测试是好的?这个指标是什么?
这绝对不是一个个人偏好或技能的问题。
分析测试质量最早创建的方法之一被称为代码覆盖率。从更广泛的角度来看,代码覆盖率衡量测试覆盖了多少代码。软件缺陷与测试代码覆盖率之间存在相关性,具有更多代码覆盖率的软件具有更少的缺陷,尽管测试不会消除引入缺陷的可能性,例如,作为模块之间复杂交互或意外输入和边缘情况的表现。这就是为什么在规划和设计测试时需要谨慎,并且需要考虑这一点不会完全消除回归和探索性测试的需求。
通常用于代码覆盖率程序的代码覆盖率标准有几个。
- 
行覆盖率:这是基于执行了多少可执行行的数量。 
- 
函数和方法覆盖率:这计算执行了多少个函数或方法。 
- 
类和特性覆盖率:这衡量在所有方法都执行时覆盖了多少个类和特性。 
- 
指令覆盖率:这与行覆盖率类似,尽管单行可能生成多个指令。行覆盖率认为一旦其指令之一被执行,该行就被覆盖了。 
- 
分支覆盖率:这衡量在测试运行时是否评估了控制结构中每个可能的布尔表达式的组合。 
- 
路径覆盖:这同样被称为决策到决策(DD)路径,它考虑了从每个方法或函数的开始到结束的所有可能的执行路径,从其独特的分支执行顺序来考虑。 
- 
变更风险反模式(C.R.A.P.)索引:这个索引基于代码单元的循环复杂度和代码覆盖率。可以通过重构代码或增加测试数量来降低这个索引。无论如何,它主要用于单元测试。 
由于 Codeception 使用 PHP_CodeCoverage,它不支持指令覆盖、分支覆盖和路径覆盖。
考虑到这一点,如果我们回到我们的单元测试,我们将更好地理解测试的结构以及它们目前的工作方式。
让我们先在我们的单元测试中启用代码覆盖率,然后查看它们的结果。
之后,我们将查看功能性和验收覆盖率报告,然后探索一些其他有趣的信息,我们可以从我们的代码中提取这些信息。
在 Codeception 中启用代码覆盖率
Codeception 提供了全局和特定配置的代码覆盖率。根据您应用程序的结构和您根据测试计划将要实施的测试类型,您可以在/tests/codeception.yml中有一个通用配置,或者为每个套件配置文件(如/tests/codeception/unit.suite.yml)有一个特定配置。您也可以同时拥有这两种配置。然而,在这种情况下,单个套件配置将覆盖全局配置的设置。
我们将使用全局配置文件。因此,在文件末尾添加以下行:
# tests/codeception.yml
coverage:
    enabled: true
    white_list:
        include:
            - ../models/*
            - ../modules/v1/controllers/*
            - ../controllers/*
            - ../commands/*
            - ../mail/*
    blacklist:
        include:
            - ../assets/*
            - ../build/*
            - ../config/*
            - ../runtime/*
            - ../vendor/*
            - ../views/*
            - ../web/*
            - ../tests/*
这应该足够开始。第一个选项启用了代码覆盖率,而其余的选项告诉 Codeception 和代码覆盖率程序在编写报告时包含哪些文件,以生成白名单和黑名单。这将确保结果汇总了与我们相关的信息,换句话说,就是我们编写的代码,而不是框架本身。
我们不需要运行 Codeception 的build命令,因为没有新的模块需要导入到我们的测试人员中。
如果我们查看 Codeception 的run操作的help选项,我们会注意到它有两个主要选项用于生成我们感兴趣的报告。
- 
--coverage:这会生成实际的覆盖率报告,并伴随一系列其他选项来控制报告的格式和详细程度
- 
--report:这会生成运行测试的整体报告
与这两个选项结合使用,我们能够根据需要生成 HTML 和 XML 测试和覆盖率报告。特别是,当到达第九章 借助自动化消除压力时,XML 报告将非常方便。
注意
重要的是要记住,目前验收测试的覆盖率报告并没有与功能测试和单元测试生成的报告合并。这是由于代码覆盖率计算和拦截的方式造成的。稍后,我们将看到生成验收测试覆盖率报告需要什么。
提取单元测试的代码覆盖率信息
在 Codeception 文档中,这通常被称为本地覆盖率报告,它适用于单元测试和功能测试。当我们谈到验收测试的覆盖率时,我们将涉及到远程覆盖率。
我们可以通过在命令中添加--coverage标志来轻松生成覆盖率:
$ ../vendor/bin/codecept run unit --coverage
这将结束于以下类似的输出:
...
Time: 44.93 seconds, Memory: 39.75Mb
OK (32 tests, 68 assertions)
Code Coverage Report: 
 2015-01-05 21:43:13 
 Summary: 
 Classes: 25.00% (2/8) 
 Methods: 45.00% (18/40)
 Lines:   26.42% (56/212)
\app\models::ContactForm
 Methods:  33.33% ( 1/ 3)   Lines:  80.00% ( 12/ 15)
\app\models::Dog
 Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  3/  3)
\app\models::LoginForm
 Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 18/ 18)
\app\models::User
 Methods:  84.62% (11/13)   Lines:  79.31% ( 23/ 29)
注意
这里显示的执行时间是基于一个装有 i7-m620 处理器的机器,该机器运行 Linux 内核。覆盖率会增加执行时间呈指数增长。在同一台机器上运行单元测试不到 10 秒。
有方法可以缩短执行时间。这可以通过使用 Robo(一个任务运行器)及其特定的 Codeception 插件 robo-paracept 来实现。更多详细信息可以在官方 Codeception 文档中找到,网址为codeception.com/docs/12-ParallelExecution。
这份报告为我们提供了代码覆盖率的一个简洁且直接的输出。
从摘要中可以看到类、方法和行的覆盖率(以及百分比的计算方式),以及每个类的略微详细分解。
我们可以看到,我们成功覆盖了 100%的Dog和LoginForm类,并且我们仍然达到了User类方法的良好覆盖率,为 84.62%,但令人失望的是,我们只覆盖了ContactForm类方法的 33.33%。
但是,我们错过了什么?
好吧,只有一个方法可以找到答案,那就是生成 HTML 覆盖率报告。
生成单元测试的详细覆盖率报告
通过使用--coverage-html选项,我们可以生成详细的代码覆盖率报告。然后,我们可以检查它,以了解哪些被覆盖了,哪些被遗漏了:
$ ../vendor/bin/codecept run unit --coverage-html
这将结束于以下输出行:
HTML report generated in coverage
报告将被保存在_output/coverage/目录中,在那里你可以找到两个文件:dashboard.html和index.html。第一个文件提供了一些漂亮的图表,这些图表比控制台上打印的覆盖率报告摘要更有趣,但它主要用于炫耀,并不适用于理解测试中的问题。实际上,有一个公开的请求要求在控制台上抑制这种输出(github.com/Codeception/Codeception/issues/1592)。

仪表板中不足覆盖率面板的详细信息
如您从前面的截图中所见,在这个细节级别,您可能感兴趣的部分是位于页面左下角的覆盖率不足面板(目前)。
我们将在稍后讨论其他面板。
你会对index.html文件真正感兴趣。从那里,你可以看到一些详细的统计数据,并且可以深入查看每个已分析的文件,以查看测试覆盖了哪些行,从而从那里改进你的测试。

所有分析文件的覆盖率总结
覆盖率总结显示了覆盖了什么,相当详细。这帮助我们立即发现我们的测试中出了什么问题,在我们的案例中,Yii 为ContactForm提供的测试覆盖不足。在前面的截图中,我们可以看到它显示了 80%的行覆盖率,33.33%的方法覆盖率,但它没有显示关于类的任何内容。这是因为,除非你覆盖了所有的方法,否则类不会被标记为已覆盖。
这可能不会证明是一个问题。有一些方法不是我们实现的一部分,这些方法只能通过使用集成测试来测试,还有一些可以通过稍微注意一下来覆盖。如果我们点击ContactForm.php链接,那么我们会看到以下内容:

选中文件的代码覆盖率总结
在两个未被覆盖的方法中,我们实际上并不需要覆盖第一个方法attributeLabels()。技术上,这是由于两个原因:第一个原因是它是 Yii 框架的一部分,我们假设它会工作;第二个原因是它是一个简单的函数,它总是返回一个内部变量,无法以任何方式控制。
另一种方法是contact()方法,它已经被部分覆盖。所以,我们将修复这个问题。完全有可能这个特定的测试将在框架的未来的版本中得到修正。这可能是一些你需要留意的事情。
通过点击contact($email)链接,或者直接滚动到页面底部,我们会找到我们的方法,这将显示所有路径都没有被覆盖。

在颜色编码线条的帮助下发现需要覆盖的内容
我们的案例相当简单,所以我们将尝试通过添加@codeCoverageIgnore指令到我们想要排除的方法的文档中,或者通过调整或添加一个新的测试来尽可能接近 100%。记住,这是我们将会追求的目标,但这并不一定是我们的目标。
// /tests/codeception/unit/models/ContactFormTest.php
/**
 * @return array customized attribute labels
 * @codeCoverageIgnore
 */
public function attributeLabels()
{
    return [
        'verifyCode' => 'Verification Code',
    ];
}
解决if语句剩余分支覆盖率的方法是添加一个类似于以下测试:
// /tests/codeception/unit/models/ContactFormTest.php
public function testContactReturnsFalseIfModelDoesNotValidate()
{
    $model = $this->getMock(
          'app\models\ContactForm', ['validate']
    );
    $model->expects($this->any())
          ->method('validate')
          ->will($this->returnValue(false));
    $this->specify('contact should not send', function () use (&$model) {
        expect($model->contact(null), false);
        expect($model->contact('admin@example.com'), false);
    });
}
现在,让我们再次运行我们的测试,我们将看到这里显示的截图:

我们已经达到了 100%的覆盖率!太好了!
我将留给你去修复剩余的错误。某些情况可能很难覆盖,你可能需要额外的提示和建议来重新结构你的测试。
将功能测试汇总到单元测试
现在我们已经看到了我们的单元测试中发生了什么,以及如何直观地理解我们是否已经尽可能多地覆盖了,我们可以转向我们之前编写的功能测试。
如我们之前所见,我们只需将功能套件添加到命令行中,以生成汇总报告。
$ ../vendor/bin/codecept run unit,functional --coverage
我们还会看到,如果我们省略了套件,最终结果将相同,但我们不知道 Codeception 开发者何时将所有三个套件合并为一个覆盖率报告,所以请记住这一点,并查阅文档。
我们的单元测试已经完全覆盖了模型。我们的功能测试应该专注于控制器。你应该能够发现登录页面和 REST 模块控制器并未完全被覆盖。所以,让我们逐一讨论这些问题。
登录页面将显示登录和注销操作的缺失覆盖率。
在第一种情况下,覆盖它似乎相当简单。我们必须确保在登录后达到该操作。因此,让我们在测试文件末尾成功登录后立即添加以下断言:
// tests/codeception/functional/LoginCept.php
$I->amGoingTo('ensure I cannot load the login page if I am logged in');
$I->amOnPage('/index-test.php/login');
$I->seeCurrentUrlEquals('/index-test.php');
如我们所见,我们正在使用一些特定的路径来测试网站。当与 Codeception REST 模块交互时,这并不是问题,但在这里我们必须详细说明。
我们必须覆盖的另一部分要复杂一些。一旦我们登录,请注意注销按钮上附加了一个 JS 点击事件,这将向/logout发送 POST 请求。
由于 PHPBrowser 无法读取 JS,也没有能力进行特定的 POST 调用,因此我们无法覆盖这段代码。甚至不要考虑使用sendPost(),因为这是一个特定方法,来自 Codeception 的 REST 模块。
解决这个问题的唯一方法是将这部分覆盖率留给验收测试或 WebDriver。
由于验收测试和功能测试尚未合并,我们可以使用@codeCoverageIgnore排除此方法,从而排除覆盖率报告中的方法覆盖率。但是,请确保这不再是问题,并在排除所有测试的方法覆盖率之前与你的同事讨论。
我们需要覆盖的最后部分是 REST 接口的控制器。在这里,我们有一个混合的情况。我们有未覆盖的函数,这些函数主要是我们框架的一部分,例如执行身份验证的匿名函数和checkAccess(),我们在actionUpdate()中有少量内容,它禁止除了 PUT 以外的任何操作,并且在actionSearch()中还有一个控制语句,它控制谁可以搜索什么。
在前两种情况下,我们很高兴避免它们被覆盖,因为我们已经明确排除了这些文件所属的框架文件。
对于actionUpdate(),我们会发现我们甚至不需要特定的检查,因为 Yii 已经定义了针对默认 REST 接口允许的 HTTP 调用类型。
我们可以添加一个测试来确保我们无法在接口上执行 POST 操作,并且它可以添加到任何现有的测试中。这可能是以下代码块的内容:
// tests/codeception/functional/UserAPIEndpointsCept.php
// I must be authenticated at this point.
$I->amGoingTo('I cannot update using POST');
$I->sendPOST('users/' . $userId);
$I->seeResponseCodeIs(405);
最后,我们希望确保用户只能搜索自己的用户名以获取 ID,正如我们在第六章中概述的那样,“测试 API - PHPBrowser 来拯救”。为了做到这一点,我们可以简单地添加类似于这里显示的代码块的内容:
// tests/codeception/functional/UserAPICept.php
// I must be authenticated at this point.
$I->amGoingTo('ensure I cannot search someone else');
$I->sendGET('users/search/someoneelse');
$I->seeResponseCodeIs(403);
如果我们运行带有覆盖率的测试,那么我们将得到所有我们想要查看覆盖率的文件的 100%。

单元和功能测试的最终覆盖率概述
生成验收测试覆盖率报告
现在我们已经了解了如何处理我们的覆盖率报告,我们将快速查看将帮助我们获取验收测试覆盖率报告的配置。
这些覆盖率报告可能不是最重要的,但如果构建正确,它们应该可以证明我们的场景编写得很好。通常,验收测试的焦点在于确保浏览器跨兼容性和向后兼容性。
正如我们在第七章中看到的,“享受浏览器测试的乐趣”,Codeception 与 Selenium 独立服务器通信,该服务器反过来启动浏览器并通过浏览器驱动程序执行所需的测试。正因为这种架构,c3 项目被创建,它基本上监听浏览器调用并理解我们的代码的哪一部分正在远程执行。
因此,首先,让我们获取 c3。我们可以从 Composer 或从官方网站(github.com/Codeception/c3)通过在项目根目录运行以下命令来下载它:
$ wget https://raw.github.com/Codeception/c3/2.0/c3.php
如果你通过 Composer 下载,那么你必须在composer.json文件中添加一些额外的说明。你应该以官方文档为主要参考点。
一旦你有了它,就将其包含在index-test.php文件中:
// web/index-test.php
//...
include_once __DIR__ . '/c3.php';
$config = require(__DIR__ . '/../tests/codeception/config/acceptance.php');
(new yii\web\Application($config))->run();
通过这种方式,我们已经将 c3 连接到 Yii。现在,我们只需要让 Codeception 知道这一点。所以打开codeception.yml文件,并将以下选项添加到文件的coverage部分:
# tests/codeception.yml
# ...
coverage:
    enabled: true
    remote: true
    remote_config: ../tests/codeception.yml
    # whitelist:
    # blacklist:
    c3_url: 'https://basic-dev.yii2.sandbox/index-test.php/'
我们需要启用远程覆盖率,通过使用remote_config设置文件配置,然后指定 c3 应该监听的 URL。
注意
关于远程代码覆盖率及其配置的详细说明,可以从 Codeception 的官方文档中阅读,该文档可在codeception.com/docs/11-Codecoverage找到,也可以从README.md文件中找到,该文件位于你的项目的tests/目录中,或者可在github.com/yiisoft/yii2-app-basic/tree/master/tests#remote-code-coverage找到。
现在,所有我们的远程调用都将通过index-test.php文件进行,并且它们将使用 c3 生成覆盖率数据。
此外,我们可能还想为特定的验收测试获取一个精简的报告,在我们的情况下,我们可以决定只关注被击中的控制器,然后选择移除对模型的任何报告。
为了做到这一点,考虑我们在主配置文件中已经有的内容。我们只需要将以下内容添加到我们的acceptance.suite.yml文件中:
# tests/codeception/acceptance.suite.yml
coverage:
    blacklist:
        include:
            - ../models/*
在这个阶段,你可以通过使用这里显示的代码块单独生成报告:
$ ../vendor/bin/codecept run acceptance --coverage-html
你也可以通过简单地运行整个套件的测试来完成这项工作,如下所示:
$ ../vendor/bin/codecept run --coverage-html
如我们之前所见,这两种方法将为验收测试生成单独的报告。未来这可能会不再有效,所以请务必查看官方文档并确认。
一旦我们生成了报告,我们会注意到两件事:带有覆盖率报告的测试可能需要很长时间,所以我们不希望在每次更改接口时都运行这个测试。其次,我们必须覆盖之前突出显示的缺失登出测试。
因此,让我们转到我们的LoginCept.php文件,并添加缺少的内容。
$I->amGoingTo('ensure I cannot load the login page if I am logged in');
$I->amOnPage('/index-test.php/login');
$I->seeCurrentUrlEquals('/index-test.php');
$I->amGoingTo('try to logout');
$I->click('Logout (admin)');
if (method_exists($I, 'wait')) {
    $I->wait(3); // only for selenium
}
$I->seeCurrentUrlEquals('/index-test.php');
请注意,在使用 URL 时,我们需要非常具体,就像我们在功能测试中所做的那样。
一旦完成,我们应该发现自己拥有所有套件的完整覆盖率。
在下一节中,我们将看到我们还能生成什么,然后我们将在下一章中借助自动化将其提升到更高水平。
在额外工具的帮助下改进我们的代码
除了代码覆盖率和测试报告之外,我们还有一系列额外的工具,我们可以使用这些工具来提高我们代码的质量。
我们将要讨论的两个工具是检查样式和通过 C.R.A.P.指数的循环复杂度。
我们将在第九章“借助自动化消除压力”中添加更多示例和工具,因为每个命令都需要开发者过多的知识,而且这是可以通过开关自动触发的事情。
PHP Checkstyle(PHPCS)是一个非常好的工具,尽管一开始它相当复杂。这将帮助我们保持所有开发者代码风格的统一。你可能对此过于关心,我也见过因为选择哪种风格而引发的激烈争论。然而,这种做法的好处是显而易见的,因为它迫使开发者控制他们的编码风格。当与循环复杂度一起使用时,它可以标准化代码并避免任何涉及复杂和困难代码的情况。
已经有一些现成的代码标准可供使用,并且这些标准已经根据你的需求进行了配置。PHPCS 只需要一个配置文件或要遵循的标准名称的引用。
我们将安装并使用 Yii 2 自己的代码标准,你可以将其作为指定更适合你需求的规则的基准。
你可以使用 Composer 安装 Yii 2 代码标准,这将包括我们需要的实际二进制文件作为依赖项:
// composer.json
    "require-dev": {
       ...
        "yiisoft/yii2-coding-standards": "*"
一旦我们安装了这两个工具,我们就可以通过以下命令在控制台中调用它们:
$ vendor/bin/phpcs \--standard=vendor/yiisoft/yii2-coding-standards/Yii2/ruleset.xml \ --extensions=php \--ignore=autoload.php \models controllers modules
最后三个参数是我们希望 PHPCS 扫描的文件夹。
如果你想要改进你的代码,那么你应该利用 C.R.A.P.指数,这是 Codeception 生成的覆盖率报告中包含的。在下一章中,我们将看到如何使用循环复杂度指数来基于修改代码的决定。
C.R.A.P.指数是为了分析和预测维护现有代码库所需的工作量、痛苦和时间而设计的。
它在这里以数学方式定义:
C.R.A.P.(m) = comp(m)² * (1 – cov(m)/100)³ + comp(m)
其中comp(m)是循环复杂度,cov(m)是自动化测试提供的测试代码覆盖率。循环复杂度是方法中唯一决策数加 1。
低 C.R.A.P.指数表明相对较低的变化和维护风险,因为它要么不是太复杂,或者被测试充分覆盖。为了保持其实用性,如果你的方法是直接的调用序列,那么它很可能有一个接近 1 的 C.R.A.P.指数。它包含的if、for和while子句越多,它就越复杂,因此它的 C.R.A.P.指数就越高。
这就是测试如何让潜在的问题显现出来,并指引你走向保持代码可维护和模块化的正确方向。
摘要
在本章中,我们讨论了配置和生成项目代码覆盖率所需的基本步骤。我们看到了如何使用生成的报告来发现代码中的潜在问题。我们还介绍了一些额外的工具来提高我们的代码质量。
在第九章中,借助自动化消除压力,我们将完成这段旅程。我们将讨论额外工具的主题,如何将它们集成到持续集成系统中,并展示结果以方便访问和浏览。
第九章. 利用自动化消除压力
到目前为止,我们已经涵盖了测试实践中几乎每个方面的内容。我们学习了在测试的所有级别(单元、功能、验收)上可以使用 Codeception 做些什么。我们还涵盖了如何通过考虑架构选择和长期考虑来改进和调试测试的额外资源。
简而言之,在本章中,我们将采取最终一步,这在当今被认为是最佳实践:持续集成。
我们将了解什么是持续集成系统,以及我们有哪些选择。我们还将开始使用 Jenkins。
在本章中,我们将讨论我们需要安装和配置的所有内容。我们将运行我们的构建,并为我们的项目获得所需的自动化水平。我们将涵盖以下主题:
- 
自动化构建过程 
- 
创建所需的构建文件 
- 
配置 Jenkins 构建 
- 
展望未来 
自动化构建过程
在规划和实施测试时,你应该始终考虑两个方面的因素。首先,100% 的代码覆盖率并不能帮助你消除出现或引入错误的可能性,这意味着探索性手动测试始终是必要的,并且它必须在编写初始测试计划草案时考虑进去。其次,到目前为止,我们为这样一个小型项目生成的所有测试和报告都可以由更改代码的任何人手动运行。
当你的代码规模开始增长,你开始支持数百个类和多方面的前端功能时,当你的代码存活超过第一个月,并且不止一个开发者需要反复访问它时,所有与测试及其工作方式或可以从它们中提取的信息相关的知识将变得越来越难以维护。最糟糕的是,很可能没有人会不费吹灰之力地使用它。
在这里,你有两个选择:接受你的测试最终会被遗忘的命运,没有人会知道已经覆盖了什么以及还需要覆盖什么,或者通过强制某种形式的自动化代码修订来开始自动化所有这些,这可能会触发报告和电子邮件来警告可能发生或已经发生的问题。
介绍持续集成系统
极限编程(XP)引入了持续集成(CI)的概念。如今,它被许多公司作为其 QA 流程的一部分使用,无论采用何种实践。
频繁集成是否比持续集成更好,我更愿意将这个问题留到讨论之外。两者之间的主要区别在于集成的频率。在此基础上,CI 被构想为 TDD 的一部分,并且专门针对在将任何功能合并到活动分支之前运行测试。这样做是为了确保新功能不会破坏现有功能。
类似于 Jenkins(以前称为 Hudson)、Bamboo、CruiseControl 和 Travis 这样的系统,是为了在发货前将不同开发者的工作集成和测试而创建的。这也确保了达到一定的质量标准,以避免在代码库中引入不一致性,并且可以将结果报告给开发者。
这些软件系统执行了众多任务。它们被设计成可以支持任何编程语言和测试框架。
通常,它们被构建成提供一种灵活的方式来定义你的集成工作流程:代码检出、准备、构建(通常包括测试和其他质量保证相关的任务)、报告发布、工件创建,最终部署。所有这些步骤都可以在系统内部或通过各种类型的脚本进行控制。例如,你可以在 Travis、Bamboo 或 Jenkins 中使用 Ant 或 Maven。除了基本功能外,这些系统中通常还提供了一些插件,用于扩展集成第三方应用程序、库和服务的功能。
在深入了解我们选择的持续集成系统的配置细节之前,我们可能首先想看看有哪些可用的选项以及我们如何进行选择。
可用系统
有许多 CI 系统,其中许多是为特定的编程语言专门创建的;因此,它们只能执行一组有限的操作。
系统越复杂,准备它按照我们的期望运行所需的时间就越长,但你将能够执行所需的所有功能,并且你还可以根据项目需求逐个开启或关闭它们。
最著名的系统是 Jenkins。它是一个开源系统 (jenkins-ci.org)。它在 2011 年从 Hudson 分支出来,之后与 Oracle 发生争议。它是一个用 Java 编写的 CI 系统,并作为 CruiseControl 的替代品而流行起来。它一直被视为最多功能化的 CI 系统。这也得益于其庞大的社区,为各种功能提供了数百种不同的插件。
我看到的 Jenkins 的唯一问题,除了配置之外,就是托管它,尽管安装和维护对我来说一直都很简单。
您可能无法自己托管系统,因此您可能想寻找提供托管解决方案的东西。Bamboo 是另一个选择,据说如果您从 Jenkins 迁移,它特别简单。它还提供了与其他 Atlassian 产品(如 Jira、BitBucket、HipChat 等)开箱即用的集成。
作为一种选择,我们将研究 Jenkins,安装所需的插件,然后使用 Apache Ant 脚本创建构建。
安装和配置 Jenkins
关于 Jenkins 的安装没有太多可说的。正如安装页面所示,它是跨兼容的,可以在 Windows、Linux、Unix 和 Docker 等操作系统上运行。更多信息,请查看wiki.jenkins-ci.org/display/JENKINS/Installing+Jenkins。
注意
重要的是要记住,它唯一的依赖项是 Java,因此您应该有 JDK 和 JRE 的最新版本。
大多数发行版已经打包了 Jenkins,并提供了他们的官方软件包仓库,从而解决了大多数依赖性问题。
在 *nix 系统上的安装将创建其自己的专用用户 Jenkins,该用户将用于执行通过其界面运行的所有操作。永远不要以超级用户身份运行 Jenkins。这可能会引起安全问题。项目将被检出到的工 作空间通常位于 /var/lib/jenkins/home/workspace,如果出现问题,您可以手动检查。
当 Jenkins 启动时,它将监听端口 8080(不总是这样,如果有的话,请务必阅读安装后的说明,或者使用 Linux 上的 netstat -ltn 检查已打开的端口),并且可以通过网页浏览器访问。
小贴士
如果您想将您的服务暴露给更广泛的受众,那么您可能需要安装一个代理,以便从端口 80 以您想要的任何主机名提供服务。我们不会涉及这个方面,但 Jenkins 提供了额外的文档,说明如何实现这一点。
因此,让我们在我们的浏览器中打开 http://<yourhostname>:8080,并开始配置基本设置。
理解 Jenkins 组织结构
在这样做之前,您可能需要了解 Jenkins 的组织结构。如果您已经对它有经验,那么您可能想跳到下一节。
您只需要关注两个部分:
- 
job 列表 
- 
管理面板 
第一个通常是您通常会到达的地方,也是您大部分时间将在此处工作的地方。
在 Jenkins 术语中,一个 job 是在特定项目中需要执行的一组特定的规则和操作。您可以为同一项目设置不同的 job,它们执行稍微不同或完全不同的操作,并且这些可以按顺序触发。
构建是执行作业的过程。我们将介绍这一点,并看看通过配置作业的各个方面,我们可以通过单个构建实现什么。构建可以导致创建一个或多个工件,部署构建结果到某个地方,或者在 Jenkins 内部或外部触发其他作业或进程。
在安装 Jenkins 后,你需要立即修复其安全性,除非你将是唯一访问它的人,并且安装它的服务器将没有外部访问,那么导航到http://jenkins:8080/configureSecurity/可能更好。
你可以设置你想要的任何身份验证系统,使用 PAM、LDAP 或其内部用户数据库。我们将使用后者,但请记住,如果你愿意做一点更强大或更互联的事情,可能需要遵循额外的步骤。大多数界面表单都有几个小的信息按钮,你可以使用它们来显示一些信息,如下面的截图所示:

配置全局安全页面的视图,带有打开的信息框
需要你稍微思考的第二个方面是 Jenkins 将如何访问和检查你的仓库。
任何主要的在线仓库提供商,如 GitHub 和 BitBucket,都会让你创建一个所谓的部署密钥,它用于对仓库的只读访问。
对于更复杂的事情,比如合并和推送你的分支,你需要为它设置自己的用户,如下面的截图所示:

BitBucket 中部署密钥设置页面,可通过每个仓库的设置访问
如果你有多于一个由不同地方托管的仓库,那么你需要为 Jenkins 设置所需数量的凭证。这可以通过访问http://jenkins:8080/credential-store/(或首页 | 凭证 | 全局凭证)来完成。正如你所见,幕后有很多内容,所以请随意探索并阅读文档以了解所需内容。通常,从设置全局凭证开始可能就足够了,但在某些情况下可能需要非全局配置。
为了做到这一点,我们首先需要为用户jenkins创建 SSH 密钥对。
peach ~ $ sudo -s
root peach # su jenkins -
jenkins peach $ cd ~
jenkins ~ $ pwd
/var/lib/jenkins
jenkins ~ $ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/var/lib/jenkins/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /var/lib/jenkins/.ssh/id_rsa.
Your public key has been saved in /var/lib/jenkins/.ssh/id_rsa.pub.
The key fingerprint is:
ff:be:b1:ee:c2:69:82:96:00:e4:9d:dd:67:cf:1e:d7 jenkins@mangotree
The key's randomart image is:
+---[RSA 2048]----+
|                 |
|                 |
| . . o. .        |
|+ . *.oo  .      |
|.o o .o+S. E     |
|+ o   .+o        |
|+*. . o.         |
|+. . .           |
|                 |
+-----------------+
现在你已经拥有了你的 SSH 密钥,你需要获取公钥,它存储在/var/lib/jenkins/.ssh/id_rsa.pub,然后将其作为部署密钥复制到你的仓库中。你可以使用以下命令将其复制到剪贴板:
root jenkins # xclip -sel clip < /var/lib/jenkins/.ssh/id_rsa.pub
这是通过root用户完成的,因为jenkins用户在其环境中不会设置 X 显示,这很可能会引起这里显示的错误:
No protocol specified
Error: Can't open display: :0
小贴士
如果你在一个无头服务器上,那么你不会有太多选择。你可以将文件scp到你的本地机器,或者cat文件然后手动将其粘贴到浏览器中。
现在您已经设置了部署密钥,需要回到 Jenkins 并导航到凭据存储。一旦到达那里,将用户名设置为jenkins(这是系统用户名),并将私钥设置为From the Jenkins master ~/.ssh。
以下部分将涵盖额外插件的安装和作业的配置。
安装所需的插件
现在您已经完成了 Jenkins 的基本配置,需要安装所需的插件,以确保一切按预期工作。
这个部分通常由 Jenkins 轻松处理。有一个专门针对 Jenkins 上 PHP 项目的项目。您可以在jenkins-php.org找到它。该项目不仅列出了以下插件,还列出了一个可在 Jenkins 上使用的元插件,它将下载所有所需的插件。
- 
Checkstyle:这是用于处理 Checkstyle 格式的 PHP_CodeSniffer 日志文件 
- 
Clover PHP:这是用于处理 PHPUnit 的 Clover XML 日志文件 
- 
Crap4J:这是用于处理 PHPUnit 的 Crap4J XML 日志文件 
- 
DRY:这是用于处理 PMD-CPD 格式的 phpcpd 日志文件 
- 
HTML Publisher:这是用于发布由 phpDox 生成的文档 
- 
JDepend:这是用于处理 JDepend 格式的 PHP_Depend 日志文件 
- 
Plot:这是用于处理 phploc CSV 输出的 
- 
PMD:这是用于处理 PMD 格式的 PHPMD 日志文件 
- 
Violations:这是用于处理各种日志文件 
- 
xUnit:这是用于处理 PHPUnit 的 JUnit XML 日志文件 
上述列表来自jenkins-php.org/installation.html,未来可能会有所变化,请记住这一点。
如果您导航到管理 Jenkins | 管理插件,然后可以搜索插件php并选择安装无需重启。当您处于安装页面时,选择安装完成后重启 Jenkins 且没有作业运行时选项(有时安装页面不会自动刷新,因此您可能需要刷新页面;即使您离开此页面,也会安装插件列表)。
现在,您需要安装这些插件所需的工具,因此打开composer.json文件,然后在require-dev部分添加以下内容:
// composer.json
"phploc/phploc": "@stable",
"pdepend/pdepend": "@stable",
"phpmd/phpmd": "@stable",
"sebastian/phpcpd": "@dev",
"yiisoft/yii2-coding-standards": "*",
"theseer/phpdox": "@stable"
现在,运行composer update以有效安装这些插件。
创建所需的构建文件
构建配置的一部分将存储在 Jenkins 中,但它主要用于发布报告和文档(如果需要的话)。我们在第八章中看到了如何运行各种脚本的实际配置,分析测试信息。这位于 build.xml 文件中。这个默认名称可以被 Jenkins 自动识别。这可以进行配置,但除非你已经有一个同名文件,否则这样做是没有意义的。
构建文件应该位于项目仓库的根目录,并且应该具有有效的 XML 格式。
我们将用来编写构建文件的语言是 Apache Ant。对于这个问题,有更复杂的解决方案,如 Maven,或者更定制的解决方案,如 Phing,但我仍然更喜欢 Ant。这是因为它简单且灵活(它很冗长,但一旦编写,就没有多少可说的)。它还允许你运行任何非特定于特定语言的任何东西。
我们将通过从 jenkins-php 项目(可在 jenkins-php.org/automation.html 获取)复制基本结构来创建构建文件,然后通过在下几段中解释的修正来修改它。我将把 Composer、Yii 和 Codeception 的功能分别放在单独的文件中,而主功能(来自 jenkins-php)将保持不变。
理解基本的 Ant 结构
Ant 非常简单,因为它是一系列指令的集合。XML 的根是一个 <project> 标签,它包含一系列 <target> 标签,这些标签可以从 Jenkins 作业中调用,你可以选择 <property> 选项来定义属性,然后选择 <include> 语句来包含单独的文件。
目标没有默认名称,但主要目标通常称为 build。它附带一系列依赖项,这些依赖项会依次触发其他目标。
每个指令都有一系列额外的属性和标签,并且这些可以嵌套在其中。用户贡献的指令也可以单独下载(并且大多数 Linux 发行版都在单独的包中提供了常见的指令)。这可以帮助你在手动创建这些指令时节省精力。例如,你可以用它来归档和打包一组文件,这些文件通常是实际命令行工具的包装器。
注意
记住,Ant 不是一个命令式编程语言,所以如果你想扩展和修改该语言,请检查其文档。
核心 Ant 指令的文档可在 ant.apache.org/manual/ 在线获取,这可能是理解它的良好起点。
调整 build.xml 文件
与您从jenkins-php复制的文件相比,我们将保留大部分目标,尽管phpunit目标可以安全删除。这是因为我们将切换到专门针对 Codeception 的自定义目标。其余的更改将在单独的文件中进行,并且将在之后进行讨论。
需要做的最重要的更改与您希望这些程序工作的文件夹有关。每个命令都接受不同的参数,因此请包含您想要的所有目录。让我们开始修改第一个目标,它将对所有指定的文件进行语法检查:
<target name="lint" description="Perform syntax check of sourcecode files">
    <apply executable="php" failonerror="true">
        <arg value="-l" />
 <fileset dir="${basedir}/models">
 <include name="**/*.php" />
 <modified />
 </fileset>
 <fileset dir="${basedir}/modules">
 <include name="**/*.php" />
 <modified />
 </fileset>
 <fileset dir="${basedir}/controllers">
 <include name="**/*.php" />
 <modified />
 </fileset>
 <fileset dir="${basedir}/tests">
 <include name="**/*.php" />
 <modified />
 </fileset>
    </apply>
</target>
接下来,我们可以将我们的代码目录添加到 phploc 中,这样我们就可以了解我们项目的复杂度:
<target name="phploc-ci"
        depends="prepare"
        description="Measure project size using PHPLOC and log result in CSV and XML format. Intended for usage within a continuous integration environment.">
    <exec executable="${toolsdir}phploc">
        <arg value="--count-tests" />
        <arg value="--log-csv" />
        <arg path="${basedir}/build/logs/phploc.csv" />
        <arg value="--log-xml" />
        <arg path="${basedir}/build/logs/phploc.xml" />
 <arg path="${basedir}/models" />
 <arg path="${basedir}/controllers" />
 <arg path="${basedir}/modules" />
 <arg path="${basedir}/tests" />
    </exec>
</target>
pdepend 在定义新目录时使用不同的语法;正如您所看到的,如果您需要做出更改,您将需要手动调用命令的帮助:
<target name="pdepend"
        depends="prepare"
        description="Calculate software metrics using PHP_Depend and log result in XML format. Intended for usage within a continuous integration environment.">
    <exec executable="${toolsdir}pdepend">
        <arg value="--jdepend-xml=${basedir}/build/logs/jdepend.xml" />
        <arg value="--jdepend-chart=${basedir}/build/pdepend/dependencies.svg" />
        <arg value="--overview-pyramid=${basedir}/build/pdepend/overview-pyramid.svg" />
        <arg path="${basedir}/models,${basedir}/controllers,${basedir}/modules,${basedir}/tests" />
    </exec>
</target>
接下来是 PHP 混乱检测(PHPMD),它将帮助我们保持代码整洁。再次强调,语法与之前的不同:
<target name="phpmd-ci"
        depends="prepare"
        description="Perform project mess detection using PHPMD and log result in XML format. Intended for usage within a continuous integration environment.">
    <exec executable="${toolsdir}phpmd">
        <arg path="${basedir}/models,${basedir}/controllers,${basedir}/modules" />
        <arg value="xml" />
        <arg path="${basedir}/build/phpmd.xml" />
        <arg value="--reportfile" />
        <arg path="${basedir}/build/logs/pmd.xml" />
    </exec>
</target>
PHP 代码检查器(PHPCS)也可以作为代码检查的额外且更重要的一步。正如解释的那样,我们还需要指定特定的 Yii 编码标准:
<target name="phpcs-ci"
        depends="prepare"
        description="Find coding standard violations using PHP_CodeSniffer and log result in XML format. Intended for usage within a continuous integration environment.">
    <exec executable="${toolsdir}phpcs" output="/dev/null">
        <arg value="--report=checkstyle" />
        <arg value="--report-file=${basedir}/build/logs/checkstyle.xml" />
        <arg value="--standard=${basedir}/vendor/yiisoft/yii2-coding-standards/Yii2/ruleset.xml" />
        <arg value="--extensions=php" />
        <arg value="--ignore=autoload.php" />
 <arg path="${basedir}/models" />
 <arg path="${basedir}/controllers" />
 <arg path="${basedir}/modules" />
 <arg path="${basedir}/tests" />
    </exec>
</target>
最后一个是 PHP 复制粘贴检测器(PHPCPD),它确实如其标签所示:
<target name="phpcpd-ci"
        depends="prepare"
        description="Find duplicate code using PHPCPD and log result in XML format. Intended for usage within a continuous integration environment.">
    <exec executable="${toolsdir}phpcpd">
        <arg value="--log-pmd" />
        <arg path="${basedir}/build/logs/pmd-cpd.xml" />
 <arg path="${basedir}/models" />
 <arg path="${basedir}/controllers" />
 <arg path="${basedir}/modules" />
    </exec>
</target>
如您可能已经注意到的,我粘贴的一些目标是-ci目标。这些目标是 Jenkins 生成所有必要报告所必需的。我们将选择这些目标,并在我们的构建中发布它们。请记住,也要在其他目标上镜像这些更改;我在这里排除了它们以避免冗余。
除了这些更改之外,值得注意的是,我选择了 Yii 2 CheckStyle 规则集来验证语法。这一步对于维护整体代码风格以及与框架开发者和跨团队使用的风格保持同步非常有用。
现在我们已经做出了基本更改,让我们转到 Composer、Yii 和 Codeception 文件。
准备构建环境
默认调用的build命令有一个依赖链,将依次触发prepare目标,运行一些构建的其他目标,运行测试,然后使用 phpDox 生成所需的文档。
prepare目标依赖于clean目标。这两个步骤将清理环境,生成所需的文件夹结构以容纳后续步骤将产生的结果,并设置一些属性以避免两次调用目标。
例如,prepare目标在最后设置了以下属性:
<property name="prepare.done" value="true"/>
当前的目标定义是:
<target name="prepare"
        unless="prepare.done"
        depends="clean, composer.composer, yii.migrate-all"
        description="Prepare for build">
现在已经很明显,除非设置了属性,否则我们可以执行目标的内联内容。clean目标也会发生同样的事情。
在这两个目标中,我们需要更新每次作业运行时清理和重新创建的目录列表。至少,你应该有以下目录,你也可以为clean包括任何与你的项目相关的其他目录。
<target name="clean"
        unless="clean.done"
        description="Cleanup build artifacts">
 <delete dir="${basedir}/runtime/*"/>
 <delete dir="${basedir}/web/assets/*"/>
 <delete dir="${basedir}/vendor"/>
 <delete dir="${basedir}/build/api"/>
 <delete dir="${basedir}/build/logs"/>
 <delete dir="${basedir}/build/pdepend"/>
 <delete dir="${basedir}/build/phpdox"/>
 <delete dir="${basedir}/tests/codeception/_output"/>
    <property name="clean.done" value="true"/>
</target>
对于prepare,以下目录将被重新创建:
<target name="prepare"
        unless="prepare.done"
        depends="clean, composer.composer, yii.migrate-all"
        description="Prepare for build">
    <mkdir dir="${basedir}/build/api"/>
 <mkdir dir="${basedir}/build/logs"/>
 <mkdir dir="${basedir}/build/pdepend"/>
 <mkdir dir="${basedir}/build/phpdox"/>
 <mkdir dir="${basedir}/tests/codeception/_output"/>
    <property name="prepare.done" value="true"/>
</target>
添加所需的配置设置
在我们开始添加自定义文件之前,我们需要添加一些配置文件,这些文件中的一些可执行文件(即 phpmd 和 PHPDox)期望在/build目录中。
jenkins-php项目将提供大部分这些配置文件,并且可以从jenkins-php.org/configuration.html复制。
在 phpmd 的情况下,你可以调整循环复杂度阈值的级别。
    <!-- build/phpmd.xml -->
    <rule ref="rulesets/codesize.xml/CyclomaticComplexity">
        <priority>1</priority>
        <properties>
            <property name="reportLevel" value="7" />
        </properties>
    </rule>
默认值通常是10,但建议的值是5。
对于 PHPDox,情况稍微复杂一些。当前的配置并不特别灵活,所以我决定走最长可能的路线,即使用以下命令生成骨架文件:
$ vendor/bin/phpdox --skel > build/phpdox.xml
这创建了一个包含所有文档选项的文件,然后我创建了自定义的配置文件:
<?xml version="1.0" encoding="utf-8" ?>
<!-- build/phpdox.xml -->
<phpdox  silent="false">
    <project name="Yii2" source="${basedir}/.." workdir="${basedir}/phpdox">
        <collector publiconly="false">
            <include mask="${phpDox.project.source}/models/*.php" />
            <include mask="${phpDox.project.source}/modules/*.php" />
            <include mask="${phpDox.project.source}/controllers/*.php" />
            <include mask="${phpDox.project.source}/vendor/yiisoft/yii2/*.php" />
        </collector>
        <generator output="${basedir}/api">
            <build engine="html" enabled="true">
                <file extension="html" />
            </build>
        </generator>
    </project>
</phpdox>
不论我如何努力,我目前使用的版本(0.7)存在一个 bug,导致它在从 Jenkins 运行时崩溃。这个问题已经在当前的 dev-master 版本中修复,但这给我带来了其他问题。我非常确信,当下一个版本发布时,你应该不会有问题。在我们的情况下,从非工作测试的角度来看,文档并不是那么关键。
在 Ant 中添加 Composer、Yii 和 Codeception 支持
现在我们需要集成准备我们的应用程序进行测试所需的变化。我们将使用 Composer 来安装所需的依赖项,并使用 Yii 来运行所需的迁移。之后,我们需要支持 Codeception,因为它是运行测试的主要工具。
正如我们在prepare的定义中看到的,目标是依赖于clean、composer.composer和yii.migrate-all。
第一个目标是来自github.com/shrikeh/ant-phptools的,它为 Composer 提供了一个包装器。它不是最好的,但它是快速搜索中唯一出现的一个。这个包做得相当不错,并且依赖于一个名为composer.properties的属性文件,项目作者提供了一个示例。
小贴士
在 Ant 脚本中,有一些内置属性是可访问的,这有助于理解,例如,当前目录,并以更可分发的方式构建适当的路径。这可以在ant.apache.org/manual/properties.html找到。
调用composer.composer目标将安装 Composer,如果指定目录中未找到,并使用它来更新所有依赖项。我更希望它清除依赖项的安装目录,然后运行composer install。不幸的是,这是安装composer.lock中定义的依赖项的唯一方法,而不是更新它们。
小贴士
如果您对composer.lock和composer.json之间的差异有任何疑问,请随时退后一步,快速浏览第二章,为测试做准备。
我已经将composer.xml和composer.properties文件放在了/build目录中,并在build.xml中定义的项目开头添加了以下内容。
<include file="${basedir}/build/composer.xml" as="composer"/>
现在,我们可以将composer.composer的依赖项添加到prepare目标中定义的目标列表中,而不会出现任何问题。
第二步是将数据库重置到我们可以使用的状态,我们将通过重新运行所有迁移并应用所有缺失的迁移来实现这一点。
为了这个,我创建了一个简单的 Ant 项目。您可以将它放在您的/build目录中,您可以从github.com/ThePeach/Yii2-Ant下载它。该项目为运行迁移的 Yii CLI 界面提供了一个包装器。
我不会深入这个项目的细节,因为它很简单,可以很容易地理解。
我们可以像在早期的 Composer 项目中那样包含它,如下所示:
<!-- build.xml -->
<include file="${basedir}/build/yii.xml" as="yii"/>
您可以通过调用现成的目标migrate-all来调用它,就像我们在build.xml中为prepare的依赖项所做的那样,或者按照您想要的方式调用migrate MacroDef:
<migrate exec="${yii.script}" action="down"/>
<migrate exec="${yii.script}" action="up"/>
<migrate exec="${yii.tests.script}" action="down"/>
<migrate exec="${yii.tests.script}" action="up"/>
注意
Ant 必须扩展其基本语法,这定义了新的任务,如 MacroDef。您可以在官方 Apache Ant 文档中了解更多信息,该文档可在ant.apache.org/manual/Tasks/macrodef.html找到。
migrate操作将始终将all作为参数传递给yii脚本,这对于我们想要实现的目标来说已经足够了,但这一点可以改进。
以类似的方式添加了 Codeception。您可以从我创建的存储库中获取副本,该存储库位于github.com/ThePeach/CodeCeption-Ant。
此 Ant 项目提供了一个名为run-tests的主要目标,您可以在不担心太多参数的情况下执行它。您还可以在运行时动态传递一些参数来微调 Codeception 的调用,例如codeception.suites和codeception.options。
$ ant -Dcodeception.suites=unit -Dcodeception.options=--coverage-html build
如果未设置,这些将分别分配空值和--xml --coverage-xml --coverage-html。
配置 Jenkins 构建
配置构建的最简单方法是从jenkins-php项目模板开始。您始终可以单独导入它,并在以后将其与自己的项目集成。
在jenkins-php网站上可用的集成页面(jenkins-php.org/integration.html)将解释如何导入项目。请记住根据您的配置调整参数。
现在转到仪表板,点击新的项目jenkins-php,然后从菜单中选择配置。
如果您从未使用过 Jenkins,那么配置页面的长度可能会让您有些害怕。然而,您只需要记住三个部分,我们现在将介绍它们。
通用构建设置
通用构建设置包含构建的启用/禁用开关设置、保留多少构建、何时丢弃构建以及仓库配置等设置。
如果您使用 Git,那么您将能够配置几乎任何东西,例如能够合并分支、提交并推送集成更改等。
我们只需指定分支为*/master,并将部署密钥设置为之前在代码库提供商处保存的值。
构建设置
当决定要运行什么时,您只需要关注构建设置;在这里,您指定目标名称和任何附加选项。
在我们的案例中,这是build,通过点击高级,我们可以将codeception.suites=unit填入属性字段。这将允许我们在不等待很长时间的情况下运行测试构建。
构建后设置
这是配置中最长的部分。所有将在构建仪表板中发布报告的步骤,例如将文档链接到各个部分、选择所需的阈值以决定是否标记作业失败等,都定义在这里。
这里定义的默认阈值相当高,所以您不必担心太多。
我们需要做的唯一更改是关于 Codeception 生成的报告,它将提供 JUnit XML 报告、Clover XML 报告以及 HTML 格式的覆盖率。
在配置页面的末尾,您将找到一个标题为发布 Clover PHP 覆盖率报告的部分,在这里更新 Clover XML 报告路径为tests/codeception/_output/coverage.xml,并将发布 HTML 报告更新为tests/codeception/_output/coverage/。如果您点击高级面板,那么您将能够修改阈值,并可以使用它们来决定您需要从测试中获得多少覆盖率。
在此步骤之后,您将看到发布 xUnit 测试结果报告,在这里将PHPUnit-3.x 模式更改为tests/codeception/_output/report.xml。像之前一样,在下一步中,您可以配置失败测试的阈值。默认情况下,不应该有任何失败的测试。因此,所有字段都将设置为 0。除非您想生活在耻辱中,否则不要更改此设置。
执行作业
让我们测试一切,并检查它是否按预期工作。保存配置,然后点击立即构建来执行作业。
一旦完成,您可以回到构建页面,然后您将看到以下图表:

项目仪表板概览
列表右侧的大多数图表都是快速了解触发失败构建原因的方法。
由 PDepend 生成的两个图表,显示在最上方,分别是所谓的概览金字塔和抽象不稳定性图表。它们都显示了关于您项目的有趣统计数据,您可以根据这些数据在性能、可扩展性和可维护性方面做出一些决策。
小贴士
PDepend 文档提供了关于这两个图表的更多信息,我强烈推荐阅读以下资源:
到目前为止,你应该已经拥有了前进并实施、改进和精通测试以及自动化项目的所有工具。
展望未来
如果你已经到达这个阶段,那么你可能需要考虑一些额外的因素。
正如我们所看到的,自动化结合了大量的报告工具、测试套件和其他项目,以帮助您理解、改进和分析您的项目。
自动化的最大抱怨之一是它执行所有测试的速度。
有一些技术可以将执行这些测试所需的时间减半。虽然 Jenkins 和 jenkins-php 通过执行 build-parallel 提供了并行运行目标的目标,但 Codeception 的情况略有不同,你需要通过扩展我们之前创建的 Ant 项目来采取不同的路线。Codeception 使用 robo-paracept 来并行化测试。
如果你想了解更多关于 Codeception 的信息,那么在 codeception.com/docs/12-ParallelExecution 有一个不错的文章。你会注意到它将通过标记测试组并聚合它们来运行,这样 Paracept 就能够并行执行。
摘要
你已经看到了关于自动化所需了解的大部分内容。这个话题相当广泛,我们已经涵盖了 Jenkins,它的运作方式以及如何配置它。我们学习了如何使用 Ant 来决定要做什么,以及如何驱动构建。我们还查看了构建生成的内容,并在 Jenkins 仪表板上显示。

 
                     
                    
                 
                    
                
 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号