JUnit Parameterized Calculator
Calculate test coverage and parameter combinations for your JUnit test cases
Calculation Results
Comprehensive Guide: How to Parameterize Calculator Examples in JUnit
Parameterized testing in JUnit is a powerful technique that allows you to run the same test method with different inputs, significantly improving test coverage and reducing code duplication. This guide will walk you through the complete process of creating parameterized calculator tests in JUnit 5 (Jupiter), with practical examples and best practices.
1. Understanding Parameterized Tests in JUnit 5
JUnit 5 introduced the junit-jupiter-params module specifically for parameterized testing. This approach offers several advantages:
- Increased coverage – Test multiple input combinations with minimal code
- Reduced duplication – Single test method handles multiple scenarios
- Better maintainability – Changes to test logic apply to all test cases
- Clearer test reports – Each parameter combination appears as a separate test
The core annotation is @ParameterizedTest, which replaces the standard @Test annotation for parameterized methods.
2. Setting Up Your Project for Parameterized Tests
To use parameterized tests, you need to add the following dependency to your pom.xml (Maven) or build.gradle (Gradle):
Maven:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
Gradle:
testImplementation('org.junit.jupiter:junit-jupiter-params:5.9.2')
3. Basic Parameterized Calculator Example
Let’s create a simple calculator class and then write parameterized tests for it:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
public int multiply(int a, int b) {
return a * b;
}
public double divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("Cannot divide by zero");
}
return (double) a / b;
}
}
4. Parameterized Test Annotations and Sources
JUnit 5 provides several ways to supply arguments to your parameterized tests:
| Annotation | Description | Best For | Example |
|---|---|---|---|
@ValueSource |
Provides a single array of literal values | Simple, single-parameter tests | @ValueSource(ints = {1, 2, 3}) |
@EnumSource |
Provides enum constants | Testing enum-based logic | @EnumSource(Day.class) |
@MethodSource |
References a factory method | Complex test data generation | @MethodSource("provideArgs") |
@CsvSource |
Provides comma-separated values | Multi-parameter tests with simple values | @CsvSource({"1,1,2", "2,3,5"}) |
@CsvFileSource |
Reads from CSV files | Large test datasets | @CsvFileSource(resources = "/test-data.csv") |
@ArgumentsSource |
Custom arguments provider | Advanced scenarios with custom logic | @ArgumentsSource(MyArgsProvider.class) |
5. Practical Calculator Test Examples
5.1 Using @MethodSource for Addition Tests
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorMethodSourceTest {
private final Calculator calculator = new Calculator();
static Stream<Arguments> provideAdditionCases() {
return Stream.of(
Arguments.of(1, 1, 2),
Arguments.of(2, 3, 5),
Arguments.of(-1, -1, -2),
Arguments.of(0, 0, 0),
Arguments.of(Integer.MAX_VALUE, 1, Integer.MIN_VALUE)
);
}
@ParameterizedTest
@MethodSource("provideAdditionCases")
void testAdd(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b),
() -> a + " + " + b + " should equal " + expected);
}
}
5.2 Using @CsvSource for Multiplication Tests
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorCsvSourceTest {
private final Calculator calculator = new Calculator();
@ParameterizedTest
@CsvSource({
"2, 3, 6",
"5, 5, 25",
"-2, 3, -6",
"0, 5, 0",
"100, 0, 0"
})
void testMultiply(int a, int b, int expected) {
assertEquals(expected, calculator.multiply(a, b),
() -> a + " * " + b + " should equal " + expected);
}
}
5.3 Using @ValueSource for Single Parameter Tests
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CalculatorValueSourceTest {
private final Calculator calculator = new Calculator();
@ParameterizedTest
@ValueSource(ints = {0, 1, 5, 10, 100})
void testMultiplyByZero(int number) {
assertTrue(calculator.multiply(number, 0) == 0,
() -> number + " multiplied by 0 should be 0");
}
}
5.4 Using @CsvFileSource for Large Datasets
Create a file src/test/resources/division-tests.csv:
10,2,5.0 100,4,25.0 9,3,3.0 1,2,0.5 -10,2,-5.0
Then create the test:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvFileSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorCsvFileSourceTest {
private final Calculator calculator = new Calculator();
@ParameterizedTest
@CsvFileSource(resources = "/division-tests.csv", numLinesToSkip = 0)
void testDivide(int a, int b, double expected) {
assertEquals(expected, calculator.divide(a, b), 0.001,
() -> a + " / " + b + " should equal " + expected);
}
}
6. Advanced Techniques for Parameterized Testing
6.1 Custom Argument Providers
For complex test data generation, you can create custom argument providers:
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import java.util.stream.Stream;
class RandomMultiplicationProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
return Stream.iterate(0, n -> n + 1)
.limit(10)
.map(n -> Arguments.of(
(int)(Math.random() * 100),
(int)(Math.random() * 100),
(int)(Math.random() * 100) * (int)(Math.random() * 100)
));
}
}
Then use it in your test:
@ParameterizedTest
@ArgumentsSource(RandomMultiplicationProvider.class)
void testRandomMultiplication(int a, int b, int expected) {
assertEquals(expected, calculator.multiply(a, b));
}
6.2 Combining Multiple Sources
You can combine multiple sources using @ArgumentsSource with composite providers:
@ParameterizedTest
@MethodSource("provideBasicCases")
@CsvSource({"10,20,30", "-5,5,0"})
void testAddMultipleSources(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
6.3 Parameterized Theory Testing
Parameterized tests can be used to implement "theory" testing, where you test general assertions with many examples:
@ParameterizedTest
@CsvSource({
"0, 0, 0", // identity
"1, 0, 1", // identity
"2, 1, 1", // difference
"5, 2, 3", // normal case
"-1, -1, 0", // negatives
"100, 99, 1" // large numbers
})
void subtractionTheory(int a, int b, int expected) {
assertEquals(expected, calculator.subtract(a, b),
() -> String.format("Failed for inputs: %d, %d", a, b));
}
7. Best Practices for Parameterized Tests
- Keep test methods focused - Each parameterized test should verify one specific behavior
- Use descriptive names - The test name should clearly indicate what's being tested
- Limit the number of parameters - More than 3-4 parameters can make tests hard to read
- Provide meaningful assertion messages - Include the actual parameters in failure messages
- Consider test data organization - For large datasets, use external files or factory methods
- Test edge cases - Include boundary values, nulls, and exceptional cases
- Keep tests independent - Each parameter combination should run in isolation
- Document complex providers - Add comments explaining non-obvious test data generation
8. Performance Considerations
While parameterized tests offer many benefits, they can impact test execution time:
| Factor | Impact | Mitigation Strategy |
|---|---|---|
| Number of parameter combinations | Linear increase in test time | Limit to essential test cases, use @MethodSource for dynamic filtering |
| Complex setup/teardown | Multiplied by number of combinations | Use @BeforeEach/@AfterEach for shared setup, keep it lightweight |
| External resource access | Network/DB calls multiplied | Mock external dependencies, use in-memory test doubles |
| Test data generation | Memory usage for large datasets | Use Stream-based providers, lazy evaluation |
| Parallel execution | Potential resource contention | Design tests to be thread-safe, use @Execution(Concurrent) |
For performance-critical test suites, consider:
- Running parameterized tests in parallel with
@Execution(Concurrent) - Using
@MethodSourcewith lazy streams for large datasets - Splitting very large parameterized tests into multiple test methods
- Implementing custom
ArgumentsProviderwith optimized data generation
9. Integration with Build Tools and CI
Parameterized tests work seamlessly with modern build tools and CI systems:
Maven Surefire Plugin:
The plugin automatically detects and runs parameterized tests. Each parameter combination appears as a separate test in reports.
Gradle Test Task:
Gradle's test task handles parameterized tests natively, with each combination reported individually.
CI Systems (Jenkins, GitHub Actions, etc.):
All major CI systems support JUnit 5 parameterized tests, with detailed test reports showing each parameter combination.
Example GitHub Actions workflow:
name: Java CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Build with Maven
run: mvn clean test
10. Real-World Case Study: Financial Calculator
Let's examine how parameterized testing was applied to a financial calculator application:
10.1 Requirements
- Calculate compound interest with varying rates and periods
- Handle different compounding frequencies (annually, monthly, daily)
- Validate edge cases (zero/negative values, very large numbers)
- Support multiple currencies with different precision requirements
10.2 Test Implementation
class FinancialCalculatorTest {
private final FinancialCalculator calculator = new FinancialCalculator();
static Stream<Arguments> provideCompoundInterestCases() {
return Stream.of(
// principal, rate, years, compounding, expected
Arguments.of(1000.00, 0.05, 1, Compounding.ANNUALLY, 1050.00),
Arguments.of(1000.00, 0.05, 10, Compounding.ANNUALLY, 1628.89),
Arguments.of(1000.00, 0.05, 1, Compounding.MONTHLY, 1051.16),
Arguments.of(1000.00, 0.05, 10, Compounding.DAILY, 1647.01),
Arguments.of(0.00, 0.05, 10, Compounding.ANNUALLY, 0.00),
Arguments.of(1000.00, 0.00, 10, Compounding.ANNUALLY, 1000.00),
Arguments.of(1000.00, 0.05, 0, Compounding.ANNUALLY, 1000.00)
);
}
@ParameterizedTest
@MethodSource("provideCompoundInterestCases")
void testCompoundInterest(double principal, double rate, int years,
Compounding compounding, double expected) {
double result = calculator.calculateCompoundInterest(
principal, rate, years, compounding);
assertEquals(expected, result, 0.01,
() -> String.format("Failed for: %.2f, %.2f%%, %d years, %s",
principal, rate*100, years, compounding));
}
@ParameterizedTest
@ValueSource(doubles = {0.0, -1.0, -1000.0})
void testNegativeOrZeroPrincipalThrowsException(double principal) {
assertThrows(IllegalArgumentException.class, () -> {
calculator.calculateCompoundInterest(principal, 0.05, 10, Compounding.ANNUALLY);
});
}
}
10.3 Results
- Test coverage increased from 65% to 92%
- Reduced test maintenance time by 40%
- Discovered 3 edge case bugs in compounding logic
- Improved test readability and documentation