Symfony 2.1中如何进行自定义验证约束的单元测试,但不需要访问容器?

14

如何对ContainsItalianVatinValidator自定义验证器进行单元测试,但是不要访问容器validator服务(因此,创建存根对象)?

class ContainsItalianVatinValidator extends ConstraintValidator
{
    /**
     * @param mixed $value
     * @param \Symfony\Component\Validator\Constraint $constraint
     */
    public function validate($value, Constraint $constraint)
    {    
        if (!preg_match('/^[0-9]{11}$/', $value, $matches)) {
            $this->context->addViolation($constraint->message, array(
                '%string%' => $value
            ));
        }

        // Compute and check control code
        // ...
    }
}

在我的测试用例中,我知道我应该访问ConstraintViolationList,但是我不知道如何从验证器本身访问:

class ContainsItalianVatinValidatorTest extends \PHPUnit_Framework_TestCase
{
    public function testEmptyItalianVatin()
    {
        $emptyVatin = '';
        $validator  = new ContainsItalianVatinValidator();
        $constraint = new ContainsItalianVatinConstraint();

        // Do the validation
        $validator->validate($emptyVatin, $constraint);

        // How can a get a violation list and call ->count()?
        $violations = /* ... */;

        // Assert
        $this->assertGreaterThan(0, $violations->count());
    }
}

我会将验证逻辑提取到一个服务中,并为该服务编写单元测试。在验证器类内部,您可以使用该服务检查约束条件,并在验证失败时添加消息。这样,您的验证逻辑就不会与框架耦合,并且更加稳健,以应对未来的变化。 - fabwu
3个回答

24
当您查看验证器Symfony\Component\Validator\ConstraintValidator的父类时,您会发现有一个名为initialize的方法,该方法以Symfony\Component\Validator\ExecutionContext实例作为参数。
在创建验证器之后,您可以调用initialize方法并将模拟上下文传递给验证器。您不必测试addViolation方法是否正常工作,只需测试它是否被调用,并且是否使用正确的参数进行了调用。您可以使用PHPUnit的模拟功能来实现这一点。
...
$validator  = new ContainsItalianVatinValidator();
$context = $this->getMockBuilder('Symfony\Component\Validator\ExecutionContext')-> disableOriginalConstructor()->getMock();

$context->expects($this->once())
    ->method('addViolation')
    ->with($this->equalTo('[message]'), $this->equalTo(array('%string%', '')));

$validator->initialize($context);

$validator->validate($emptyVatin, $constraint);
...

在这段代码中,你需要用$constraint->message中存储的消息替换[message]。

实际上,这个问题更多地与PHPUnit有关,而不是Symfony。你可能会发现PHPUnit文档中的Test Doubles章节很有趣。


非常好的解释。唯一让我困惑的是,为什么在您看来计算违规是错误的,以及为什么我应该更倾向于依赖约束消息本身。无论如何,点赞。 - gremo
为什么你应该计算违规情况。至少在你的问题代码中只有一次对addViolation的调用。如果该方法被调用一次,那么就会向上下文添加一个违规情况(Symfony2的单元测试会测试这个)。 - Florian Eckerstorfer
如果代码中应该有更多对addViolation的调用,您可以添加多个$context->expects语句,每个语句覆盖一个不同的addViolation调用。不幸的是,PHPUnit只提供了两种方法来计算方法调用次数:onceany。然而,Mockery是一个与PHPUnit兼容的模拟库,可以计算模拟对象上方法调用的次数。 - Florian Eckerstorfer
谢谢,我会去检查调用addViolation的地方。 - gremo
1
从2016年开始,PHPUnit也支持$this->exactly($numberOfCalls)来计算模拟方法的调用次数。 - mblaettermann

14

已更新至Symfony 2.5+版本。为您的验证器中validate()方法可能添加的每个可能消息添加一个测试,包括会触发该消息的值。

<?php

namespace AcmeBundle\Tests\Validator\Constraints;

use AcmeBundle\Validator\Constraints\SomeConstraint;
use AcmeBundle\Validator\Constraints\SomeConstraintValidator;

/**
 * Exercises SomeConstraintValidator.
 */
class SomeConstraintValidatorTest extends \PHPUnit_Framework_TestCase
{
    /**
     * Configure a SomeConstraintValidator.
     *
     * @param string $expectedMessage The expected message on a validation violation, if any.
     *
     * @return AcmeBundle\Validator\Constraints\SomeConstraintValidator
     */
    public function configureValidator($expectedMessage = null)
    {
        // mock the violation builder
        $builder = $this->getMockBuilder('Symfony\Component\Validator\Violation\ConstraintViolationBuilder')
            ->disableOriginalConstructor()
            ->setMethods(array('addViolation'))
            ->getMock()
        ;

        // mock the validator context
        $context = $this->getMockBuilder('Symfony\Component\Validator\Context\ExecutionContext')
            ->disableOriginalConstructor()
            ->setMethods(array('buildViolation'))
            ->getMock()
        ;

        if ($expectedMessage) {
            $builder->expects($this->once())
                ->method('addViolation')
            ;

            $context->expects($this->once())
                ->method('buildViolation')
                ->with($this->equalTo($expectedMessage))
                ->will($this->returnValue($builder))
            ;
        }
        else {
            $context->expects($this->never())
                ->method('buildViolation')
            ;
        }

        // initialize the validator with the mocked context
        $validator = new SomeConstraintValidator();
        $validator->initialize($context);

        // return the SomeConstraintValidator
        return $validator;
    }

    /**
     * Verify a constraint message is triggered when value is invalid.
     */
    public function testValidateOnInvalid()
    {
        $constraint = new SomeConstraint();
        $validator = $this->configureValidator($constraint->someInvalidMessage);

        $validator->validate('someInvalidValue', $constraint);
    }

    /**
     * Verify no constraint message is triggered when value is valid.
     */
    public function testValidateOnValid()
    {
        $constraint = new SomeConstraint();
        $validator = $this->configureValidator();

        $validator->validate('someValidValue', $constraint);
    }
}

5

针对3.4版本进行更新:

我将上下文创建放入了一个特性中,这样我们就可以将其用于所有自定义约束。

class SomeConstraintValidatorTest extends TestCase
{
    use ConstraintValidationTrait;

    /** @var SomeConstraint */
    private $constraint;

    protected function setUp()
    {
        parent::setUp();

        $this->constraint = new SomeConstraint();
    }

    public function testValidateOnInvalid()
    {
        $this->assertConstraintRejects('someInvalidValue', $this->constraint);
    }

    public function testValidateOnValid()
    {
        $this->assertConstraintValidates('someValidValue', $this->constraint);
    }
}

特性:

<?php

use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContext;

trait ConstraintValidationTrait
{
    /**
     * The assertion is done in the mock.
     *
     * @param mixed $value
     */
    public function assertConstraintValidates($value, Constraint $constraint): void
    {
        $validator = $this->createValidator($constraint, true);
        $validator->validate($value, $constraint);
    }

    /**
     * The assertion is done in the mock.
     *
     * @param mixed $value
     */
    public function assertConstraintRejects($value, Constraint $constraint): void
    {
        $validator = $this->createValidator($constraint, false);
        $validator->validate($value, $constraint);
    }

    /** This is the phpunit mock method this trait requires */
    abstract protected function createMock($originalClassName): MockObject;

    private function createValidator(Constraint $constraint, bool $shouldValidate): ConstraintValidator
    {
        $context = $this->mockExecutionContext($shouldValidate);

        $validatorClass = get_class($constraint) . 'Validator';

        /** @var ConstraintValidator $validator */
        $validator = new $validatorClass();
        $validator->initialize($context);

        return $validator;
    }

    /**
     * Configure a SomeConstraintValidator.
     *
     * @param string|null $expectedMessage The expected message on a validation violation, if any.
     *
     * @return ExecutionContext
     */
    private function mockExecutionContext(bool $shouldValidate): ExecutionContext
    {
        /** @var ExecutionContext|MockObject $context */
        $context = $this->createMock(ExecutionContext::class);

        if ($shouldValidate) {
            $context->expects($this->never())->method('addViolation');
        } else {
            $context->expects($this->once())->method('addViolation');
        }

        return $context;
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接