本文与测试无关。这是关于采用一种工作流程,让您在开发功能时保持控制。测试只是这个过程的引擎和美好的结果。
这个工作流程彻底改变了我的编码方式,它总是让我脸上露出笑容。我希望它对你也有同样的作用。最后,您将拥有一个完全开发的功能,满足所有业务规则,以及一个测试套件,可以在不到一秒的时间内对其进行验证。
我使用 PHP 进行演示,但此工作流程完全适用于任何语言。
当需要开发新功能时,通常很难知道从哪里开始。您应该从业务逻辑、控制器还是前端开始?
知道何时停止也同样棘手。如果没有明确的流程,衡量进度的唯一方法就是通过手动测试。一种乏味且容易出错的方法。
你认识这个人。该文件发生了无法解释的事情。您需要更改一行,但每次尝试似乎都会破坏整个项目。如果没有测试的安全网,重构就像在峡谷上走钢丝。
为了避免这种混乱,您决定通过端到端测试来测试每个功能。好主意!直到您意识到在等待测试套件完成时您有足够的时间喝五杯咖啡。生产力?窗外。
为了创建即时反馈,您决定为所有课程编写细粒度的测试。然而,现在您所做的每项更改都会导致一系列损坏的测试,并且您最终会花费更多的时间来修复它们,而不是进行实际的更改。这是令人沮丧的、低效的,并且让你害怕每一次重构。
反馈是软件开发每个阶段的关键。在开发项目核心时,我需要即时反馈以立即了解我的任何业务规则是否被破坏。在重构过程中,有一个助手告诉我代码是否被破坏或者我是否可以安全地继续,这是一个无价的优势。
项目能够做什么,项目行为,是最重要的方面。这些行为应该以用户为中心。即使实现(为使功能正常工作而创建的代码)发生变化,用户的意图也将保持不变。
例如,当用户下订单时,他们希望收到通知。通知用户的方式有很多种:电子邮件、短信、邮件等。虽然用户的意图不会改变,但实施往往会改变。
如果测试代表了用户的意图并且代码不再满足该意图,则测试应该失败。但是,在重构期间,如果代码仍然符合用户的意图,则测试不应该中断。
想象一下在创建新功能的过程中一步步被引导。通过首先编写测试,它将成为您编写正确功能的指南。可能还不完全清楚,但我希望我的测试在编码时能够充当我的 GPS。通过这种方法,我不需要考虑下一步,我只需按照 GPS 指示操作即可。如果这个概念仍然不清楚,别担心!我将在工作流程部分详细解释。 ?
正如我在简介中提到的,本文不是关于测试,而是关于创建功能。为此,我需要一个功能。更具体地说,我需要一个带有示例定义的验收测试的功能。对于每一项功能,团队应建立验收标准或规则,并为每项规则创建一个或多个示例。
⚠️ 这些示例必须以用户/域为中心,不描述任何技术方面。检查示例是否定义良好的一个简单方法是问自己:“我的示例是否适用于任何实现(Web 前端、HTTP API、终端 CLI、现实生活等)?”
例如,如果我想开发“将产品添加到购物篮”功能,我的示例可能如下所示:
这些示例将作为测试的基础。对于更复杂的场景,我使用给定/何时/然后模式来获取更多细节,但对于更简单的场景则没有必要。
即时反馈和对行为的关注,这不是一个很大的挑战吗?使用 Symfony MVC 服务架构,实现这一点可能很困难。这就是为什么我将使用以领域为中心的架构,我在本文中详细介绍了这一点:构建 Symfony 项目的另一种方法。
为了专注于行为,我需要以正确的方式构建测试。首先,我描述用户操作之前系统的状态。接下来,我执行用户操作,这会更改系统的状态。最后,我断言系统的状态符合我的期望。
通过这种方式测试,测试并不关心系统如何处理用户操作;它只检查操作是否成功。这意味着如果我们更改用户操作的实现,测试不会中断。但是,如果实现无法按预期更新系统状态,则测试将中断——而这正是我们想要的!
为了实现即时反馈,需要模拟系统的某些部分。以域为中心的架构使这成为可能,因为域仅依赖于与外部库交互的接口。这使得伪造这些依赖关系变得非常容易,从而使功能测试运行得非常快。当然,真正的实现也将使用集成测试进行测试(尽管不在本文中)。
在此工作流程中,测试的是我的 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 次的测试套件:
此工作流程位于多个概念的交叉点。让我介绍其中一些,以便您可以在需要时进一步探索它们。
为了描述一个功能,最好的方法是提供尽可能多的示例。团队会议可以通过确定该功能如何工作的具体示例来帮助您深入了解该功能。
Given-When-Then 框架是用于形式化这些示例的一个很好的工具。
要将这些示例转换为代码,Gherkin 语言(在 PHP 中使用 Behat)可能会有所帮助。然而,我个人更喜欢直接在 PHPUnit 测试中工作并使用这些关键字命名我的函数。
“我们想要什么?”部分可以用首字母缩略词 F.I.R.S.T 来概括:
现在您有一个清单可以知道您的测试是否做得很好。
所有这些架构的目的是将业务逻辑与实现细节隔离。无论您使用端口和适配器、六角形还是干净架构,核心思想都是使业务逻辑框架与框架无关并且易于测试。
一旦您记住了这一点,就会有各种各样的实现,而最好的实现取决于您的上下文和偏好。这种架构的一个主要优点是,通过隔离业务逻辑,它可以实现更高效的测试。
我猜你已经熟悉了测试金字塔,相反我更喜欢使用菱形表示。 Thomas Pierrain 描述的该策略是用例驱动的,重点关注我们应用程序的行为。
此策略还鼓励对应用程序进行黑盒测试,重点关注其产生的结果,而不是其产生结果的方式。这种方法使重构变得更加容易。
正如您在整个工作流程中看到的那样,在测试和命令处理程序中,我总是在它存在之前就写下我想要的内容。我许下了一些“愿望”。这种方法被称为一厢情愿的编程。它允许您在决定如何实现之前想象系统的结构以及与之交互的理想方式。当与测试相结合时,它成为指导您编程的强大方法。
这种模式有助于有效地构建测试。首先,将系统设置为正确的初始状态。接下来,执行状态修改器,例如命令或用户操作。最后,进行断言以确保系统在操作后处于预期状态。
我们已经介绍了以领域为中心的架构与验收测试驱动开发相结合如何改变您的开发体验。即时反馈、强大的测试以及对用户意图的关注使编码不仅更高效,而且更有趣。
尝试这个工作流程,你可能会发现每次测试变绿时你自己都会微笑✅ ➡️ ?
请在评论中告诉我你的完美工作流程是什么,或者你会在这个工作流程中改进什么!
以上是在不到一秒的时间内测试您的所有业务逻辑的详细内容。更多信息请关注PHP中文网其他相关文章!