在不到一秒的时间内测试您的所有业务逻辑
本文与测试无关。这是关于采用一种工作流程,让您在开发功能时保持控制。测试只是这个过程的引擎和美好的结果。
这个工作流程彻底改变了我的编码方式,它总是让我脸上露出笑容。我希望它对你也有同样的作用。最后,您将拥有一个完全开发的功能,满足所有业务规则,以及一个测试套件,可以在不到一秒的时间内对其进行验证。
我使用 PHP 进行演示,但此工作流程完全适用于任何语言。
工作流程和测试挫折
我从哪里开始我的功能?
当需要开发新功能时,通常很难知道从哪里开始。您应该从业务逻辑、控制器还是前端开始?
知道何时停止也同样棘手。如果没有明确的流程,衡量进度的唯一方法就是通过手动测试。一种乏味且容易出错的方法。
别害怕,这只是一个 2000 行的文件,没有任何测试?
你认识这个人。该文件发生了无法解释的事情。您需要更改一行,但每次尝试似乎都会破坏整个项目。如果没有测试的安全网,重构就像在峡谷上走钢丝。
测试速度慢得令人痛苦
为了避免这种混乱,您决定通过端到端测试来测试每个功能。好主意!直到您意识到在等待测试套件完成时您有足够的时间喝五杯咖啡。生产力?窗外。
每次重构后测试都会中断
为了创建即时反馈,您决定为所有课程编写细粒度的测试。然而,现在您所做的每项更改都会导致一系列损坏的测试,并且您最终会花费更多的时间来修复它们,而不是进行实际的更改。这是令人沮丧的、低效的,并且让你害怕每一次重构。
让我们一起梦想吧!什么是完美测试?
即时反馈
反馈是软件开发每个阶段的关键。在开发项目核心时,我需要即时反馈以立即了解我的任何业务规则是否被破坏。在重构过程中,有一个助手告诉我代码是否被破坏或者我是否可以安全地继续,这是一个无价的优势。
关注行为
项目能够做什么,项目行为,是最重要的方面。这些行为应该以用户为中心。即使实现(为使功能正常工作而创建的代码)发生变化,用户的意图也将保持不变。
例如,当用户下订单时,他们希望收到通知。通知用户的方式有很多种:电子邮件、短信、邮件等。虽然用户的意图不会改变,但实施往往会改变。
如果测试代表了用户的意图并且代码不再满足该意图,则测试应该失败。但是,在重构期间,如果代码仍然符合用户的意图,则测试不应该中断。
副驾驶
想象一下在创建新功能的过程中一步步被引导。通过首先编写测试,它将成为您编写正确功能的指南。可能还不完全清楚,但我希望我的测试在编码时能够充当我的 GPS。通过这种方法,我不需要考虑下一步,我只需按照 GPS 指示操作即可。如果这个概念仍然不清楚,别担心!我将在工作流程部分详细解释。 ?
开始之前的一些要求
功能和验收测试
正如我在简介中提到的,本文不是关于测试,而是关于创建功能。为此,我需要一个功能。更具体地说,我需要一个带有示例定义的验收测试的功能。对于每一项功能,团队应建立验收标准或规则,并为每项规则创建一个或多个示例。
⚠️ 这些示例必须以用户/域为中心,不描述任何技术方面。检查示例是否定义良好的一个简单方法是问自己:“我的示例是否适用于任何实现(Web 前端、HTTP API、终端 CLI、现实生活等)?”
例如,如果我想开发“将产品添加到购物篮”功能,我的示例可能如下所示:
这些示例将作为测试的基础。对于更复杂的场景,我使用给定/何时/然后模式来获取更多细节,但对于更简单的场景则没有必要。
以领域为中心的架构
即时反馈和对行为的关注,这不是一个很大的挑战吗?使用 Symfony MVC 服务架构,实现这一点可能很困难。这就是为什么我将使用以领域为中心的架构,我在本文中详细介绍了这一点:构建 Symfony 项目的另一种方法。
以行为为重点的测试
为了专注于行为,我需要以正确的方式构建测试。首先,我描述用户操作之前系统的状态。接下来,我执行用户操作,这会更改系统的状态。最后,我断言系统的状态符合我的期望。
通过这种方式测试,测试并不关心系统如何处理用户操作;它只检查操作是否成功。这意味着如果我们更改用户操作的实现,测试不会中断。但是,如果实现无法按预期更新系统状态,则测试将中断——而这正是我们想要的!
伪造 I/O
为了实现即时反馈,需要模拟系统的某些部分。以域为中心的架构使这成为可能,因为域仅依赖于与外部库交互的接口。这使得伪造这些依赖关系变得非常容易,从而使功能测试运行得非常快。当然,真正的实现也将使用集成测试进行测试(尽管不在本文中)。
GPS 工作流程
在此工作流程中,测试的是我的 GPS!我设定一个目的地,让它引导我,并在我到达时通知我。测试将根据团队提供的示例进行。
输入您的目的地
为了测试业务逻辑和用户意图,我使用功能测试:
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
此文件将包含此功能的所有测试,但让我们从第一个开始。
安排
在第一部分中,我描述了应用程序的状态。这里,有一个空篮子供 ID 为“1”的顾客使用。
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
行为
handler和command代表了用户的意图,这样就很明确了,大家都明白。
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
? :我决定将这个项目中的命令和查询分开,但我们可以有一个 AddProductToBasketUseCase。
断言
最后,是时候描述一下最终结果应该是什么样子了。我希望我的购物篮里有合适的产品。
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
目的地
这是将第一个示例转换为代码的测试。
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
不要害怕错误
此时,我的 IDE 到处都显示错误,因为我在此测试中使用的任何内容都不存在。但这些真的是错误吗,还是只是我们迈向目的地的下一步?我将这些错误视为指令,每次运行测试时,它都会告诉我们下一步该做什么。这样我就不用想太多;我只是跟着GPS走。
? 提示:为了改善开发人员体验,您可以启用文件观察器,该观察器会在您进行更改时自动运行测试套件。由于测试套件速度非常快,因此您将在每次更新时获得即时反馈。
在这个例子中,我将一一解决每个错误。当然,如果你有信心,你可以走捷径。 ?
那么,让我们来进行测试吧!对于每个错误,我都会执行下一步所需的最低限度的操作。
使测试编译
❌? :“错误:找不到类“AppTestsFunctionalBasket””
由于这是此功能的第一次测试,因此大多数类尚不存在。所以第一步,我创建一个篮子对象:
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌? :“错误:找不到类“AppTestsFunctionalInMemoryBasketRepository””
我创建了 InMemoryBasketRepository :
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌? :“错误:找不到类“AppTestsFunctionalAddProductToBasketCommandHandler””
我在 Catalog/Application 文件夹中创建 AddProductToBasketCommandHandler,该处理程序将成为“将产品添加到购物篮”功能的入口点。
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
为了尊重以领域为中心的架构,我为存储库创建了一个接口,这样我们就反转了依赖关系。
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
❌? : “TypeError : AppCatalogApplicationCommandAddProductToBasketAddProductToBasketCommandHandler::__construct(): 参数 #1 ($basketRepository) 必须是 AppCatalogDomainBasketRepository、AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository 类型给定“
现在,InMemory 实现必须实现该接口才能注入到命令处理程序中。
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
❌? :_“错误:找不到类“AppTestsFunctionalAddProductToBasketCommand””
_
我创建了一个命令,因为命令始终是 DTO,所以我将其标记为只读并将所有属性公开。
// src/Catalog/Domain/Basket.php namespace App\Catalog\Domain; class Basket { public function __construct( public readonly string $id, public readonly string $customerId, public array $products = [], ) { } }
❌? : “断言两个对象相等失败。”
测试正在编译!它还是红色的,所以我们还没有完成,让我们继续吧! ?
让我们来看看业务逻辑
是时候编写业务逻辑了。就像我编写测试时一样,即使什么都不存在,我也会编写我期望处理程序处理命令的方式。它无法编译,但测试将再次引导我进行下一步。
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
❌? : “错误:调用未定义的方法 AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository::get()”
我在存储库接口中创建 get 方法:
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌? : “PHP 致命错误:类 AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository 包含 1 个抽象方法,因此必须声明为抽象方法或实现其余方法 (AppCatalogDomainBasketRepository::get)”
现在我在 InMemory 存储库中实现它。由于它只是为了测试,我创建了一个非常简单的实现:
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌? : “错误:调用未定义的方法 AppCatalogDomainBasket::add()”
我创建 add 方法并在篮子对象上实现它
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
❌? : 错误:调用未定义的方法 AppCatalogInfrastructPersistenceInMemoryInMemoryBasketRepository::save()
同样,我在接口中创建方法并在内存存储库中实现它:
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
✅? :
目的地到达! ? ? ?
通过这个工作流程,我已经将开发者体验游戏化了。在完成红色测试的挑战后,看到它变成绿色,你就会注射多巴胺??
重构时间
为了让测试通过,我故意走得快一点。现在,我可以花时间完善测试并以更好的方式实现代码。
测试
测试翻译了示例,但在翻译过程中丢失了用户意图。通过很少的函数,我可以使其更接近原始示例。
示例:
测试:
// src/Catalog/Domain/Basket.php namespace App\Catalog\Domain; class Basket { public function __construct( public readonly string $id, public readonly string $customerId, public array $products = [], ) { } }
代码
这不是本文的重点,所以我不会详细介绍,但我可以使用丰富的领域模型更精确地表达领域模型。
由于测试是绿色的,现在我可以随心所欲地重构,测试有我的支持。 ?
其他例子
正如您在文章开头看到的,一个功能有很多示例来描述它。所以是时候全部实现它们了:
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
我已经通过不同的测试多次遵循此工作流程,并且我的代码的各个部分已经发展到与业务规则保持一致。正如您在第一个测试中看到的那样,我的对象非常简单,有时甚至是基本的。然而,随着新业务规则的引入,它们促使我开发一个更智能的域模型。这是我的文件夹结构:
为了简化测试,同时保留域模型的封装,我引入了构建器和快照模式。
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
我如何使用它:
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
速度⚡
工作流程
由于我完全专注于业务逻辑,因此我的测试会在每一步中指导我,告诉我每次添加新代码时要做什么。此外,我不再需要执行手动测试来确保一切正常,这显着提高了我的效率。
在本文中,我演示了一个非常简单的示例,其中我主要创建了类。然而,随着业务逻辑更加复杂,每条新规则都会增加域模型的复杂性。在重构过程中,我的目标是简化领域模型,同时保持测试绿色。这是一个真正的挑战,但当它最终发挥作用时,会令人难以置信的满足。
我在这里展示的只是完整工作流程的第一部分。正如我提到的,为了拥有完整的功能,我仍然需要实现真正的适配器。在本例中,这将包括使用测试优先方法和真实数据库创建存储库(购物篮、客户和产品)。完成后,我只需在依赖项注入配置中配置真正的实现即可。
测试
我使用纯PHP,不依赖任何框架,只专注于封装所有业务逻辑。有了这个策略,我就不用再担心测试性能了。无论有数百甚至数千个测试,它们仍然会在短短几秒钟内运行,在编码时提供即时反馈。
为了让您了解性能,这里是重复 10,000 次的测试套件:
4秒...⚡
概念
此工作流程位于多个概念的交叉点。让我介绍其中一些,以便您可以在需要时进一步探索它们。
映射示例
为了描述一个功能,最好的方法是提供尽可能多的示例。团队会议可以通过确定该功能如何工作的具体示例来帮助您深入了解该功能。
Given-When-Then 框架是用于形式化这些示例的一个很好的工具。
要将这些示例转换为代码,Gherkin 语言(在 PHP 中使用 Behat)可能会有所帮助。然而,我个人更喜欢直接在 PHPUnit 测试中工作并使用这些关键字命名我的函数。
更进一步:
- Matt Wynne - 介绍示例映射
- Aslak Hellesøy - 介绍示例映射
- Kenny Baas-Schwegler - 使用 DDD 和事件风暴处理“现实生活故事”
- 马丁·福勒 - 给定、何时、然后
第一的
“我们想要什么?”部分可以用首字母缩略词 F.I.R.S.T 来概括:
- 快
- 孤立
- 可重复
- 自我验证
- 彻底
现在您有一个清单可以知道您的测试是否做得很好。
走得更远
- Robert C. Martin - 简洁代码,第 9 章:单元测试,F.I.R.S.T
端口和适配器、六边形、洋葱形和简洁架构
所有这些架构的目的是将业务逻辑与实现细节隔离。无论您使用端口和适配器、六角形还是干净架构,核心思想都是使业务逻辑框架与框架无关并且易于测试。
一旦您记住了这一点,就会有各种各样的实现,而最好的实现取决于您的上下文和偏好。这种架构的一个主要优点是,通过隔离业务逻辑,它可以实现更高效的测试。
走得更远
- Alistair Cockburn - 六角形建筑
- 鲍勃叔叔 - 干净的建筑
- Herberto Graca - DDD、六边形、洋葱、清洁、CQRS,……我如何将它们放在一起
由外而内的钻石测试策略
我猜你已经熟悉了测试金字塔,相反我更喜欢使用菱形表示。 Thomas Pierrain 描述的该策略是用例驱动的,重点关注我们应用程序的行为。
此策略还鼓励对应用程序进行黑盒测试,重点关注其产生的结果,而不是其产生结果的方式。这种方法使重构变得更加容易。
走得更远
- 托马斯·皮尔兰 - 由外而内的钻石? TDD #1 - 一种由(并为)普通人打造的风格
- Thomas Pierrain - 由外而内的钻石? TDD #2(风格剖析)
- Thomas Pierrain - 使用“由外而内的钻石”编写反脆弱和领域驱动测试 ◆ TDD
一厢情愿的编程
正如您在整个工作流程中看到的那样,在测试和命令处理程序中,我总是在它存在之前就写下我想要的内容。我许下了一些“愿望”。这种方法被称为一厢情愿的编程。它允许您在决定如何实现之前想象系统的结构以及与之交互的理想方式。当与测试相结合时,它成为指导您编程的强大方法。
安排、行动、断言
这种模式有助于有效地构建测试。首先,将系统设置为正确的初始状态。接下来,执行状态修改器,例如命令或用户操作。最后,进行断言以确保系统在操作后处于预期状态。
走得更远
- 比尔·韦克 - 3A – 安排、行动、断言
最后的想法
我们已经介绍了以领域为中心的架构与验收测试驱动开发相结合如何改变您的开发体验。即时反馈、强大的测试以及对用户意图的关注使编码不仅更高效,而且更有趣。
尝试这个工作流程,你可能会发现每次测试变绿时你自己都会微笑✅ ➡️ ?
请在评论中告诉我你的完美工作流程是什么,或者你会在这个工作流程中改进什么!
以上是在不到一秒的时间内测试您的所有业务逻辑的详细内容。更多信息请关注PHP中文网其他相关文章!

热AI工具

Undresser.AI Undress
人工智能驱动的应用程序,用于创建逼真的裸体照片

AI Clothes Remover
用于从照片中去除衣服的在线人工智能工具。

Undress AI Tool
免费脱衣服图片

Clothoff.io
AI脱衣机

Video Face Swap
使用我们完全免费的人工智能换脸工具轻松在任何视频中换脸!

热门文章

热工具

记事本++7.3.1
好用且免费的代码编辑器

SublimeText3汉化版
中文版,非常好用

禅工作室 13.0.1
功能强大的PHP集成开发环境

Dreamweaver CS6
视觉化网页开发工具

SublimeText3 Mac版
神级代码编辑软件(SublimeText3)

JWT是一种基于JSON的开放标准,用于在各方之间安全地传输信息,主要用于身份验证和信息交换。1.JWT由Header、Payload和Signature三部分组成。2.JWT的工作原理包括生成JWT、验证JWT和解析Payload三个步骤。3.在PHP中使用JWT进行身份验证时,可以生成和验证JWT,并在高级用法中包含用户角色和权限信息。4.常见错误包括签名验证失败、令牌过期和Payload过大,调试技巧包括使用调试工具和日志记录。5.性能优化和最佳实践包括使用合适的签名算法、合理设置有效期、

PHP8.1中的枚举功能通过定义命名常量增强了代码的清晰度和类型安全性。1)枚举可以是整数、字符串或对象,提高了代码可读性和类型安全性。2)枚举基于类,支持面向对象特性,如遍历和反射。3)枚举可用于比较和赋值,确保类型安全。4)枚举支持添加方法,实现复杂逻辑。5)严格类型检查和错误处理可避免常见错误。6)枚举减少魔法值,提升可维护性,但需注意性能优化。

SOLID原则在PHP开发中的应用包括:1.单一职责原则(SRP):每个类只负责一个功能。2.开闭原则(OCP):通过扩展而非修改实现变化。3.里氏替换原则(LSP):子类可替换基类而不影响程序正确性。4.接口隔离原则(ISP):使用细粒度接口避免依赖不使用的方法。5.依赖倒置原则(DIP):高低层次模块都依赖于抽象,通过依赖注入实现。

会话劫持可以通过以下步骤实现:1.获取会话ID,2.使用会话ID,3.保持会话活跃。在PHP中防范会话劫持的方法包括:1.使用session_regenerate_id()函数重新生成会话ID,2.通过数据库存储会话数据,3.确保所有会话数据通过HTTPS传输。

静态绑定(static::)在PHP中实现晚期静态绑定(LSB),允许在静态上下文中引用调用类而非定义类。1)解析过程在运行时进行,2)在继承关系中向上查找调用类,3)可能带来性能开销。

RESTAPI设计原则包括资源定义、URI设计、HTTP方法使用、状态码使用、版本控制和HATEOAS。1.资源应使用名词表示并保持层次结构。2.HTTP方法应符合其语义,如GET用于获取资源。3.状态码应正确使用,如404表示资源不存在。4.版本控制可通过URI或头部实现。5.HATEOAS通过响应中的链接引导客户端操作。

在PHP中,异常处理通过try,catch,finally,和throw关键字实现。1)try块包围可能抛出异常的代码;2)catch块处理异常;3)finally块确保代码始终执行;4)throw用于手动抛出异常。这些机制帮助提升代码的健壮性和可维护性。

匿名类在PHP中的主要作用是创建一次性使用的对象。1.匿名类允许在代码中直接定义没有名字的类,适用于临时需求。2.它们可以继承类或实现接口,增加灵活性。3.使用时需注意性能和代码可读性,避免重复定义相同的匿名类。
