Skip to main content

Testing Strategy

The project tests the entire service layer with fast, isolated JUnit 5 unit tests using Mockito to stub the repository — no Spring context, no H2, no network, just pure business logic verification.

What Problem Does It Solve?

The service contains the most complex logic in the project: risk bands, interest rate formulas, the EMI calculation, and two tiers of eligibility rules. Integration tests with a full Spring context would be slow and hard to pinpoint when a rule changes. Unit tests with a mocked repository run in milliseconds and verify each rule independently.

Test Architecture

One test class, three @Nested groups. Each group focuses on one concern — risk, EMI maths, or eligibility.

Test Setup

@ExtendWith(MockitoExtension.class)          // ← activates Mockito; no Spring context loaded
public class LoanApplicationServiceTest {

@Mock
private ILoanApplicationRepository repository; // ← real repository replaced with a mock

@InjectMocks
private LoanApplicationService service; // ← Mockito creates the service and injects the mock

@ExtendWith(MockitoExtension.class) replaces the old MockitoAnnotations.openMocks(this) in a @BeforeEach. It:

  1. Creates a mock for every @Mock-annotated field.
  2. Injects those mocks into the @InjectMocks target via constructor injection (preferred) or field injection.

Because LoanApplicationService uses constructor injection (public LoanApplicationService(ILoanApplicationRepository repo)), Mockito injects via constructor.

The Repository Stub

Every test that results in a save() call uses the same stub:

when(repository.save(any(LoanApplication.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); // ← returns the object passed to save()

thenAnswer(invocation -> invocation.getArgument(0)) returns whatever was passed to save() unchanged. This is important because save() is expected to return the persisted entity (with the UUID assigned in @PrePersist), and the response mapping works on that returned entity.

Why not thenReturn(someFixedEntity)?

Because save() is called with an object whose applicationId hasn't been set yet (it's set in @PrePersist via Hibernate). thenAnswer lets the test return the same object Mockito received — simulating the @PrePersist callback just before the entity comes back.

Helper Methods

private ApplicantDTO createApplicant(String name, Integer age, BigDecimal income,
EmploymentType employmentType, Integer creditScore) {
return new ApplicantDTO(name, age, income, employmentType, creditScore);
}

private LoanDTO createLoan(BigDecimal amount, Integer tenureMonths, LoanPurpose purpose) {
return new LoanDTO(amount, tenureMonths, purpose);
}

Tiny factory methods eliminate repetition and make each test's intent obvious — the test data is in the test, not hidden in a fixture class.

@Nested Class: RiskClassificationTests

Groups all tests that verify how credit scores map to risk bands.

@Nested
class RiskClassificationTests {

@Test
void testRiskClassification_LowRisk() {
ApplicantDTO applicant = createApplicant("John", 30, new BigDecimal("50000"),
EmploymentType.SALARIED, 800); // ← score 800 → LOW
LoanDTO loan = createLoan(new BigDecimal("500000"), 120, LoanPurpose.PERSONAL);

when(repository.save(any(LoanApplication.class)))
.thenAnswer(inv -> inv.getArgument(0));

LoanApplicationResponse response = service.processLoanApplication(
new LoanApplicationRequest(applicant, loan));

assertEquals(RiskBand.LOW, response.riskBand());
assertEquals(ApplicationStatus.APPROVED, response.status());
verify(repository).save(any(LoanApplication.class)); // ← confirm save was called exactly once
}

// Similar tests for MEDIUM (score 700) and HIGH (score 620)
}
Credit ScoreExpected riskBandExpected status
800LOWAPPROVED
700MEDIUMAPPROVED
620HIGHAPPROVED (just above 600 threshold)

@Nested Class: EmiCalculationTests

Verifies that the EMI formula produces exact values for known inputs.

@Test
void testEmiCalculation_Standard() {
ApplicantDTO applicant = createApplicant("Alice", 30, new BigDecimal("50000"),
EmploymentType.SALARIED, 800);
LoanDTO loan = createLoan(new BigDecimal("500000"), 60, LoanPurpose.PERSONAL);

when(repository.save(any(LoanApplication.class))).thenAnswer(inv -> inv.getArgument(0));

LoanApplicationResponse response = service.processLoanApplication(
new LoanApplicationRequest(applicant, loan));

BigDecimal expectedEmi = new BigDecimal("11122.22");
// compareTo ignores scale differences; assertEquals with BigDecimal uses .equals() which considers scale
assertEquals(0, expectedEmi.compareTo(response.offer().emi()),
"EMI should be " + expectedEmi + " but was " + response.offer().emi());
}
assertEquals with BigDecimal

assertEquals(new BigDecimal("1.50"), new BigDecimal("1.5")) fails.equals() considers scale, so 1.50 ≠ 1.5. Always use assertEquals(0, a.compareTo(b)) when comparing BigDecimal values in tests.

Test coverage for EMI scenarios:

TestPrincipalRateTenureExpected EMI
Standard (LOW risk, SALARIED)5,00,00012%60m₹11,122.22
Medium risk (SALARIED)3,00,00013.5%36m₹10,180.59
Self-employed (LOW risk)4,00,00013%48m₹10,731.00
Large loan (>10L, LOW risk)15,00,00012.5%120m₹21,956.43
Total payable = EMI × tenure6,00,00012%60mVerified

@Nested Class: EligibilityLogicTests

The largest group — verifies every rejection path and the approval path.

Credit Score Too Low

@Test
void testEligibility_CreditScoreTooLow() {
ApplicantDTO applicant = createApplicant("George", 30, new BigDecimal("50000"),
EmploymentType.SALARIED, 550); // ← below 600
// ...
assertEquals(ApplicationStatus.REJECTED, response.status());
assertTrue(response.rejectionReasons().contains(RejectionReason.CREDIT_SCORE_TOO_LOW));
assertNull(response.riskBand()); // ← must be cleared
assertNull(response.offer()); // ← must be cleared
}

Age + Tenure Exceeds 65

@Test
void testEligibility_AgeTenureLimitExceeded() {
// age=58, tenure=120 months (10 years) → 58+10=68 > 65
ApplicantDTO applicant = createApplicant("Helen", 58, new BigDecimal("80000"),
EmploymentType.SALARIED, 750);
LoanDTO loan = createLoan(new BigDecimal("500000"), 120, LoanPurpose.PERSONAL);
// ...
assertTrue(response.rejectionReasons().contains(RejectionReason.AGE_TENURE_LIMIT_EXCEEDED));
}

EMI Exactly 50% — Edge Case (Approved)

@Test
void testEligibility_EmiExactly50Percent() {
// EMI is designed to be exactly = 50% of income → should APPROVE (≤ 50% passes)
ApplicantDTO applicant = createApplicant("Laura", 30, new BigDecimal("70000"),
EmploymentType.SALARIED, 800);
LoanDTO loan = createLoan(new BigDecimal("1500000"), 60, LoanPurpose.HOME);
// ...
assertEquals(ApplicationStatus.APPROVED, response.status());
}

This is a boundary test — it verifies that emi.compareTo(maxEmi50) <= 0 uses <= (inclusive), not < (exclusive).

Multiple Rejection Reasons

@Test
void testEligibility_MultipleRejectionReasons() {
// age=59, tenure=120 (10y) → 59+10=69 > 65 AND credit=580 < 600
ApplicantDTO applicant = createApplicant("Mike", 59, new BigDecimal("50000"),
EmploymentType.SALARIED, 580);
LoanDTO loan = createLoan(new BigDecimal("300000"), 120, LoanPurpose.PERSONAL);
// ...
assertEquals(ApplicationStatus.REJECTED, response.status());
assertTrue(response.rejectionReasons().size() > 1);
assertTrue(response.rejectionReasons().contains(RejectionReason.CREDIT_SCORE_TOO_LOW));
assertTrue(response.rejectionReasons().contains(RejectionReason.AGE_TENURE_LIMIT_EXCEEDED));
}

Verifies that the List<RejectionReason> accumulates all applicable reasons rather than stopping at the first.

What Is NOT Tested Here

GapWhyRecommended Fix
GlobalExceptionHandlerNot a unit test concern; needs @WebMvcTestAdd @WebMvcTest(LoanApplicationController.class) tests
Repository persistence (H2)Mocked outAdd @DataJpaTest for repository-level tests
Invalid JSON inputNeeds HTTP layer@WebMvcTest with MockMvc
Concurrent accessNot testedLoad test with Gatling or k6

Best Practices Demonstrated

  • @Nested grouping — tests for each concern live in their own group, making failures immediately localisable.
  • verify(repository).save(...) in every test — confirms the save was called; prevents a test that passes because the service returns early without saving.
  • BigDecimal.compareTo() for assertions — avoids false failures from scale differences.
  • Factory methodscreateApplicant() and createLoan() keep test data readable and editable.
  • @DisplayName — used in the original test class; makes JUnit output human-readable in CI reports.

Common Pitfalls

  • Using assertEquals directly on BigDecimal — it compares using .equals() which considers scale; always use compareTo.
  • Not calling verify() — a test that only asserts on the response but never verifies save() was called would pass even if the response is built from hard-coded values without calling the repository.
  • Over-mocking — mocking the LoanApplicationService to test itself would be circular. The service is the system under test; only its external dependency (the repository) is mocked.
  • Mixing @SpringBootTest with Mockito@SpringBootTest loads the full context; combine it with @MockBean (not @Mock) if you need Spring context + mocked beans. This project deliberately avoids the full context for speed.

Interview Questions

Q: Why use @ExtendWith(MockitoExtension.class) instead of @SpringBootTest?
A: @ExtendWith(MockitoExtension.class) creates and injects mocks without loading any Spring context. The test runs in milliseconds. @SpringBootTest starts the entire application context, including all auto-configurations, and is 10–50× slower. When testing a pure business logic service, there is no reason to involve Spring.

Q: How does Mockito's thenAnswer differ from thenReturn?
A: thenReturn(value) always returns the same fixed value regardless of arguments. thenAnswer(invocation -> ...) executes a lambda and can inspect/transform the actual arguments passed to the mock. Here it returns invocation.getArgument(0) — the exact entity passed to save() — which simulates what a real repository would do.

Q: What is the purpose of @Nested in JUnit 5?
A: @Nested lets you group related tests into inner classes within a single test class. Each group can have its own setup/teardown and its own descriptive name. It organises the test output into a tree structure that mirrors the concerns under test, making it easier to understand which rule a failing test covers.

Further Reading