diff --git a/CHANGELOG.md b/CHANGELOG.md index f56d566..b3e1fe3 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 1ee9125..df6a748 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 4c02492..d236d1c 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