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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f56d566..b3e1fe3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,19 @@
 
 ### 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 @@
 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 @@
   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_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 @@
         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