Skip to content
Open
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com).

### Fixed

## 2.3.2 (2025-10-31)

## Unreleased

### Added

* (testing) Added a `@parameterized.named_product` which combines both
`named_parameters` and `product` together.

### Changed

### Fixed

## 2.3.1 (2025-07-03)

### Changed
Expand Down
119 changes: 119 additions & 0 deletions absl/testing/parameterized.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,50 @@ def testModuloResult(self, num, modulo, expected, dtype):
data (supplied as kwarg dicts) and for each of the two data types (supplied as
a named parameter). Multiple keyword argument dicts may be supplied if required.


Named Parameterized Test Cases of a Cartesian Product
======================================================

Both named_parameters and product have useful features as described above.
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:

@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


Async Support
=============

Expand Down Expand Up @@ -486,6 +530,81 @@ def named_parameters(*testcases):
return _parameter_decorator(_NAMED, testcases)


def named_product(*kwargs_seqs):
"""Decorates a test method to run it over the cartesian product of parameters.

See the module docstring for a usage example. The test will be run for every
possible combination of the parameters.

For example,

```python
named_product(
[
dict(testcase_name="foo", x=1, y=2),
dict(testcase_name="bar", x=3, y=4),
],
[
dict(testcase_name="baz", z=5),
dict(testcase_name="qux", z=6),
],
)
```

is equivalent to:

```python
named_parameters(
[
dict(testcase_name="foo_baz", x=1, y=2, z=5),
dict(testcase_name="foo_qux", x=1, y=2, z=6),
dict(testcase_name="bar_baz", x=3, y=4, z=5),
dict(testcase_name="bar_qux", x=3, y=4, z=6),
],
)
```

Args:
*kwargs_seqs: Each positional parameter is a sequence of keyword arg dicts;
every test case generated will include exactly one kwargs dict from each
positional parameter; these will then be merged to form an overall list of
arguments for the test case.

Returns:
A test generator to be handled by TestGeneratorMetaclass.
"""
if len(kwargs_seqs) <= 1:
raise ValueError('Need at least 2 arguments for cross product.')
if not all(kwargs_seq for kwargs_seq in kwargs_seqs):
raise ValueError('All arguments for cross product must be non-empty.')
# Ensure all kwargs_seq have a `testcase_name` key.
for kwargs_seq in kwargs_seqs:
for kwargs in kwargs_seq:
if _NAMED_DICT_KEY not in kwargs:
raise ValueError(
'All arguments for cross product must have a `testcase_name` key.'
)

def _cross_join():
"""Yields a single kwargs dict for each dimension of the cross join."""
for kwargs_seq in itertools.product(*kwargs_seqs):
joined_kwargs = {}
for v in kwargs_seq:
duplicate_keys = joined_kwargs.keys() & v.keys() - {_NAMED_DICT_KEY}
if duplicate_keys:
raise ValueError(
f'Duplicate keys in {v[_NAMED_DICT_KEY]}: {duplicate_keys}'
)
joined_kwargs.update(v)
joined_kwargs[_NAMED_DICT_KEY] = '_'.join(
kwargs[_NAMED_DICT_KEY] for kwargs in kwargs_seq
)
yield joined_kwargs

testcases = tuple(_cross_join())
return _parameter_decorator(_NAMED, testcases)


def product(*kwargs_seqs, **testgrid):
"""A decorator for running tests over cartesian product of parameters values.

Expand Down
114 changes: 114 additions & 0 deletions absl/testing/tests/parameterized_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,24 @@ def test_mixed_something(self, case):
def test_without_parameters(self):
pass

class NamedProductTests(parameterized.TestCase):
"""Used by test_named_product_creates_expected_tests."""

@parameterized.named_product(
[
dict(testcase_name='a_1', x=[1, 2], y=[3, 4]),
dict(testcase_name='a_2', x=[5, 6], y=[7, 8]),
],
[
dict(testcase_name='b_1', z=['foo', 'bar'], w=['baz', 'qux']),
dict(
testcase_name='b_2', z=['quux', 'quuz'], w=['corge', 'grault']
),
],
)
def test_named_product(self, x, y, z, w):
pass

class ChainedTests(parameterized.TestCase):

@dict_decorator('cone', 'waffle')
Expand Down Expand Up @@ -825,6 +843,102 @@ class _(parameterized.TestCase):
def test_something(self, unused_obj):
pass

def test_named_product_empty_fails(self):
with self.assertRaises(ValueError):

class _(parameterized.TestCase):

@parameterized.named_product()
def test_something(self):
pass

def test_named_product_one_argument_fails(self):
with self.assertRaises(ValueError):

class _(parameterized.TestCase):

@parameterized.named_product(
[
{'testcase_name': 'foo', 'x': 1, 'y': 2},
{'testcase_name': 'bar', 'x': 3, 'y': 4},
],
)
def test_something(self, x, y):
pass

def test_named_product_duplicate_keys_fails(self):
with self.assertRaises(ValueError):

class _(parameterized.TestCase):

@parameterized.named_product(
[
{'testcase_name': 'foo', 'x': 1, 'y': 2},
{'testcase_name': 'bar', 'x': 3, 'y': 4},
],
[
{'testcase_name': 'baz', 'x': 5, 'z': 7},
{'testcase_name': 'qux', 'x': 6, 'z': 8},
],
)
def test_something(self, x, y, z):
pass

def test_named_product_no_testcase_name_fails(self):
with self.assertRaises(ValueError):

class _(parameterized.TestCase):

@parameterized.named_product(
[
{'x': 1, 'y': 2},
{'testcase_name': 'bar', 'x': 3, 'y': 4},
],
[
{'testcase_name': 'baz', 'x': 5, 'z': 7},
{'testcase_name': 'qux', 'z': 8},
],
)
def test_something(self, x, y, z):
pass

def test_named_product_no_testcase_name_in_second_argument_fails(self):
with self.assertRaises(ValueError):

class _(parameterized.TestCase):

@parameterized.named_product(
[
{'testcase_name': 'foo', 'x': 1, 'y': 2},
{'testcase_name': 'bar', 'x': 3, 'y': 4},
],
[
{'x': 5, 'z': 7},
{'testcase_name': 'qux', 'z': 8},
],
)
def test_something(self, x, y, z):
pass

def test_named_product_creates_expected_tests(self):
ts = unittest.defaultTestLoader.loadTestsFromTestCase(
self.NamedProductTests
)
test = next(t for t in ts)
self.assertContainsSubset(
[
'test_named_product_a_1_b_1',
'test_named_product_a_1_b_2',
'test_named_product_a_2_b_1',
'test_named_product_a_2_b_2',
],
dir(test),
)
res = unittest.TestResult()
ts.run(res)
self.assertEqual(4, res.testsRun)
self.assertTrue(res.wasSuccessful())

def test_parameterized_test_iter_has_testcases_property(self):
@parameterized.parameters(1, 2, 3, 4, 5, 6)
def test_something(unused_self, unused_obj): # pylint: disable=invalid-name
Expand Down
Loading