具有单一职责原则的 TDD

让我们从我认为是 SOLID 原则中最重要的原则之一开始。你是否熟悉 "神类" 或 "神对象" --一个类几乎可以做所有事情?一个类就能完成登录、注册、显示注册用户等所有操作?如果有两个开发人员在开发同一个神类,你能想象这有多么具有挑战性吗?而当你把它部署到生产环境后,发现在显示注册用户列表的部分出现了问题,又会发生什么呢?你将不得不更改或修复该神类,但现在登录和注册的神类已被修改,这些流程也可能受到影响。如果只是修复注册用户列表,那么登录和注册功能回归的风险会更大。修复了一项功能,就有可能破坏其他功能。

这就是 SRP 的意义所在。SRP 规定,一个类只能有一个主要职责和一个需要更改的原因。就这么简单吗?有时并非如此。一个 Login 类应该只知道让用户登录,而不是让程序负责显示注册用户列表或结账购物车,但有时在哪里划线会变得非常主观。

接下来,我们将开始编写实际的解决方案代码,同时尝试实现 SRP

编写解决方案代码

我们有一个失败的测试,测试我们的应用程序能否创建一个玩具车模型并将其持久化到数据库中,但我们甚至还没有一个数据库表。没关系,我们现在只关注 PHP 方面的问题。

Model class

对于我们的 PHP 处理器类来说,最好是处理对象,而不是直接了解数据库表行等信息。让我们创建一个普通旧 PHP 对象 (POPO),它将代表一个玩具车模型,而无需关心数据库结构:

  1. 创建包含以下内容的以下文件:

    codebase/symfony/src/Model/ToyCar.php
    <?php
    
    namespace App\Model;
    
    class ToyCar
    {
        /**
        * @var int
        */
        private $id;
    
        /**
        * @var string
        */
        private $name;
    
        /**
        * @var CarManufacturer
        */
        private $manufacturer;
    
        /**
        * @var ToyColor
        */
        private $colour;
    
        /**
        * @var int
        */
        private $year;
    }

    声明属性后,最好为所有这些属性生成访问器(accessors)和突变器(mutators),而不是直接访问它们。

    正如你所看到的,这只是一个 POPO 类。没有任何花哨的东西。没有任何关于如何在数据库中持久化的信息。它的职责只是成为一个模型,代表一辆玩具(toy)车。

  2. 我们还创建 CarManufacturerToyColor 模型。使用以下内容创建以下类:

    codebase/symfony/src/Model/ToyColor.php
    <?php
    
    namespace App\Model;
    
    class ToyColor
    {
        /**
        * @var int
        */
        private $id;
    
        /**
        * @var string
        */
        private $name;
    }

    声明属性后,生成该类的访问器(accessors)和修改器(mutators)。

  3. 有关汽车制造商的信息,请参阅以下内容:

    codebase/symfony/src/Model/CarManufacturer.php
    <?php
    
    namespace App\Model;
    
    class CarManufacturer
    {
        /**
        * @var int
        */
        private $id;
    
        /**
        * @var string
        */
        private $name;
    }

    现在,也为此类生成访问器和修改器。

    现在,我们有了主 ToyCar 模型,它也使用了 ToyColorCarManufacturer 模型。正如您所看到的,与 ToyCar 模型一样,这两个类也不负责保存或读取数据。

正如你所记得的,我们正在使用 Doctrine ORM 作为与数据库交互的工具。如果愿意,我们也可以在处理器类中直接使用 Doctrine 实体,但这意味着我们的处理器类现在将使用一个依赖于 Doctrine 的类。如果我们需要使用不同的 ORM 怎么办?为了减少耦合,我们将在接下来创建的处理器类中使用 codebase/symfony/ src/Model/ToyCar.php

Processor class

为了创建和持久化玩具车模型,我们需要一个类来处理它。问题是,现阶段我们还没有数据库—​我们该在哪里持久化玩具车模型呢?目前还没有,但我们仍然可以通过测试:

  1. 使用以下内容创建以下接口:

    codebase/symfony/src/DAL/Writer/WriterInterface.php
    <?php
    
    namespace App\DAL\Writer;
    
    interface WriterInterface
    {
        /**
        * @param $model
        * @return bool
        */
        public function write($model): bool;
    }

    我们创建了一个非常简单的接口,我们的数据写入器对象可以实现该接口。然后我们将在我们的处理器类中使用这个接口。

  2. 现在,让我们创建玩具车工作流程或处理器类。创建包含以下内容的以下类:

    codebase/symfony/src/Processor/ToyCarProcessor.php
    <?php
    
    namespace App\Processor;
    
    use App\DAL\Writer\WriterInterface;
    use App\Model\ToyCar;
    use App\Validator\ToyCarValidationException;
    
    class ToyCarProcessor
    {
        /**
        * @var WriterInterface
        */
        private $dataWriter;
    
        /**
        * @param ToyCar $toyCar
        * @return bool
        * @throws ToyCarValidationException
        */
        public function create(ToyCar $toyCar)
        {
            // Do some validation here
            $this->validate($toyCar);
    
            // Write the data
            $result = $this->getDataWriter()->write($toyCar);
    
            // Do other stuff.
    
            return $result;
        }
    
        /**
        * @param ToyCar $toyCar
        * @throws ToyCarValidationException
        */
        public function validate(ToyCar $toyCar)
        {
            if (is_null($toyCar->getName())) {
                throw new ToyCarValidationException('Invalid Toy Car Data');
            }
        }
    
        /**
        * @return WriterInterface
        */
        public function getDataWriter(): WriterInterface
        {
            return $this->dataWriter;
        }
    
        /**
        * @param WriterInterface $dataWriter
        */
        public function setDataWriter(WriterInterface $dataWriter): void
        {
            $this->dataWriter = $dataWriter;
        }
    }

我们创建了一个处理器类,它有一个 create 方法,接受我们之前创建的玩具车模型,然后尝试使用一个不存在的写入器类的实例来写入模型。如果你公司的另一位开发人员正在开发数据写入器类,而他需要 2 周时间才能完成,你会怎么办?你会等上 2 周才能通过集成测试吗?

如果你的处理器类必须在数据写入数据库后验证数据并做其他事情,那么这些程序是否也应该因为你在等待其他开发人员完成工作而被延迟呢?可能不会!我们可以暂时使用测试替身来替代缺失的依赖关系。

Test doubles

在大多数情况下,针对已构建了所有依赖项的功能进行测试是非常困难或不切实际的。有时,我们需要一种解决方案来测试我们想要的特定功能,即使我们还没有构建其他依赖项,或者只是想隔离或只针对特定功能进行测试。在这里,我们可以使用测试替身。有关 PHPUnit 测试替身(test doubles)的更多信息,请访问 https://phpunit.readthedocs.io/en/9.5/test-doubles.html

在软件测试中,test double 的种类通常包括以下几种:

  • Stub(桩):替代品,它提供了预定义的行为,通常返回固定的值,用来模拟被测试组件的某些方法或函数。

  • Mock(模拟):通过预设期望来验证交互,例如调用了某个方法的次数或者传递给方法的参数。它通常用于验证代码是否正确地与外部系统或其他组件交互。

  • Fake(伪造):一个简化的替代品,它用简化版的实现来替代真实的依赖组件。例如,使用一个内存数据库代替真实的数据库。

  • Spy(间谍):类似于 mock,但它不会预先设定行为。它记录被调用的方法和参数,以便后续检查。

  • Dummy(虚拟对象):最简单的一种替代物,通常是传递给方法的无用对象,它没有任何行为,仅仅用于填充参数。

Mock and Stub

我们刚刚创建的处理器类需要 ToyValidatorInterfaceWriterInterface 的具体实例。由于我们还没有创建这些类,我们仍然可以通过使用 Mock 对象来通过测试。在 PHPUnit 中,Mock 对象是一个扩展了 Stub 接口的接口。这意味着在代码中,Mock 对象是 Stub 接口的实现。用 Mock 对象替换 ToyValidatorInterfaceWriterInterface 的实例,并在执行特定方法时设置返回值的过程称为存根(stubbing)。让我们来真正尝试一下:

  1. 回到 ToyCarProcessorTest 类并使用以下内容重构它:

    codebase/symfony/tests/Integration/Processor/ToyCarProcessorTest.php
    <?php
    
    namespace App\Tests\Integration\Repository;
    
    use App\DAL\Writer\WriterInterface;
    use App\Model\CarManufacturer;
    use App\Model\ToyCar;
    use App\Model\ToyColor;
    use App\Processor\ToyCarProcessor;
    use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
    
    class ToyCarProcessorTest extends KernelTestCase
    {
        /**
        * @param ToyCar $toyCarModel
        * @throws \App\Validator\ToyCarValidationException
        * @dataProvider provideToyCarModel
        */
        public function testCanCreate(ToyCar $toyCarModel): void
        {
            // Mock: Data writer
            $toyWriterStub = $this->createMock(WriterInterface::class);
            $toyWriterStub
                ->method(‘write’)
                ->willReturn(true);
    
            // Processor Class
            $processor = new ToyCarProcessor();
            $processor->setDataWriter($toyWriterStub);
    
            // Execute
            $result = $processor->create($toyCarModel);
    
            $this->assertTrue($result);
        }
    
        public function provideToyCarModel(): array
        {
            // Toy Car Color
            $toyColor = new ToyColor();
            $toyColor->setName(‘Black’);
    
            // Car Manufacturer
            $carManufacturer = new CarManufacturer();
            $carManufacturer->setName(‘Ford’);
    
            // Toy Car
            $toyCarModel = new ToyCar();
            $toyCarModel->setName(‘Mustang’);
            $toyCarModel->setColour($toyColor);
            $toyCarModel->setManufacturer($carManufacturer);
            $toyCarModel->setYear(1968);
    
            return [
                [$toyCarModel],
            ];
        }
    }

    testCanCreate 函数中,我们为 ValidationModelToyCarValidatorToyCarWriter 类创建了模拟对象。然后,我们实例化 ToyCarCreator 主类,同时将模拟的 ToyCarValidatorToyCarWriter 类传入其构造函数。这就是所谓的 "依赖注入",本章稍后将进一步讨论。最后,我们运行 ToyCarCreator 的创建方法,模拟开发人员尝试创建新的玩具车记录:

  2. 让我们输入以下命令来运行测试,看看得到什么结果:

    /var/www/html/symfony# ./vendor/bin/phpunit --filter ToyCarProcessorTest

    然后您应该看到以下结果:

    image 2023 10 24 10 28 27 878
    Figure 1. Figure 8.5 – Passed the test using a stub

我们通过了测试,尽管我们还没有在数据库中持久化任何东西。在规模更大、更复杂的项目中,经常会遇到这样的情况:即使其他依赖项尚未构建或过于繁琐而无法作为测试的一部分,你也不得不依赖测试替身来隔离并专注于你的测试。

现在回到 SRP,我们的 ToyCarProcessor 现在有两个职责:验证和创建玩具车模型。为了让其他开发人员使用你的类的 validate 方法。让我们重构代码,重新定义 ToyCarProcessor 类的重点和职责:

  1. 重命名以下类:

    • ToyCarProcessor.php 重命名为 ToyCarCreator.php

    • ToyCarProcessorTest.php 重命名为 ToyCarCreatorTest.php

  2. 接下来,让我们重构 ToyCarCreatorTest.php 类。打开以下类并将内容替换为以下内容:

    codebase/symfony/tests/Integration/Processor/ToyCarCreatorTest.php
    <?php
    
    namespace App\Tests\Integration\Repository;
    
    use App\DAL\Writer\WriterInterface;
    use App\Model\CarManufacturer;
    use App\Model\ToyCar;
    use App\Model\ToyColor;
    use App\Processor\ToyCarCreator;
    use App\Validator\ValidatorInterface;
    use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
    
    class ToyCarCreatorTest extends KernelTestCase
    {
        /**
        * @param ToyCar $toyCarModel
        * @throws \App\Validator\ToyCarValidationException
        * @dataProvider provideToyCarModel
        */
        public function testCanCreate(ToyCar $toyCarModel): void
        {
            // Mock 1: Validator
            $validatorStub = $this->createMock
            (ValidatorInterface::class);
            $validatorStub
                ->method(‘validate’)
                ->willReturn(true);
    
            // Mock 2: Data writer
            $toyWriterStub = $this->createMock(WriterInterface::class);
            $toyWriterStub
                ->method(‘write’)
                ->willReturn(true);
    
            // Processor Class
            $processor = new ToyCarCreator();
            $processor->setValidator($validatorStub);
            $processor->setDataWriter($toyWriterStub);
    
            // Execute
            $result = $processor->create($toyCarModel);
            $this->assertTrue($result);
        }
    
        public function provideToyCarModel(): array
        {
            // Toy Car Color
            $toyColor = new ToyColor();
            $toyColor->setName(‘Black’);
    
            // Car Manufacturer
            $carManufacturer = new CarManufacturer();
            $carManufacturer->setName(‘Ford’);
    
            // Toy Car
            $toyCarModel = new ToyCar();
            $toyCarModel->setName(‘Mustang’);
            $toyCarModel->setColour($toyColor);
            $toyCarModel->setManufacturer($carManufacturer);
            $toyCarModel->setYear(1968);
            return [
                [$toyCarModel],
            ];
        }
    }

    如您所见,我们添加了一个新的 Mock 对象用于验证。我将在重构 ToyCarCreator.php 类的内容后解释为什么我们必须这样做。让我们创建一个验证器接口,然后重构 ToyCarCreator 类。

  3. 创建包含以下内容的以下文件:

    codebase/symfony/src/Validator/ValidatorInterface.php
    <?php
    
    namespace App\Validator;
    
    interface ValidatorInterface
    {
        /**
        * @param $input
        * @return bool
        * @throws ToyCarValidationException
        */
        public function validate($input): bool;
    }
  4. 打开 codebase/symfony/src/Processor/ToyCarCreator.php 并使用以下内容:

    <?php
    
    namespace App\Processor;
    
    use App\DAL\Writer\WriterInterface;
    use App\Model\ToyCar;
    use App\Validator\ToyCarValidationException;
    use App\Validator\ValidatorInterface;
    
    class ToyCarCreator
    {
        /**
        * @var ValidatorInterface
        */
        private $validator;
        /**
        * @var WriterInterface
        */
        private $dataWriter;
    
        /**
        * @param ToyCar $toyCar
        * @return bool
        * @throws ToyCarValidationException
        */
        public function create(ToyCar $toyCar): bool
        {
            // Do some validation here and so on...
            $this->getValidator()->validate($toyCar);
            // Write the data
            $result = $this->getDataWriter()->write($toyCar);
            // Do other stuff.
            return $result;
        }
    }

    接下来,为类中声明的私有属性添加必要的访问器和突变器。

    我们重新命名该类,只是为了给它一个更具体的名称。有时,将类命名为其它名称有助于清理代码。另外,你会发现我们删除了公开可见的 validate 类。该类不再包含任何验证逻辑,它只知道在尝试持久化数据之前会运行一个验证例程。这就是该类的主要职责。

    我们仍未编写任何验证和数据持久化代码,但让我们看看是否仍能通过测试,以检验该类的主要职责,即完成以下工作:

    1. 接受一个 ToyCar 模型对象。

    2. 运行验证例程。

    3. 尝试保留数据。

    4. 返回结果。

  5. 运行以下命令:

/var/www/html/symfony# ./vendor/bin/phpunit --filter ToyCarCreatorTest

现在,您应该看到以下结果:

image 2023 10 24 10 59 18 202
Figure 2. Figure 8.6 – Passing the test using two stubs

在本节中,我们使用 BDDTDD 来指导我们编写解决方案代码。我们创建了具有单一职责的 POPOs。我们还创建了一个 ToyCarCreator 类,该类不包含验证逻辑,也不包含持久化机制。它知道自己需要做一些验证和持久化工作,但它没有这些程序的具体实现。每个类都有自己的专长或特定工作,或特定的单一职责。

很好—​现在,即使重构后,我们也能再次通过测试了。接下来,让我们继续编写解决方案代码,遵循 SOLID 原则中的 O,即开放-封闭原则(Open-Closed Principle,OCP)。