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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy