PHP和PHPUnit中的纯测试


PHP生态系统中有许多工具可提供便利的PHP测试。 最著名的之一是PHPUnit ,它几乎是使用这种语言进行测试的同义词。 但是,关于好的测试方法的文章很少。 关于为什么和何时编写测试,什么样的测试等等有很多选择。 但是老实说,如果以后再看不到测试 ,那就写没有意义

测试是一种特殊的文档。 正如我之前在PHP中关于TDD所写的那样 ,该测试将始终(或至少应该)清楚地讨论特定代码段的任务。

如果一个测试不能表达这个想法,那么这个测试是不好的。

我准备了一套技术,可以帮助PHP开发人员编写良好,可读和有用的测试。

让我们从基础开始


有许多人可以毫无疑问地遵循的一组标准技术。 我将提及其中的许多内容,并尝试解释为什么需要它们。

1.测试中不应包含输入输出操作


主要原因 :I / O操作缓慢且不可靠。

:即使您拥有世界上最好的硬件,I / O仍然比内存访问慢。 测试应始终快速进行,否则人们将很少进行测试。

不可靠 :某些文件,二进制文件,套接字,文件夹和DNS记录在您正在测试的某些计算机上可能不可用。 您越依赖I / O测试,您的测试与基础架构的联系就越多。

与I / O有关的操作:

  • 读写文件。
  • 网络通话。
  • 调用外部进程(使用execproc_open等)。

在某些情况下,输入输出操作的存在使您可以更快地编写测试。 但请注意:请确保在您的机器上进行开发,组装和部署时,这些操作是否相同,否则可能会遇到严重的问题。

隔离测试,使它们不需要I / O操作:下面提供了一种体系结构解决方案,该解决方案通过在接口之间共享责任来防止测试执行I / O。

一个例子:

 public function getPeople(): array { $rawPeople = file_get_contents( 'people.json' ) ?? '[]'; return json_decode( $rawPeople, true ); } 

使用这种方法开始测试时,将创建一个本地文件,并会不时创建快照:

 public function testGetPeopleReturnsPeopleList(): void { $people = $this->peopleService ->getPeople(); // assert it contains people } 

为此,我们需要配置运行测试的先决条件。 乍一看,一切看起来都合理,但实际上却很糟糕。

由于没有满足先决条件而跳过测试并不能保证我们软件的质量。 这只会隐藏错误!

我们解决了这种情况 :我们通过将职责转移到接口来隔离I / O操作。

 // extract the fetching // logic to a specialized // interface interface PeopleProvider { public function getPeople(): array; } // create a concrete implementation class JsonFilePeopleProvider implements PeopleProvider { private const PEOPLE_JSON = 'people.json'; public function getPeople(): array { $rawPeople = file_get_contents( self::PEOPLE_JSON ) ?? '[]'; return json_decode( $rawPeople, true ); } } class PeopleService { // inject via __construct() private PeopleProvider $peopleProvider; public function getPeople(): array { return $this->peopleProvider ->getPeople(); } } 

现在我知道JsonFilePeopleProvider在任何情况下都将使用I / O。

可以使用Flysystem文件系统之抽象层来代替file_get_contents()可以很容易地创建存根。

然后为什么我们需要PeopleService ? 好问题。 为此,需要进行测试:挑战架构并删除无用的代码。

2.测试应该有意识且有意义。


主要原因 :测试是一种文档形式。 保持它们的清晰,简洁和可读性。

简洁明了 :没有混乱,没有数千行存根,没有陈述序列。

可读性 :测试应说明一个故事。 为此,“给定,何时,然后”结构非常好。

良好且易读的测试的特征:

  • 仅包含对assert方法的必要调用(最好是一个)。
  • 他非常清楚地说明了在给定条件下应该发生的情况。
  • 它仅测试方法执行的一个分支。
  • 出于任何陈述,他不会在整个宇宙中存根。

重要的是要注意,如果您的实现包含条件表达式,过渡运算符或循环,则测试都应明确覆盖它们。 例如,使早期答案始终包含测试。

我再说一遍:这不是覆盖范围的问题,而是文档的问题。

这是一个令人困惑的测试示例:

 public function testCanFly(): void { $noWings = new Person(0); $this->assertEquals( false, $noWings->canFly() ); $singleWing = new Person(1); $this->assertTrue( !$singleWing->canFly() ); $twoWings = new Person(2); $this->assertTrue( $twoWings->canFly() ); } 

让我们调整“给定时间,然后给定”的格式,看看会发生什么:

 public function testCanFly(): void { // Given $person = $this->givenAPersonHasNoWings(); // Then $this->assertEquals( false, $person->canFly() ); // Further cases... } private function givenAPersonHasNoWings(): Person { return new Person(0); } 

就像“给定”部分一样,“何时”和“然后”可以转移到私有方法中。 这将使您的测试更具可读性。

assertEquals毫无意义。 阅读此内容的人必须跟踪该声明,以了解其含义。

使用特定的语句将使您的测试更具可读性。 assertTrue()应该接收一个布尔变量,而不是类似canFly() !== true的表达式。

在前面的示例中,我们用一个简单的$person->canFly()替换了false$person->canFly()之间的assertEquals

 // ... $person = $this->givenAPersonHasNoWings(); $this->assertFalse( $person->canFly() ); // Further cases... 

现在一切都非常清楚了! 如果一个人没有翅膀,他一定不能飞! 像诗一样读

现在,“更多案例”部分(在我们的文字中出现两次)清楚地表明该测试做出了太多陈述。 testCanFly()方法是完全无用的。

让我们再次改进测试:

 public function testCanFlyIsFalsyWhenPersonHasNoWings(): void { $person = $this->givenAPersonHasNoWings(); $this->assertFalse( $person->canFly() ); } public function testCanFlyIsTruthyWhenPersonHasTwoWings(): void { $person = $this->givenAPersonHasTwoWings(); $this->assertTrue( $person->canFly() ); } // ... 

我们甚至可以重命名测试方法,以使其与实际场景相匹配,例如在testPersonCantFlyWithoutWings ,但testPersonCantFlyWithoutWings一切都适合我。

3.该测试不应依赖于其他测试


主要原因 :测试应该以任何顺序运行并成功运行。

我看不出有足够的理由在测试之间建立互连。 最近,我被要求做一个登录功能测试,我将在这里给出一个很好的例子。

该测试应:

  • 生成用于登录的JWT令牌。
  • 执行登录功能。
  • 批准状态更改。

就像这样:

 public function testGenerateJWTToken(): void { // ... $token $this->token = $token; } // @depends testGenerateJWTToken public function testExecuteAnAmazingFeature(): void { // Execute using $this->token } // @depends testExecuteAnAmazingFeature public function testStateIsBlah(): void { // Poll for state changes on // Logged-in interface } 

这很糟糕,原因如下:

  • PHPUnit无法保证此执行顺序。
  • 测试必须能够独立运行。
  • 并行测试可能会随机失败。

解决此问题的最简单方法是使用给定的,何时,然后方案。 因此,测试将更加周到,它们将讲述一个故事,清楚地表明其依赖性,并说明要测试的功能。

 public function testAmazingFeatureChangesState(): void { // Given $token = $this->givenImAuthenticated(); // When $this->whenIExecuteMyAmazingFeature( $token ); $newState = $this->pollStateFromInterface( $token ); // Then $this->assertEquals( 'my-state', $newState ); } 

我们还需要添加用于身份验证等的测试。此结构是如此好,以至于默认情况下使用Behat

4.始终实现依赖性


主要原因 :语气很差-为全局状态创建存根。 无法创建依赖项的存根不允许测试该功能。

有用的提示: 忘记静态有状态类和单例实例 。 如果您的班级依赖于某种东西,那么就使它得以实现。

这是一个悲伤的例子:

 class FeatureToggle { public function isActive( Id $feature ): bool { $cookieName = $feature->getCookieName(); // Early return if cookie // override is present if (Cookies::exists( $cookieName )) { return Cookies::get( $cookieName ); } // Evaluate feature toggle... } } 

我如何测试这个早期答案?

没错 没办法

为了测试它,我们需要了解Cookies类的行为,并确保我们可以重现与其关联的所有环境,从而得出某些答案。

不要这样做。

如果您将Cookies实例实现为依赖项,则可以纠正这种情况。 测试将如下所示:

 // Test class... private Cookies $cookieMock; private FeatureToggle $service; // Preparing our service and dependencies public function setUp(): void { $this->cookieMock = $this->prophesize( Cookies::class ); $this->service = new FeatureToggle( $this->cookieMock->reveal() ); } public function testIsActiveIsOverriddenByCookies(): void { // Given $feature = $this->givenFeatureXExists(); // When $this->whenCookieOverridesFeatureWithTrue( $feature ); // Then $this->assertTrue( $this->service->isActive($feature) ); // additionally we can assert // no other methods were called } private function givenFeatureXExists(): Id { // ... return $feature; } private function whenCookieOverridesFeatureWithTrue( Id $feature ): void { $cookieName = $feature->getCookieName(); $this->cookieMock->exists($cookieName) ->shouldBeCalledOnce() ->willReturn(true); $this->cookieMock->get($cookieName) ->shouldBeCalledOnce() ->willReturn(true); } 

单调也是如此。 因此,如果您要使对象唯一,那么请正确配置依赖项注入器,而不要使用(反)单例模式。 否则,您将编写仅对reset()setInstance()这样的情况有用的方法。 我认为这太疯狂了。

更改架构以简化测试是完全正常的! 创建有助于测试的方法是不正常的。

5.永远不要测试受保护/私有方法


主要原因是 :它们通过确定行为的签名来影响我们测试功能的方式:在这种情况下,当我输入A时,我期望得到B。 私有/受保护的方法不是功能签名的一部分

我什至不想展示一种“测试”私有方法的方法,但我会提示:您只能使用反射 API来做到这一点。

当您考虑使用反射来测试私有方法时,请始终以某种方式惩罚自己! 不好,不好的开发者!

根据定义,私有方法只能在内部调用。 也就是说,它们不是公开可用的。 这意味着只有来自同一类的公共方法才能调用此类方法。

如果您测试了所有公共方法,那么您还将测试所有私有/受保护方法 。 如果不是这种情况,则可以自由删除私有/受保护的方法;无论如何都不会使用它们。

进阶技巧


希望您还不会觉得无聊。 不过,我不得不谈论基础知识。 现在,我将分享我的观点,以编写影响我的开发过程的干净的测试和决策。

在编写测试时,我不会忘记的最重要的事情是:

  • 研究。
  • 快速反馈。
  • 文献资料
  • 重构
  • 在测试期间进行设计。

1.在开始而不是结束时进行测试


价值 :研究,快速反馈,文档编制,重构,测试过程中的设计。

这是一切的基础。 最重要的方面,包括所有列出的值。 提前编写测试时,这可以帮助您首先了解应如何构造“给定,何时,然后”方案。 为此,您首先要记录文档,更重要的是,请记住需求并将其设置为最重要的方面。

听说在实现之前编写测试会很奇怪吗? 并想象实现某件事有多么奇怪,并且当进行测试以找出答案时,“那么,那么”给出的所有表达式都是没有意义的。

同样,这种方法将每两秒钟检查一次您的期望。 您将尽快获得反馈。 无论外观大小,该功能如何。

绿色测试是重构的理想领域。 主要思想:无测试-无重构。 未经测试进行重构是很危险的。

最后,将结构设置为“何时,然后给定”,这对您来说将变得显而易见,方法应该具有什么接口以及行为方式。 保持测试整洁还会迫使您不断做出不同的架构决策。 这将迫使您创建工厂,接口,破坏继承等。是的,测试将变得更加容易!

如果您的测试是实时文档,可以解释应用程序的工作原理,则必须使它们清晰明了。

2.没有测试比有坏测试要好


:研究,文档,重构。

许多开发人员以这种方式考虑测试:我将编写一个功能,将驱动测试框架,直到测试覆盖一定数量的新行并将其投入运行。

在我看来,当新的开发人员开始使用此功能时,您需要更加注意这种情况。 测试会告诉这个人什么?

如果名称不够详细,测试通常会令人困惑。 更清楚的是: testCanFlytestCanFlyReturnsFalseWhenPersonHasNoWings吗?

如果您的测试只是凌乱的代码,使该框架涵盖了更多行,并且使用了没有意义的示例,那么该是停下来思考一下是否要编写这些测试的时候了。

甚至$b变量分配$a$b或分配与特定用途无关的名称也很无聊。

请记住 :您的测试是实时文档,试图解释您的应用程序应如何运行。 assertFalse($a->canFly())文档不多。 assertFalse($personWithNoWings->canFly())已经很多了。

3.侵入性地运行测试


价值观 :学习,快速反馈,重构。

在开始使用功能之前,请运行测试。 如果它们在您开始工作之前失败了,您将编写代码之前就知道了,并且不必花费宝贵的时间调试甚至根本不在乎的坏测试。

保存文件后,运行测试。 您越早发现某物已损坏,则修复它并继续进行的速度就越快。 如果中断工作流程来解决问题对您而言无济于事,那么可以想象一下,如果以后您不知道该问题,那么您将不得不返回许多步骤。

与同事聊天五分钟或检查来自Github的通知后,运行测试。 如果它们脸红了,那么您就知道您离开的地方。 如果测试为绿色,则可以继续工作。
进行任何重构(甚至是变量名)之后,请运行测试。

认真地,运行该死的测试。 按下保存按钮的频率。
PHPUnit Watcher可以为您做到这一点,甚至可以发送通知!

4.大考验-大责任


价值观 :研究,重构,测试过程中的设计。

理想情况下,每个班级应该有一个测试。 该测试应涵盖此类中的所有公共方法以及每个条件表达式或转换运算符。

您可以采取这样的做法:

  • 一类=一个测试用例。
  • 一种方法=一个或多个测试。
  • 一个替代分支(如果/ switch / try-catch / exception)=一项测试。

因此,对于这个简单的代码,您将需要四个测试:

 // class Person public function eatSlice(Pizza $pizza): void { // test exception if ([] === $pizza->slices()) { throw new LogicException('...'); } // test exception if (true === $this->isFull()) { throw new LogicException('...'); } // test default path (slices = 1) $slices = 1; // test alternative path (slices = 2) if (true === $this->isVeryHungry()) { $slices = 2; } $pizza->removeSlices($slices); } 

您拥有的公共方法越多,将需要进行更多的测试。

没有人喜欢阅读冗长的文档。 由于您的测试也是文档,因此小巧而有意义的做法只会提高其质量和实用性。

这也是一个重要信号,表明您的班级正在累积责任,现在该通过将许多功能转移到其他班级或重新设计系统来重构它了。

5.支持一组测试以解决回归问题


价值观 :研究,文件记录,快速反馈。

考虑以下功能:

 function findById(string $id): object { return fromDb((int) $id); } 

您认为有人正在传播“ 10”,但实际上正在传播“ 10香蕉”。 也就是说,有两个值,但一个是多余的。 你有个错误。

您首先要做什么? 编写一个测试,将这种行为标记为错误!

 public function testFindByIdAcceptsOnlyNumericIds(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( 'Only numeric IDs are allowed.' ); findById("10 bananas"); } 

当然,测试不会传输任何内容。 但是现在您知道需要做什么才能使它们传播。 更正错误,使测试变为绿色,部署应用程序并感到满意。

随身携带该测试。 只要有可能,就使用一组旨在解决回归问题的测试。

仅此而已! 快速反馈,错误修复,文档,抗回归代码和幸福。

最后的话


以上大部分只是我个人观点,是我在职业生涯中发展起来的。 这并不意味着建议是对还是错,仅是一种意见。

Source: https://habr.com/ru/post/zh-CN485124/


All Articles