From 95a1e83a1a2ff3021469f4bced7e700fb8c3d808 Mon Sep 17 00:00:00 2001 From: Abseil Team Date: Mon, 27 Oct 2025 04:46:53 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 13 +++ absl/testing/parameterized.py | 119 +++++++++++++++++++++++ absl/testing/tests/parameterized_test.py | 114 ++++++++++++++++++++++ 3 files changed, 246 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f56d566f..b3e1fe3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/absl/testing/parameterized.py b/absl/testing/parameterized.py index 1ee91258..df6a7488 100644 --- a/absl/testing/parameterized.py +++ b/absl/testing/parameterized.py @@ -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 ============= @@ -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. diff --git a/absl/testing/tests/parameterized_test.py b/absl/testing/tests/parameterized_test.py index 4c024927..d236d1c5 100644 --- a/absl/testing/tests/parameterized_test.py +++ b/absl/testing/tests/parameterized_test.py @@ -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') @@ -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