遵循开闭原则的 TDD

OCP 最初由 Bertrand Meyer 定义,但在本章中,我们将遵循 Robert C. Martin 定义的后期版本,也称为多态 OCP

OCP 规定,对象应该对扩展开放,对修改关闭。这样做的目的是,我们应该能够通过扩展原始代码来修改行为或功能,而不是直接重构原始代码。这很好,因为这将帮助我们开发人员和测试人员对我们正在处理的单子更有信心,因为我们没有触及可能在其他地方使用的原始代码—​减少了回归的风险。

在我们的 ToyCarCreateTest 类中,我们正在存根验证器对象,因为我们还没有编写具体的验证器类。实现验证的方法有很多种,但在本例中,我们将尽量使其简单化。让我们回到代码中,创建一个验证器:

  1. 创建一个新的测试类,内容如下:

    codebase/symfony/tests/Unit/Validator/ToyCarValidatorTest.php
    <?php
    
    namespace App\Tests\Unit\Validator;
    
    use App\Model\CarManufacturer;
    use App\Model\ToyCar;
    use App\Model\ToyColor;
    use App\Validator\ToyCarValidator;
    use PHPUnit\Framework\TestCase;
    
    class ToyCarValidatorTest extends TestCase
    {
        /**
        * @param ToyCar $toyCar
        * @param bool $expected
        * @dataProvider provideToyCarModel
        */
        public function testCanValidate(ToyCar $toyCar,bool $expected): void
        {
            $validator = new ToyCarValidator();
            $result = $validator->validate($toyCar);
    
            $this->assertEquals($expected, $result);
        }
    
        public function provideToyCarModel(): array
        {
            // Toy Car Color
            $toyColor = new ToyColor();
            $toyColor->setName(‘White’);
    
            // Car Manufacturer
            $carManufacturer = new CarManufacturer();
            $carManufacturer->setName(‘Williams’);
    
            // Toy Car
            $toyCarModel = new ToyCar();
            $toyCarModel->setName(‘’); // Should fail.
            $toyCarModel->setColour($toyColor);
            $toyCarModel->setManufacturer($carManufacturer);
            $toyCarModel->setYear(2004);
    
            return [
                [$toyCarModel, false],
            ];
        }
    }

    创建测试类后,像往常一样,我们需要运行测试以确保 PHPUnit 识别您的测试。

  2. 运行以下命令:

    /var/www/html/symfony# ./vendor/bin/phpunit --testsuite=Unit --filter ToyCarValidatorTest

    由于我们尚未创建验证器类,因此请确保您收到的是错误信息。还记得红色阶段吗?你会注意到,在数据提供程序中,我们为名称设置了一个空字符串。只要验证器类看到玩具车名称为空字符串,就会返回 false

  3. 现在,我们的测试失败了,让我们继续创建类以通过它。创建一个新的 PHP 类,内容如下:

    codebase/symfony/src/Validator/ToyCarValidator.php
    <?php
    
    namespace App\Validator;
    
    use App\Model\ToyCar;
    
    class ToyCarValidator
    {
        public function validate(ToyCar $toyCar): bool
        {
            if (!$toyCar->getName()) {
                return false;
            }
            return true;
        }
    }

    我们创建了一个非常简单的验证逻辑,仅检查玩具车的名称(如果它不是空字符串)。现在,让我们再次运行测试。

  4. 运行以下命令:

    /var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter ToyCarValidatorTest

    您现在应该看到通过的测试。

好了,现在我们可以确保玩具车模型的名称始终是一个不为空的字符串,但问题是,如果我们想添加更多的验证逻辑怎么办?我们将不得不继续修改 ToyCarValidator 类。这并没有错。只是可以说,遵循 OCP 更好,这样我们就不会不停地修改代码—​类的修改越少,破坏的风险就越小。让我们重构解决方案代码,再次通过测试:

  1. 让我们为年份添加一些验证逻辑,同时保留玩具车名称验证。

  2. 现在,我们正处于绿色阶段,进入重构阶段。在本方案中,我们将使用 第 4 章 "在 PHP 中使用面向对象编程" 中讨论过的多态性来代替继承。创建以下内容的接口:

    codebase/symfony/src/Validator/ToyCarValidatorInterface.php
    <?php
    
    namespace App\Validator;
    
    use App\Model\ToyCar;
    use App\Model\ValidationModel;
    
    interface ToyCarValidatorInterface
    {
        public function validate(ToyCar $toyCar): ValidationModel;
    }
  3. 我们创建了一个新的 ToyCarValidatorInterface 接口,它将取代 ToyCarValidator 具体类。您会注意到,validate 方法会返回一个对象,让我们也来创建这个对象:

    codebase/symfony/src/Model/ValidationModel.php
    <?php
    
    namespace App\Model;
    
    class ValidationModel
    {
        /**
        * @var bool
        */
        private $valid = false;
    
        /**
        * @var array
        */
        private $report = [];
    }

    创建类后,生成属性的访问器和修改器。

    我们现在可以返回一个包含字段名称和该字段名称的验证结果的数组,而不是简单地在验证程序中返回 truefalse。让我们继续编码。

  4. 使用以下内容创建以下测试类:

    codebase/symfony/tests/Unit/Validator/ToyCarValidatorTest.php
    <?php
    
    namespace App\Tests\Unit\Validator;
    
    use PHPUnit\Framework\TestCase;
    use App\Validator\YearValidator;
    
    class YearValidatorTest extends TestCase
    {
        /**
        * @param $data
        * @param $expected
        * @dataProvider provideYear
        */
        public function testCanValidateYear(int $year, bool $expected): void
        {
            $validator = new YearValidator();
            $isValid = $validator->validate($year);
    
            $this->assertEquals($expected, $isValid);
        }
    
        /**
        * @return array
        */
        public function provideYear(): array
        {
            return [
                [1, false],
                [2005, true],
                [1955, true],
                [312, false],
            ];
        }
    }
  5. 如果运行此测试,您将看到四个失败,因为我们在 ProvideYear 数据提供程序中有四组值。通过运行以下命令来运行测试:

    /var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter YearValidatorTest --debug

如果测试失败,那很好。我们继续看解决方案代码:

  1. 使用以下内容创建以下解决方案类:

    codebase/symfony/src/Validator/YearValidator.php
    <?php
    
    namespace App\Validator;
    
    class YearValidator implements ValidatorInterface
    {
        /**
        * @param $input
        * @return bool
        */
        public function validate($input): bool
        {
            if (preg_match(“/^(\d{4})$/”, $input, $matches)) {
                return true;
            }
    
            return false;
        }
    }

    现在,我们有一个简单的验证类来检查我们的汽车是否可以接受一年。如果我们想在此处添加更多逻辑,例如检查可接受的最小和最大值,我们可以将所有逻辑放在这里。

  2. 再次运行以下命令,查看测试是否通过:

    /var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter YearValidatorTest --debug

    您应该看到以下结果:

    image 2023 10 24 11 31 17 087
    Figure 1. Figure 8.7 – Simple date validation test

    现在我们已经通过了年份验证器的非常简单的测试,接下来,让我们继续进行名称验证器:

  3. 使用以下内容创建以下测试类:

    codebase/symfony/tests/Unit/Validator/NameValidatorTest.php
    <?php
    
    namespace App\Tests\Unit\Validator;
    
    use App\Validator\NameValidator;
    use PHPUnit\Framework\TestCase;
    
    class NameValidatorTest extends TestCase
    {
        /**
        * @param $data
        * @param $expected
        * @dataProvider provideNames
        */
        public function testCanValidateName(string $name, bool $expected): void
        {
            $validator = new NameValidator();
            $isValid = $validator->validate($name);
    
            $this->assertEquals($expected, $isValid);
        }
    
        /**
        * @return array
        */
        public function provideNames(): array
        {
            return [
                ['', false],
                ['$50', false],
                ['Mercedes', true],
                ['RedBull', true],
                ['Williams', true],
            ];
        }
    }
  4. 与年份验证器一样,如果您现在运行此测试,您将遇到多个错误,但我们必须确保它确实失败或出错。运行以下命令:

    /var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter NameValidatorTest
  5. 运行该命令后,您应该看到五个错误。没关系,现在让我们为其构建解决方案代码。创建包含以下内容的以下类:

    codebase/symfony/src/Validator/NameValidator.php
    <?php
    
    namespace App\Validator;
    
    class NameValidator implements ValidatorInterface
    {
        public function validate($input): bool
        {
            if (preg_match(“/^([a-zA-Z’ ]+)$/”, $input)) {
                return true;
            }
    
            return false;
        }
    }
  6. 现在,我们有一个简单的逻辑来验证名称。让我们再次运行名称验证器测试,看看它是否通过。再次运行以下命令:

    /var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter NameValidatorTest

    现在你应该看到五个测试通过了。

    让我们总结一下到目前为止我们添加的内容。我们创建了两个新的验证类,根据单元测试,这两个类都能按预期运行,但这比我们创建的第一个解决方案好在哪里?这与 OCP 有什么关系?首先,我们需要将事情串联起来,并通过更大的 ToyCarValidatorTest

  7. 让我们用以下内容重构 ToyCarValidator 类:

    codebase/symfony/src/Validator/ToyCarValidator.php
    <?php
    
    namespace App\Validator;
    
    use App\Model\ToyCar;
    use App\Model\ValidationModel as ValidationResult;
    
    class ToyCarValidator implements ToyCarValidatorInterface
    {
        /**
        * @var array
        */
        private $validators = [];
    
        public function __construct()
        {
            $this->setValidators([
                ‘year’ => new YearValidator(),
                ‘name’ => new NameValidator(),
            ]);
        }
    
        /**
        * @param ToyCar $toyCar
        * @return ValidationResult
        */
        public function validate(ToyCar $toyCar) ValidationResult
        {
            $result = new ValidationResult();
            $allValid = true;
    
            foreach ($this->getValidators() as $key => $validator) {
    
                $accessor = 'get' . ucfirst(strtolower($key));
                $value = $toyCar->$accessor();
                $isValid = false;
    
                try {
                    $isValid = $validator->validate($value);
                    $results[$key]['message'] = '';
                } catch (ToyCarValidationException $ex) {
                    $results[$key][‘message’] = $ex->getMessage();
                } finally {
                    $results[$key][‘is_valid’] = $isValid;
                }
    
                if (!$isValid) {
                    $allValid = false;
                }
            }
    
            $result->setValid($allValid);
            $result->setReport($results);
    
            return $result;
        }
    }

    然后,为 $validators 属性生成访问器和修改器。

  8. 你会注意到,在构造函数中,我们实例化了两个验证器类,在 validate 方法中,我们使用了这些验证器类。每个验证器类都将有自己的自定义逻辑来运行验证方法。现在,用以下内容重构以下测试类:

    codebase/symfony/tests/Unit/Validator/ToyCarValidatorTest.php
    <?php
    
    namespace App\Tests\Unit\Validator;
    
    use App\Model\CarManufacturer;
    use App\Model\ToyCar;
    use App\Model\ToyColor;
    use App\Validator\ToyCarValidator;
    use PHPUnit\Framework\TestCase;
    
    class ToyCarValidatorTest extends TestCase
    {
        /**
        * @param ToyCar $toyCar
        * @param array $expected
        * @dataProvider provideToyCarModel
        */
        public function testCanValidate(ToyCar $toyCar,
        array $expected): void
        {
            $validator = new ToyCarValidator();
            $result = $validator->validate($toyCar);
    
            $this->assertEquals($expected['is_valid'],$result->isValid());
            $this->assertEquals($expected['name'],$result->getReport()['name']['is_valid']);
            $this->assertEquals($expected['year'],$result->getReport()['year']['is_valid']);
        }
    
        public function provideToyCarModel(): array
        {
            // Toy Car Color
            $toyColor = new ToyColor();
            $toyColor->setName('White');
    
            // Car Manufacturer
            $carManufacturer = new CarManufacturer();
            $carManufacturer->setName('Williams');
    
            // Toy Car
            $toyCarModel = new ToyCar();
            $toyCarModel->setName(''); // Should fail.
            $toyCarModel->setColour($toyColor);
            $toyCarModel->setManufacturer($carManufacturer);
            $toyCarModel->setYear(2004);
    
            return [
                [$toyCarModel, ['is_valid' => false, 'name' => false, 'year' => true]],
            ];
        }
    }
  9. 现在,在这个测试中,我们要检查整个玩具车模型对象的有效性,以及检查玩具车模型的哪个特定字段通过或未通过验证。让我们看看测试是否通过。运行以下命令:

    /var/www/html/symfony# ./runDebug.sh --testsuite=Unit --filter ToyCarValidatorTest

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

image 2023 10 24 11 47 23 519
Figure 2. Figure 8.8 – Passing toy car validation test

您会发现我们通过了三个断言。看起来,我们开始让测试承担更多责任了。还是每个测试做一个断言比较好,这样我们就不会有一个神一样的测试类了!现在,我们继续。

现在,我们通过重构实现了什么?首先,我们不再需要在 ToyCarValidatorTest 类中检查玩具名称有效性的验证逻辑。其次,我们现在可以检查年份的有效性。如果我们想改进日期和名称验证逻辑,就不必在 ToyCarValidator 主类中进行,但如果我们想添加更多验证器类呢?比如 ToyColorValidator 类?我们仍然可以做到这一点,甚至无需触及主类!我们将重构 ToyCarValidator,并在本章稍后的 "TDD 与依赖反转原则" 部分讨论如何重构。

但是,如果我们想改变我们创建的 ToyCarValidator.php 类的整个行为并完全改变其逻辑呢?我们只需用 ToyCarValidatorInterface 接口的另一种具体实现替换整个 ToyCarValidator.php 类即可!

接下来,我们来谈谈 里氏替换原则(LSP)