Skip to content

Commit cb2f596

Browse files
committed
Add documentation: unit tests of custom validator
1 parent 6c67c6e commit cb2f596

File tree

2 files changed

+245
-1
lines changed

2 files changed

+245
-1
lines changed

form/unit_testing.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ method is only set to ``false`` if a data transformer throws an exception::
9797

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

102102
Next, verify the submission and mapping of the form. The test below
103103
checks if all the fields are correctly specified::

validation/unit_testing.rst

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
.. index::
2+
single: Validator; Custom validator testing
3+
4+
How to Unit Test your custom constraint
5+
=======================================
6+
7+
.. caution::
8+
9+
This article is intended for developers who create
10+
:doc:`custom constraint </validation/custom_constraint>`. If you are using
11+
the :doc:`built-in Symfony constraints </validation>` or the constraints
12+
provided by third-party bundles, you don't need to unit test them.
13+
14+
The Validator component consists of 2 core objects while dealing with a custom validator.
15+
- a constraint (extending:class:`Symfony\\Component\\Validator\\Constraint`)
16+
- and the validator (extending:class:`Symfony\\Component\\Validator\\ConstraintValidator`).
17+
18+
.. note::
19+
20+
Depending on the way you installed your Symfony or Symfony Validator component
21+
the tests may not be downloaded. Use the ``--prefer-source`` option with
22+
Composer if this is the case.
23+
24+
The case of example
25+
-------------------
26+
27+
The classic Order - Products example::
28+
29+
<?php
30+
31+
class Product
32+
{
33+
/** @var string */
34+
private $type;
35+
36+
public function __construct(string $type)
37+
{
38+
$this->type = $type;
39+
}
40+
41+
/**
42+
* @return string
43+
*/
44+
public function getType(): string
45+
{
46+
return $this->type;
47+
}
48+
}
49+
50+
class Order
51+
{
52+
/** @var Product[] */
53+
private $products;
54+
55+
public function __construct()
56+
{
57+
$this->products = [];
58+
}
59+
60+
public function addProduct(Product $product): void
61+
{
62+
$this->products[] = $product;
63+
}
64+
65+
public function getProducts(): array
66+
{
67+
return $this->products;
68+
}
69+
}
70+
71+
Let's imagine we want a constraint to check there is less product with same type than a specific number.
72+
73+
The Basics
74+
----------
75+
76+
The constraint class
77+
********************
78+
79+
80+
Basically your job here is to test available options of your constraint.
81+
82+
Our constraint class await a max number, so let's define it.
83+
84+
The constraint class could look like this::
85+
86+
class LimitProductTypePerOrder extends \Symfony\Component\Validator\Constraint
87+
{
88+
public $message = 'There is {{ count }} products with the type "{{ type }}", but the limit is {{ max }}.';
89+
public $max;
90+
91+
public function __construct(array $options)
92+
{
93+
parent::__construct($options);
94+
if (!is_int($this->max)) {
95+
throw new InvalidArgumentException('The max value must be an integer');
96+
}
97+
98+
if ($this->max <= 0) {
99+
throw new InvalidArgumentException('The max value must be strictly positive');
100+
}
101+
}
102+
}
103+
104+
Here you want to verify that the given options to your constraint are correct.
105+
It's mainly a variable type checking, but it could depends of your application too:
106+
::
107+
108+
class LimitProductTypePerOrderTest extends \PHPUnit\Framework\TestCase
109+
{
110+
public function testItAllowMaxInt()
111+
{
112+
$constraint = new LimitProductTypePerOrder(['max' => 1]);
113+
$this->assertEquals(1, $constraint->max);
114+
}
115+
116+
public function testItThrowIfMaxIsNotAnInt()
117+
{
118+
$this->expectException(InvalidArgumentException::class);
119+
$this->expectExceptionMessage('The max value must be an integer');
120+
new LimitProductTypePerOrder(['max' => 'abcde']);
121+
}
122+
123+
public function testItThrowIfMaxIsNegative()
124+
{
125+
$this->expectException(InvalidArgumentException::class);
126+
$this->expectExceptionMessage('The max value must be positive');
127+
new LimitProductTypePerOrder(['max' => -2]);
128+
}
129+
}
130+
131+
132+
Here you want to unit test your custom validator logic. Symfony provide a class ``ConstraintValidatorTestCase`` used internally for testing constraints available by default.
133+
This class avoid code duplication and simplify unit testing of your custom constraint.
134+
135+
It is possible to access to the validator with the ``$this->validator`` property from parent class.
136+
137+
You can use few methods to assert violations during your test
138+
139+
- ``assertNoViolation()``
140+
- ``buildViolation($constraint->message)->assertRaised();`` // Don't forget the ->assertRaised(); otherwise your tests will fail.
141+
142+
143+
The Validator class
144+
************************
145+
In this class you will write your domain validation logic:
146+
::
147+
148+
class LimitProductTypePerOrderValidator extends \Symfony\Component\Validator\ConstraintValidator
149+
{
150+
public function validate($order, \Symfony\Component\Validator\Constraint $constraint)
151+
{
152+
if (!$constraint instanceof LimitProductTypePerOrder) return;
153+
if (!$order instanceof Order) return;
154+
155+
$countPerType = [];
156+
foreach ($order->getProducts() as $product) {
157+
if (!isset($countPerType[$product->getType()])) $countPerType[$product->getType()] = 0;
158+
159+
$countPerType[$product->getType()] = $countPerType[$product->getType()] +=1;
160+
}
161+
162+
$errors = array_filter($countPerType, function($count) use($constraint) {
163+
return $count > $constraint->max;
164+
});
165+
166+
foreach ($errors as $productType => $count) {
167+
$this->context->buildViolation($constraint->message)
168+
->setParameter('{{ max }}', $constraint->max)
169+
->setParameter('{{ count }}', $count)
170+
->setParameter('{{ type }}', $productType)
171+
->addViolation();
172+
}
173+
}
174+
}
175+
176+
The Validator test class
177+
************************
178+
In this class you will test your custom validator domain logic:
179+
::
180+
181+
182+
class LimitProductTypePerOrderValidatorTest extends ConstraintValidatorTestCase
183+
{
184+
/** @var Order|\Prophecy\Prophecy\ObjectProphecy */
185+
private $order;
186+
187+
protected function setUp(): void
188+
{
189+
parent::setUp(); // This is important
190+
$this->order = $this->prophesize(Order::class);
191+
}
192+
193+
protected function createValidator()
194+
{
195+
return new LimitProductTypePerOrderValidator();
196+
}
197+
198+
public function testItRunOnlyTheGoodConstraintType()
199+
{
200+
$randomConstraint = new \Symfony\Component\Validator\Constraint();
201+
$this->validator->validate($this->order->reveal(), $randomConstraint);
202+
203+
$this->order->getProducts()->shouldNotBeCalled();
204+
$this->assertNoViolation();
205+
}
206+
207+
public function testAddViolationIfMoreProductsWithSameTypeThanMax()
208+
{
209+
$product1 = $this->productMock('my_type');
210+
$product2 = $this->productMock('my_type');
211+
$this->order->getProducts()->willReturn([$product1, $product2]);
212+
213+
$constraint = new LimitProductTypePerOrder(['max' => 1]);
214+
$this->validator->validate($this->order->reveal(), $constraint);
215+
216+
$this->buildViolation($constraint->message)
217+
->setParameter('{{ max }}', 1)
218+
->setParameter('{{ count }}', 2)
219+
->setParameter('{{ type }}', 'my_type')
220+
->assertRaised();
221+
}
222+
223+
public function testItDontAddViolation()
224+
{
225+
$product1 = $this->productMock('symfony');
226+
$product2 = $this->productMock('is');
227+
$product3 = $this->productMock('awesome');
228+
$product4 = $this->productMock('!');
229+
$this->order->getProducts()->willReturn([$product1, $product2, $product3, $product4]);
230+
231+
$constraint = new LimitProductTypePerOrder(['max' => 1]);
232+
$this->validator->validate($this->order->reveal(), $constraint);
233+
234+
$this->assertNoViolation();
235+
}
236+
237+
private function productMock(string $type)
238+
{
239+
$productMock = $this->prophesize(Product::class);
240+
$productMock->getType()->willReturn($type);
241+
return $productMock->reveal();
242+
}
243+
}
244+

0 commit comments

Comments
 (0)