Skip to content

Unit tests of custom validator #12676

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion form/unit_testing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ method is only set to ``false`` if a data transformer throws an exception::

Don't test the validation: it is applied by a listener that is not
active in the test case and it relies on validation configuration.
Instead, unit test your custom constraints directly.
Instead, :ref:`unit test your custom constraints directly<testing-data-providers>`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Instead, :ref:`unit test your custom constraints directly<testing-data-providers>`.
Instead, :ref:`unit test your custom constraints directly <testing-data-providers>`.


Next, verify the submission and mapping of the form. The test below
checks if all the fields are correctly specified::
Expand Down
244 changes: 244 additions & 0 deletions validation/unit_testing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
.. index::
single: Validator; Custom validator testing

How to Unit Test your custom constraint
=======================================

.. caution::

This article is intended for developers who create
:doc:`custom constraint </validation/custom_constraint>`. If you are using
the :doc:`built-in Symfony constraints </validation>` or the constraints
provided by third-party bundles, you don't need to unit test them.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove this note. In other places where we talk about tests we also do not explain that you do not need to write tests for Symfony core functions.


The Validator component consists of 2 core objects while dealing with a custom validator.
- a constraint (extending:class:`Symfony\\Component\\Validator\\Constraint`)
- and the validator (extending:class:`Symfony\\Component\\Validator\\ConstraintValidator`).

.. note::

Depending on the way you installed your Symfony or Symfony Validator component
the tests may not be downloaded. Use the ``--prefer-source`` option with
Composer if this is the case.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove this note. You won't need the Symfony tests to write tests for your own constraints.


The case of example
-------------------

The classic Order - Products example::

<?php

class Product
{
/** @var string */
private $type;

public function __construct(string $type)
{
$this->type = $type;
}

/**
* @return string
*/
public function getType(): string
{
return $this->type;
}
}

class Order
{
/** @var Product[] */
private $products;

public function __construct()
{
$this->products = [];
}

public function addProduct(Product $product): void
{
$this->products[] = $product;
}

public function getProducts(): array
{
return $this->products;
}
}

Let's imagine we want a constraint to check there is less product with same type than a specific number.

The Basics
----------

The constraint class
********************


Basically your job here is to test available options of your constraint.

Our constraint class await a max number, so let's define it.

The constraint class could look like this::

class LimitProductTypePerOrder extends \Symfony\Component\Validator\Constraint
{
public $message = 'There is {{ count }} products with the type "{{ type }}", but the limit is {{ max }}.';
public $max;

public function __construct(array $options)
{
parent::__construct($options);
if (!is_int($this->max)) {
throw new InvalidArgumentException('The max value must be an integer');
}

if ($this->max <= 0) {
throw new InvalidArgumentException('The max value must be strictly positive');
}
}
}

Here you want to verify that the given options to your constraint are correct.
It's mainly a variable type checking, but it could depends of your application too:
::

class LimitProductTypePerOrderTest extends \PHPUnit\Framework\TestCase
{
public function testItAllowMaxInt()
{
$constraint = new LimitProductTypePerOrder(['max' => 1]);
$this->assertEquals(1, $constraint->max);
}

public function testItThrowIfMaxIsNotAnInt()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The max value must be an integer');
new LimitProductTypePerOrder(['max' => 'abcde']);
}

public function testItThrowIfMaxIsNegative()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The max value must be positive');
new LimitProductTypePerOrder(['max' => -2]);
}
}


Here you want to unit test your custom validator logic. Symfony provide a class ``ConstraintValidatorTestCase`` used internally for testing constraints available by default.
This class avoid code duplication and simplify unit testing of your custom constraint.

It is possible to access to the validator with the ``$this->validator`` property from parent class.

You can use few methods to assert violations during your test

- ``assertNoViolation()``
- ``buildViolation($constraint->message)->assertRaised();`` // Don't forget the ->assertRaised(); otherwise your tests will fail.


The Validator class
************************
In this class you will write your domain validation logic:
::

class LimitProductTypePerOrderValidator extends \Symfony\Component\Validator\ConstraintValidator
{
public function validate($order, \Symfony\Component\Validator\Constraint $constraint)
{
if (!$constraint instanceof LimitProductTypePerOrder) return;
if (!$order instanceof Order) return;

$countPerType = [];
foreach ($order->getProducts() as $product) {
if (!isset($countPerType[$product->getType()])) $countPerType[$product->getType()] = 0;

$countPerType[$product->getType()] = $countPerType[$product->getType()] +=1;
}

$errors = array_filter($countPerType, function($count) use($constraint) {
return $count > $constraint->max;
});

foreach ($errors as $productType => $count) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ max }}', $constraint->max)
->setParameter('{{ count }}', $count)
->setParameter('{{ type }}', $productType)
->addViolation();
}
}
}

The Validator test class
************************
In this class you will test your custom validator domain logic:
::


class LimitProductTypePerOrderValidatorTest extends ConstraintValidatorTestCase
{
/** @var Order|\Prophecy\Prophecy\ObjectProphecy */
private $order;

protected function setUp(): void
{
parent::setUp(); // This is important
$this->order = $this->prophesize(Order::class);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need another one dependency?

}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tearDown is missing btw.


protected function createValidator()
{
return new LimitProductTypePerOrderValidator();
}

public function testItRunOnlyTheGoodConstraintType()
{
$randomConstraint = new \Symfony\Component\Validator\Constraint();
$this->validator->validate($this->order->reveal(), $randomConstraint);

$this->order->getProducts()->shouldNotBeCalled();
$this->assertNoViolation();
}

public function testAddViolationIfMoreProductsWithSameTypeThanMax()
{
$product1 = $this->productMock('my_type');
$product2 = $this->productMock('my_type');
$this->order->getProducts()->willReturn([$product1, $product2]);

$constraint = new LimitProductTypePerOrder(['max' => 1]);
$this->validator->validate($this->order->reveal(), $constraint);

$this->buildViolation($constraint->message)
->setParameter('{{ max }}', 1)
->setParameter('{{ count }}', 2)
->setParameter('{{ type }}', 'my_type')
->assertRaised();
}

public function testItDontAddViolation()
{
$product1 = $this->productMock('symfony');
$product2 = $this->productMock('is');
$product3 = $this->productMock('awesome');
$product4 = $this->productMock('!');
$this->order->getProducts()->willReturn([$product1, $product2, $product3, $product4]);

$constraint = new LimitProductTypePerOrder(['max' => 1]);
$this->validator->validate($this->order->reveal(), $constraint);

$this->assertNoViolation();
}

private function productMock(string $type)
{
$productMock = $this->prophesize(Product::class);
$productMock->getType()->willReturn($type);
return $productMock->reveal();
}
}