Note: This is hand-written and organically generated by human
Parameterized Testing: How I Reduced Test Code by ~50% Without Losing Coverage
The Problem
Agents have made code production cheap and formal verification even more important. A lot of tests are needed to validate that business logic, edges cases and application flow are highly deterministic. The more tests the merrier but there is a catch. A point can be reached where the tests files become a dump site that only agents can wade through. This is not desirable for many applications and least desirable in Finance where flow must be deterministic and unknowns reduced to minimum.
At a point you can longer read the tests but scanning for patterns. Domestic vs international. PIN vs insufficient funds. Frozen card vs suspected fraud. Same setup, same assertions, different constants.
In my case, this had manifested as two classes:
TxApprovedNtProcessorTestTxDeclinedNtProcessorTest
Between them:
- 61 test methods
- 700+ lines per file
- A lot of copy-paste
- A quiet fear of touching anything
Every new scenario meant duplicating ~15 lines of boilerplate. Coverage was good, but maintainability was aspirational.
So I decided to clean it up over the weekend.
Before: What Test Bloat Actually Looks Like
Domestic vs International (a Familiar Smell)
@Test
void shouldSendDomesticInsufficientFundsNotification() {
BaseAuthorizationEventDto payload =
buildPayload("51", "840", "840");
processor.sendNotification(payload, AUTHORIZATION_DECLINED);
CardNotificationContext ctx = captureNotification();
assertThat(ctx.getNotificationEvent())
.isEqualTo(INSUFFICIENT_FUNDS_DOMESTIC);
}
@Test
void shouldSendInternationalInsufficientFundsNotification() {
BaseAuthorizationEventDto payload =
buildPayload("51", "840", "978"); // changed
processor.sendNotification(payload, AUTHORIZATION_DECLINED);
CardNotificationContext ctx = captureNotification();
assertThat(ctx.getNotificationEvent())
.isEqualTo(INSUFFICIENT_FUNDS_INTERNATIONAL); // also changed
}
Now imagine this repeated for:
- Incorrect PIN
- Suspected fraud
- Restricted card
- Issuer error
That’s not ten tests.
That’s one test written ten times.
Limit Warnings: Same Logic, Different Ratios
@Test
void testNearingDailyLimit() {
payload.setOverallDailyAmtRatio(new BigDecimal("0.7"));
processor.sendNotification(payload, APPROVED);
verify(schedulerService).scheduleTask(any(), any());
}
@Test
void testDailyLimitReached() {
payload.setOverallDailyAmtRatio(BigDecimal.ONE);
processor.sendNotification(payload, APPROVED);
verify(schedulerService).scheduleTask(any(), any());
}
Each scenario had its own method, even though the behavior was identical.
The pattern was invisible. The file was just long.
Mock Setup Everywhere
CardDetails cardDetails = new CardDetails(...);
Account account = Account.builder()...;
BalanceDetails balance = BalanceDetails.builder()...;
when(cardService.getCardDetails(cardId)).thenReturn(cardDetails);
when(accountService.getPrimaryAccountByUserId(any())).thenReturn(account);
when(cbaClient.getAccountBalance(...)).thenReturn(response);
Copy. Paste. Repeat.
The Refactoring Strategy
I didn’t do anything clever. I just did these three things: centralised the mock setup, a fluent test data builder and parameterized tests.
1. Centralize Mock Setup
Most tests were happy-path tests. So I treated them like it.
@BeforeEach
void setup() {
processor = new TransactionApprovedNotificationProcessor(...);
lenient().when(cbaProps.getBookCode()).thenReturn(BOOK_CODE);
lenient().when(verificationProps.getExcludedMerchants())
.thenReturn(Collections.emptyList());
setupDependentServicesMocks(BigDecimal.valueOf(500));
}
Anything special could override this in the test itself.
This alone removed hundreds of duplicated lines.
2. One Fluent Test Data Builder
Instead of multiple helper methods, I introduced one builder with defaults.
createDto()
.responseCode("51")
.txnCurrency("840")
.billingCurrency("978")
.build();
Defaults handled the noise.
Chaining highlighted intent.
It became very easy to see what mattered in each test.
3. Parameterized Tests
When I started researching on how to reduce the test files there were other things like breaking up the test files into multiple files, making a test data factory but this returned the biggest win. You write a single test, create a table of values with expected outcome. It was truly a breadth of fresh air to learn how to use Parameterized testing.
All those “same logic, different data” tests collapsed into one test + a data table.
@ParameterizedTest
@MethodSource("standardDeclineScenarios")
void shouldSendCorrectDeclineNotification(
String responseCode,
String txnCurrency,
String billingCurrency,
NotificationEvent expectedEvent) {
BaseAuthorizationEventDto payload = createDto()
.responseCode(responseCode)
.txnCurrency(txnCurrency)
.billingCurrency(billingCurrency)
.build();
processor.sendNotification(payload, DECLINED);
assertThat(captureNotification().getNotificationEvent())
.isEqualTo(expectedEvent);
}
And the scenarios:
static Stream<Arguments> standardDeclineScenarios() {
return Stream.of(
Arguments.of("51", "840", "840", INSUFFICIENT_FUNDS_DOMESTIC),
Arguments.of("51", "840", "978", INSUFFICIENT_FUNDS_INTERNATIONAL),
Arguments.of("55", "840", "840", INCORRECT_PIN_DOMESTIC),
Arguments.of("55", "840", "978", INCORRECT_PIN_INTERNATIONAL)
);
}
The Results
TxApprovedNtProcessorTest
| Metric | Before | After |
|---|---|---|
| Test methods | 26 | 13 |
| Lines of code | ~600 | ~374 |
| Mock setup | Every test | Once |
TxDeclinedNtProcessorTest
| Metric | Before | After |
|---|---|---|
| Test methods | 35 | 13 |
| Lines of code | ~750 | ~476 |
| Mock setup | Every test | Once |
Combined
- 61 → 26 test methods
- ~1,350 → ~850 lines
- Coverage: unchanged
- Readability: dramatically better
Things I Learned Along the Way
- Parameterized tests are great when behavior is the same
- They are bad when you start adding
ifstatements - Builders should have defaults — otherwise they’re just verbose setters
@BeforeEachis underrated
Final Thoughts
Before this refactor, finding a specific test felt like mining. Afterwards, it feels much more like reading application spec.
“What happens when insufficient funds + international currency?”
→ It’s one row in a table.
Parameterized tests didn’t just reduce code, it made the tests clearer. I might end up overusing it in the coming days 😹
That alone was worth the refactor.