Skip to content

Commit 95a1e83

Browse files
Abseil Teamcopybara-github
authored andcommitted
Add a @parameterized.named_product which combines both named_parameters and product together.
Named Parameterized Test Cases of a Cartesian Product ====================================================== Both `named_parameters` and `product` have useful features. However, when using both, it can be difficult to ensure that generated test cases retain useful names. Here, we combine both approaches to create parameterized tests with both generated permutations and human-readable names. Example: ```python @parameterized.named_product( [ dict( testcase_name='five_mod_three_is_2', num=5, modulo=3, expected=2, ), dict( testcase_name='seven_mod_four_is_3', num=7, modulo=4, expected=3, ), ], [ dict(testcase_name='int', dtype=int), dict(testcase_name='float', dtype=float), ] ) def testModuloResult(self, num, modulo, expected, dtype): self.assertEqual(expected, dtype(num) % modulo) This would generate the test cases: testModuloResult_five_mod_three_is_2_int testModuloResult_five_mod_three_is_2_float testModuloResult_seven_mod_four_is_3_int testModuloResult_seven_mod_four_is_3_float ``` PiperOrigin-RevId: 824463104
1 parent 6c275f3 commit 95a1e83

File tree

3 files changed

+246
-0
lines changed

3 files changed

+246
-0
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com).
1212

1313
### Fixed
1414

15+
## 2.3.2 (2025-10-31)
16+
17+
## Unreleased
18+
19+
### Added
20+
21+
* (testing) Added a `@parameterized.named_product` which combines both
22+
`named_parameters` and `product` together.
23+
24+
### Changed
25+
26+
### Fixed
27+
1528
## 2.3.1 (2025-07-03)
1629

1730
### Changed

absl/testing/parameterized.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,50 @@ def testModuloResult(self, num, modulo, expected, dtype):
191191
data (supplied as kwarg dicts) and for each of the two data types (supplied as
192192
a named parameter). Multiple keyword argument dicts may be supplied if required.
193193
194+
195+
Named Parameterized Test Cases of a Cartesian Product
196+
======================================================
197+
198+
Both named_parameters and product have useful features as described above.
199+
However, when using both, it can be difficult to ensure that generated test
200+
cases retain useful names.
201+
202+
Here, we combine both approaches to create parameterized tests with both
203+
generated permutations and human-readable names.
204+
205+
Example:
206+
207+
@parameterized.named_product(
208+
[
209+
dict(
210+
testcase_name='five_mod_three_is_2',
211+
num=5,
212+
modulo=3,
213+
expected=2,
214+
),
215+
dict(
216+
testcase_name='seven_mod_four_is_3',
217+
num=7,
218+
modulo=4,
219+
expected=3,
220+
),
221+
],
222+
[
223+
dict(testcase_name='int', dtype=int),
224+
dict(testcase_name='float', dtype=float),
225+
]
226+
)
227+
def testModuloResult(self, num, modulo, expected, dtype):
228+
self.assertEqual(expected, dtype(num) % modulo)
229+
230+
This would generate the test cases:
231+
232+
testModuloResult_five_mod_three_is_2_int
233+
testModuloResult_five_mod_three_is_2_float
234+
testModuloResult_seven_mod_four_is_3_int
235+
testModuloResult_seven_mod_four_is_3_float
236+
237+
194238
Async Support
195239
=============
196240
@@ -486,6 +530,81 @@ def named_parameters(*testcases):
486530
return _parameter_decorator(_NAMED, testcases)
487531

488532

533+
def named_product(*kwargs_seqs):
534+
"""Decorates a test method to run it over the cartesian product of parameters.
535+
536+
See the module docstring for a usage example. The test will be run for every
537+
possible combination of the parameters.
538+
539+
For example,
540+
541+
```python
542+
named_product(
543+
[
544+
dict(testcase_name="foo", x=1, y=2),
545+
dict(testcase_name="bar", x=3, y=4),
546+
],
547+
[
548+
dict(testcase_name="baz", z=5),
549+
dict(testcase_name="qux", z=6),
550+
],
551+
)
552+
```
553+
554+
is equivalent to:
555+
556+
```python
557+
named_parameters(
558+
[
559+
dict(testcase_name="foo_baz", x=1, y=2, z=5),
560+
dict(testcase_name="foo_qux", x=1, y=2, z=6),
561+
dict(testcase_name="bar_baz", x=3, y=4, z=5),
562+
dict(testcase_name="bar_qux", x=3, y=4, z=6),
563+
],
564+
)
565+
```
566+
567+
Args:
568+
*kwargs_seqs: Each positional parameter is a sequence of keyword arg dicts;
569+
every test case generated will include exactly one kwargs dict from each
570+
positional parameter; these will then be merged to form an overall list of
571+
arguments for the test case.
572+
573+
Returns:
574+
A test generator to be handled by TestGeneratorMetaclass.
575+
"""
576+
if len(kwargs_seqs) <= 1:
577+
raise ValueError('Need at least 2 arguments for cross product.')
578+
if not all(kwargs_seq for kwargs_seq in kwargs_seqs):
579+
raise ValueError('All arguments for cross product must be non-empty.')
580+
# Ensure all kwargs_seq have a `testcase_name` key.
581+
for kwargs_seq in kwargs_seqs:
582+
for kwargs in kwargs_seq:
583+
if _NAMED_DICT_KEY not in kwargs:
584+
raise ValueError(
585+
'All arguments for cross product must have a `testcase_name` key.'
586+
)
587+
588+
def _cross_join():
589+
"""Yields a single kwargs dict for each dimension of the cross join."""
590+
for kwargs_seq in itertools.product(*kwargs_seqs):
591+
joined_kwargs = {}
592+
for v in kwargs_seq:
593+
duplicate_keys = joined_kwargs.keys() & v.keys() - {_NAMED_DICT_KEY}
594+
if duplicate_keys:
595+
raise ValueError(
596+
f'Duplicate keys in {v[_NAMED_DICT_KEY]}: {duplicate_keys}'
597+
)
598+
joined_kwargs.update(v)
599+
joined_kwargs[_NAMED_DICT_KEY] = '_'.join(
600+
kwargs[_NAMED_DICT_KEY] for kwargs in kwargs_seq
601+
)
602+
yield joined_kwargs
603+
604+
testcases = tuple(_cross_join())
605+
return _parameter_decorator(_NAMED, testcases)
606+
607+
489608
def product(*kwargs_seqs, **testgrid):
490609
"""A decorator for running tests over cartesian product of parameters values.
491610

absl/testing/tests/parameterized_test.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,24 @@ def test_mixed_something(self, case):
229229
def test_without_parameters(self):
230230
pass
231231

232+
class NamedProductTests(parameterized.TestCase):
233+
"""Used by test_named_product_creates_expected_tests."""
234+
235+
@parameterized.named_product(
236+
[
237+
dict(testcase_name='a_1', x=[1, 2], y=[3, 4]),
238+
dict(testcase_name='a_2', x=[5, 6], y=[7, 8]),
239+
],
240+
[
241+
dict(testcase_name='b_1', z=['foo', 'bar'], w=['baz', 'qux']),
242+
dict(
243+
testcase_name='b_2', z=['quux', 'quuz'], w=['corge', 'grault']
244+
),
245+
],
246+
)
247+
def test_named_product(self, x, y, z, w):
248+
pass
249+
232250
class ChainedTests(parameterized.TestCase):
233251

234252
@dict_decorator('cone', 'waffle')
@@ -825,6 +843,102 @@ class _(parameterized.TestCase):
825843
def test_something(self, unused_obj):
826844
pass
827845

846+
def test_named_product_empty_fails(self):
847+
with self.assertRaises(ValueError):
848+
849+
class _(parameterized.TestCase):
850+
851+
@parameterized.named_product()
852+
def test_something(self):
853+
pass
854+
855+
def test_named_product_one_argument_fails(self):
856+
with self.assertRaises(ValueError):
857+
858+
class _(parameterized.TestCase):
859+
860+
@parameterized.named_product(
861+
[
862+
{'testcase_name': 'foo', 'x': 1, 'y': 2},
863+
{'testcase_name': 'bar', 'x': 3, 'y': 4},
864+
],
865+
)
866+
def test_something(self, x, y):
867+
pass
868+
869+
def test_named_product_duplicate_keys_fails(self):
870+
with self.assertRaises(ValueError):
871+
872+
class _(parameterized.TestCase):
873+
874+
@parameterized.named_product(
875+
[
876+
{'testcase_name': 'foo', 'x': 1, 'y': 2},
877+
{'testcase_name': 'bar', 'x': 3, 'y': 4},
878+
],
879+
[
880+
{'testcase_name': 'baz', 'x': 5, 'z': 7},
881+
{'testcase_name': 'qux', 'x': 6, 'z': 8},
882+
],
883+
)
884+
def test_something(self, x, y, z):
885+
pass
886+
887+
def test_named_product_no_testcase_name_fails(self):
888+
with self.assertRaises(ValueError):
889+
890+
class _(parameterized.TestCase):
891+
892+
@parameterized.named_product(
893+
[
894+
{'x': 1, 'y': 2},
895+
{'testcase_name': 'bar', 'x': 3, 'y': 4},
896+
],
897+
[
898+
{'testcase_name': 'baz', 'x': 5, 'z': 7},
899+
{'testcase_name': 'qux', 'z': 8},
900+
],
901+
)
902+
def test_something(self, x, y, z):
903+
pass
904+
905+
def test_named_product_no_testcase_name_in_second_argument_fails(self):
906+
with self.assertRaises(ValueError):
907+
908+
class _(parameterized.TestCase):
909+
910+
@parameterized.named_product(
911+
[
912+
{'testcase_name': 'foo', 'x': 1, 'y': 2},
913+
{'testcase_name': 'bar', 'x': 3, 'y': 4},
914+
],
915+
[
916+
{'x': 5, 'z': 7},
917+
{'testcase_name': 'qux', 'z': 8},
918+
],
919+
)
920+
def test_something(self, x, y, z):
921+
pass
922+
923+
def test_named_product_creates_expected_tests(self):
924+
ts = unittest.defaultTestLoader.loadTestsFromTestCase(
925+
self.NamedProductTests
926+
)
927+
test = next(t for t in ts)
928+
self.assertContainsSubset(
929+
[
930+
'test_named_product_a_1_b_1',
931+
'test_named_product_a_1_b_2',
932+
'test_named_product_a_2_b_1',
933+
'test_named_product_a_2_b_2',
934+
],
935+
dir(test),
936+
)
937+
res = unittest.TestResult()
938+
ts.run(res)
939+
self.assertEqual(4, res.testsRun)
940+
self.assertTrue(res.wasSuccessful())
941+
828942
def test_parameterized_test_iter_has_testcases_property(self):
829943
@parameterized.parameters(1, 2, 3, 4, 5, 6)
830944
def test_something(unused_self, unused_obj): # pylint: disable=invalid-name

0 commit comments

Comments
 (0)