Parameterized Test: Run the Same Logic Across Multiple
How to write parameterized tests to verify the same logic across multiple inputs. Covers pytest parametrize, Jest test.each, JUnit ParameterizedTest, and data providers.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Parameterized tests (also called data-driven tests) run the same test logic across multiple input combinations. Instead of writing ten separate test functions that each test one input, you write one test function and provide a table of inputs and expected outputs. This reduces duplication, makes it easy to add new test cases, and clearly documents the relationship between inputs and expected behavior.
When to Use
- Testing pure functions with many input-output combinations
- Testing validators, parsers, formatters, and calculators
- Edge case testing — null, empty, boundary values, invalid inputs
- Testing the same behavior across different configurations or environments
- When adding a new test case should be a one-line change
When NOT to Use
- Tests with complex, unique setup per case — each case needs different mocks or fixtures
- Tests that verify interaction (mock calls) rather than output — parameterization adds confusion
- When each test case has different assertions — the logic isn’t truly shared
- For tests with side effects that require careful ordering — parameterized tests should be independent
Solution
pytest parametrize
# Python — pytest.mark.parametrize
import pytest
def add(a, b):
return a + b
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(-1, -1, -2),
(100, 200, 300),
(0.1, 0.2, 0.3),
])
def test_add(a, b, expected):
assert add(a, b) == pytest.approx(expected)
pytest parametrize with IDs
# Python — parametrize with test IDs for readable output
@pytest.mark.parametrize("input, expected", [
("hello", "HELLO"),
("world", "WORLD"),
("", ""),
(" spaces ", " SPACES "),
("CamelCase", "CAMELCASE"),
("123abc", "123ABC"),
], ids=[
"lowercase",
"word",
"empty",
"with_spaces",
"camelcase",
"alphanumeric",
])
def test_to_upper(input, expected):
assert input.upper() == expected
pytest parametrize with multiple parameters
# Python — multiple parameters and edge cases
@pytest.mark.parametrize("email, is_valid", [
("alice@example.com", True),
("bob@x.com", True),
("invalid", False),
("@example.com", False),
("alice@", False),
("alice@.com", False),
("", False),
(None, False),
], ids=[
"valid_standard",
"valid_short",
"no_at_symbol",
"no_local_part",
"no_domain",
"dot_domain",
"empty_string",
"none_input",
])
def test_validate_email(email, is_valid):
assert validate_email(email) == is_valid
Jest test.each (table format)
// JavaScript — Jest test.each with table syntax
describe('add', () => {
test.each`
a | b | expected
${1} | ${2} | ${3}
${0} | ${0} | ${0}
${-1} | ${1} | ${0}
${-1} | ${-1} | ${-2}
${100} | ${200} | ${300}
`('returns $expected for $a + $b', ({ a, b, expected }) => {
expect(add(a, b)).toBe(expected);
});
});
Jest test.each (array format)
// JavaScript — Jest test.each with array format
describe('validateEmail', () => {
test.each([
['alice@example.com', true],
['bob@x.com', true],
['invalid', false],
['@example.com', false],
['alice@', false],
['', false],
])('validateEmail("%s") returns %s', (email, expected) => {
expect(validateEmail(email)).toBe(expected);
});
});
Jest describe.each for grouped tests
// JavaScript — Jest describe.each for grouping
describe.each([
['Celsius', 'Fahrenheit', c => c * 9/5 + 32],
['Fahrenheit', 'Celsius', f => (f - 32) * 5/9],
])('%s to %s conversion', (from, to, convert) => {
test.each([
[0, 32],
[100, 212],
[-40, -40],
[37, 98.6],
])('%s° %s = %s° %s', (input, expected) => {
expect(convert(input)).toBeCloseTo(expected, 1);
});
});
JUnit 5 ParameterizedTest
// Java — JUnit 5 @ParameterizedTest
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"0, 0, 0",
"-1, 1, 0",
"-1, -1, -2",
"100, 200, 300",
"0.1, 0.2, 0.3"
})
void testAdd(double a, double b, double expected) {
assertEquals(expected, calculator.add(a, b), 0.001);
}
@ParameterizedTest
@ValueSource(strings = {"hello", "world", "test", "jest"})
void testIsNotEmpty(String input) {
assertFalse(input.isEmpty());
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 5, 8, 13, 21})
void testIsPositive(int number) {
assertTrue(number > 0);
}
}
JUnit 5 with MethodSource
// Java — JUnit 5 @MethodSource for complex test data
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
class EmailValidatorTest {
@ParameterizedTest
@MethodSource("emailProvider")
void testValidateEmail(String email, boolean expected) {
assertEquals(expected, EmailValidator.isValid(email));
}
static Stream<Arguments> emailProvider() {
return Stream.of(
Arguments.of("alice@example.com", true),
Arguments.of("bob@x.com", true),
Arguments.of("invalid", false),
Arguments.of("@example.com", false),
Arguments.of("alice@", false),
Arguments.of("", false),
Arguments.of(null, false)
);
}
}
JUnit 5 with EnumSource
// Java — JUnit 5 @EnumSource for testing all enum values
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
enum OrderStatus { PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED }
class OrderStatusTest {
@ParameterizedTest
@EnumSource(OrderStatus.class)
void testAllStatusesHaveDisplayName(OrderStatus status) {
assertNotNull(status.getDisplayName());
assertFalse(status.getDisplayName().isEmpty());
}
@ParameterizedTest
@EnumSource(value = OrderStatus.class, names = {"PENDING", "PROCESSING"})
void testActiveStatusesCanBeCancelled(OrderStatus status) {
assertTrue(OrderRules.canCancel(status));
}
@ParameterizedTest
@EnumSource(value = OrderStatus.class, names = {"DELIVERED", "CANCELLED"}, mode = EnumSource.Mode.EXCLUDE)
void testNonFinalStatusesCanBeModified(OrderStatus status) {
assertTrue(OrderRules.canModify(status));
}
}
Python with fixture + parametrize
# Python — combining fixtures with parametrize
@pytest.fixture
def calculator():
return Calculator()
@pytest.mark.parametrize("expression, expected", [
("1 + 2", 3),
("10 - 5", 5),
("3 * 4", 12),
("20 / 4", 5),
("2 ** 3", 8),
("10 % 3", 1),
])
def test_calculator_operations(calculator, expression, expected):
assert calculator.evaluate(expression) == expected
@pytest.mark.parametrize("expression", [
"1 / 0",
"10 % 0",
"abc + 1",
"",
None,
])
def test_calculator_invalid_input(calculator, expression):
with pytest.raises((ValueError, TypeError)):
calculator.evaluate(expression)
Cross-product parametrization
# Python — parametrize multiple dimensions (cross product)
@pytest.mark.parametrize("currency", ["USD", "EUR", "GBP", "JPY"])
@pytest.mark.parametrize("amount", [0, 100, 1000, 10000])
def test_format_currency(amount, currency):
result = format_currency(amount, currency)
assert currency in result
assert str(amount) in result or format(amount, ",") in result
Property-based testing (Hypothesis)
# Python — property-based testing with Hypothesis
from hypothesis import given, strategies as st
@given(st.integers(), st.integers())
def test_add_commutative(a, b):
assert add(a, b) == add(b, a)
@given(st.integers(), st.integers(), st.integers())
def test_add_associative(a, b, c):
assert add(add(a, b), c) == add(a, add(b, c))
@given(st.lists(st.integers()))
def test_sum_equals_len_times_mean(numbers):
if numbers:
assert sum(numbers) == len(numbers) * (sum(numbers) / len(numbers))
@given(st.text())
def test_upper_lower_roundtrip(s):
assert s.upper().lower() == s.lower()
Variants
Data from external file
# Python — load test data from CSV
import csv
import pytest
def load_test_data():
with open('tests/data/email_cases.csv') as f:
reader = csv.DictReader(f)
return [(row['email'], row['expected'] == 'true') for row in reader]
@pytest.mark.parametrize("email, expected", load_test_data())
def test_validate_email_from_csv(email, expected):
assert validate_email(email) == expected
# tests/data/email_cases.csv
email,expected
alice@example.com,true
bob@x.com,true
invalid,false
@example.com,false
Parameterized with custom names
// JavaScript — Jest with custom test names
describe('fizzbuzz', () => {
const cases = [
{ input: 1, expected: '1' },
{ input: 3, expected: 'Fizz' },
{ input: 5, expected: 'Buzz' },
{ input: 15, expected: 'FizzBuzz' },
{ input: 30, expected: 'FizzBuzz' },
{ input: 7, expected: '7' },
];
test.each(cases)('fizzbuzz($input) → "$expected"', ({ input, expected }) => {
expect(fizzbuzz(input)).toBe(expected);
});
});
Parameterized integration tests
// Java — parameterized integration test with different DB configs
@ParameterizedTest
@MethodSource("databaseConfigs")
void testUserCrudAcrossDatabases(DatabaseConfig config) {
var repo = new UserRepository(config);
// Create
User user = repo.save(new User("Alice", "alice@x.com"));
assertNotNull(user.getId());
// Read
User found = repo.findById(user.getId());
assertEquals("Alice", found.getName());
// Update
found.setName("Alice Smith");
repo.save(found);
assertEquals("Alice Smith", repo.findById(user.getId()).getName());
// Delete
repo.delete(user.getId());
assertNull(repo.findById(user.getId()));
}
static Stream<DatabaseConfig> databaseConfigs() {
return Stream.of(
new DatabaseConfig("h2", "jdbc:h2:mem:test"),
new DatabaseConfig("sqlite", "jdbc:sqlite::memory:"),
new DatabaseConfig("postgres", "jdbc:postgresql://localhost/testdb")
);
}
Best Practices
-
For a deeper guide, see JUnit 5: Extensions, Parameterized Tests, Dynamic Tests.
-
Use descriptive test IDs —
idsin pytest, template strings in Jest, names in JUnit -
Keep test data readable — align columns, group related cases
-
Test edge cases — null, empty, boundary values, max/min, invalid input
-
Don’t over-parameterize — if each case needs different setup, write separate tests
-
Use
pytest.approxfor floats — floating point comparisons need tolerance -
Group by behavior, not by function —
test_email_validationnottest_validate_email_valid+test_validate_email_invalid -
Consider property-based testing for mathematical functions — Hypothesis finds edge cases you’d miss
-
Keep parameter lists short — if you have 8 parameters, your function may be too complex
Common Mistakes
- Too many parameters: 6+ parameters per case makes tests unreadable. Extract an object or split the test.
- Testing implementation details: parameterized tests should test outputs, not internal calls.
- Missing edge cases: developers test the happy path and forget null, empty, negative, and boundary values.
- Not using IDs:
test_add[0]is useless when it fails. Usetest_add[zero_plus_zero]instead. - Mixing concerns: one parameterized test for both valid and invalid inputs with different assertion logic is confusing. Split them.
FAQ
What is a parameterized test?
A test that runs the same logic with multiple input-output combinations. Instead of writing 10 test functions, you write 1 and provide a table of 10 cases.
How is this different from a loop inside a test?
A loop inside a test reports one pass/fail for all cases. Parameterized tests report each case separately — you know exactly which input failed.
What is property-based testing?
Instead of providing specific inputs, you define properties (e.g., “addition is commutative”) and the framework generates random inputs to verify the property holds. Hypothesis (Python) and fast-check (JavaScript) are popular tools.
Should I use parameterized tests for integration tests?
Yes, when the same workflow applies to different configurations (e.g., testing CRUD against multiple databases). But avoid it when each configuration needs different setup.
How many test cases should I have?
Enough to cover all equivalence classes: normal cases, edge cases, boundary values, and error cases. Typically 5-15 cases per parameterized test. If you have 50+, consider property-based testing.
Related Resources
Test Double: Replace Dependencies with Stubs, Spies, Fakes
How to use test doubles to isolate units under test. Covers stubs, spies, fakes, mocks, and dummy objects with examples in Python, JavaScript, and Java.
PatternFixture Setup/Teardown: Reusable Test Context Lifecycle
How to use setup and teardown fixtures to create reusable test context. Covers beforeEach, factory functions, fixture objects, and cleanup with examples.
PatternSnapshot Testing: Capture and Compare Serialized Output
How to use snapshot testing to detect unintended changes in serialized output. Covers Jest snapshots, pytest snapshot, and inline vs external snapshots.