JUnit 5 & Mockito Testing Guide for Node.js Developers¶
Comprehensive guide to writing unit tests in Java using JUnit 5 and Mockito, with Node.js comparisons.
Table of Contents¶
- Basic Test Structure
- Assertion Methods
- AAA Pattern (Arrange-Act-Assert)
- Test Naming Convention
- Display Names
- Lifecycle Methods
- Test Instance Behavior
- Disabling Tests
- Exception Testing
- Parameterized Tests
- Repeated Tests
- Test Method Ordering
- Test Doubles (Mocks, Stubs, Fakes, Spies)
- Advanced Assertions
- Real-World Example from MIIHC Project
- Common Difficulties & Challenges When Mocking Spring Boot
1. Basic Test Structure¶
@Test Annotation¶
The @Test annotation marks a method as a test method that JUnit will execute.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorTest {
@Test
public void testAddition() {
// Test code here
}
}
Node.js equivalent (Jest):
test('should add two numbers', () => {
// Test code here
});
Basic Structure¶
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class ClaimServiceTest {
private ClaimService claimService = new ClaimService(); // System Under Test (SUT)
@Test
public void testCreateClaim() {
// Arrange - setup test data
CreateClaimRequest request = new CreateClaimRequest();
request.setClaimArticleId(1);
request.setCountryId("FR");
// Act - execute the method being tested
Claim result = claimService.createClaim(request);
// Assert - verify the results
assertNotNull(result);
assertEquals("FR", result.getCountry());
}
}
2. Assertion Methods¶
assertEquals()¶
Checks if two values are equal.
@Test
public void testEquals() {
int actual = 5 + 5;
int expected = 10;
assertEquals(expected, actual); // Passes
assertEquals(expected, actual, "Math doesn't work!"); // With custom message
}
Node.js equivalent (Jest):
test('should equal', () => {
const actual = 5 + 5;
const expected = 10;
expect(actual).toBe(expected);
});
assertTrue()¶
Checks if a value is true.
@Test
public void testInvoiceIsValid() {
Invoice invoice = new Invoice();
invoice.setInValidationFlag(false);
boolean isValid = !invoice.getInValidationFlag();
assertTrue(isValid); // Passes
}
Node.js equivalent:
expect(isValid).toBe(true);
assertFalse()¶
Checks if a value is false.
@Test
public void testClaimNotDuplicate() {
Claim claim = new Claim();
claim.setClaimStatus("PROCESSING");
boolean isDuplicate = claim.getClaimStatus().equals("DUPLICATE");
assertFalse(isDuplicate); // Passes
}
Node.js equivalent:
expect(isDuplicate).toBe(false);
assertNotEqual()¶
Checks if two values are NOT equal.
@Test
public void testClaimIdUnique() {
UUID claimId1 = UUID.randomUUID();
UUID claimId2 = UUID.randomUUID();
assertNotEquals(claimId1, claimId2); // Almost always passes (random UUIDs)
}
Node.js equivalent:
expect(claimId1).not.toBe(claimId2);
assertDoesNotThrow()¶
Checks that a method does NOT throw an exception.
@Test
public void testCalculateDeadlineDoesNotThrow() {
LocalDate receivedDate = LocalDate.of(2024, 1, 15);
// This method should complete without throwing
assertDoesNotThrow(() -> {
deadlineCalculator.calculateDeadline(receivedDate, 18);
});
}
Node.js equivalent:
test('should not throw', () => {
expect(() => {
deadlineCalculator.calculateDeadline(new Date(), 18);
}).not.toThrow();
});
assertNull() and assertNotNull()¶
Checks if a value is null or not null.
@Test
public void testNullHandling() {
Claim claim = claimService.getClaimByUuid(UUID.randomUUID());
assertNull(claim); // Passes if claim is null
Claim foundClaim = claimService.getClaimByUuid(validUuid);
assertNotNull(foundClaim); // Passes if claim is not null
}
assertSame() and assertNotSame()¶
Checks if two objects are the same instance (using ==, not .equals()).
@Test
public void testObjectIdentity() {
Invoice invoice1 = new Invoice();
Invoice invoice2 = invoice1; // Same reference
Invoice invoice3 = new Invoice(); // Different instance
assertSame(invoice1, invoice2); // Passes - same object
assertNotSame(invoice1, invoice3); // Passes - different objects
}
Node.js equivalent:
test('object identity', () => {
const invoice1 = { id: 1 };
const invoice2 = invoice1;
const invoice3 = { id: 1 };
expect(invoice1).toBe(invoice2); // Same reference
expect(invoice1).not.toBe(invoice3); // Different references
expect(invoice1).toEqual(invoice3); // Same content
});
3. AAA Pattern (Arrange-Act-Assert)¶
The AAA Pattern (also called Given-When-Then) is the standard way to structure tests:
- Arrange (Given) — Set up test data and mocks
- Act (When) — Execute the method being tested
- Assert (Then) — Verify the results
Example: Testing ClaimsService¶
@Test
public void testCreateClaimWithValidData() {
// ===== ARRANGE (Given) =====
CreateClaimRequest request = new CreateClaimRequest();
request.setClaimArticleId(1);
request.setClaimSourceId(2);
request.setCountryId("DE");
request.setReference("REF123");
request.setYear(2024);
LookupService mockLookupService = mock(LookupService.class);
ClaimRepository mockRepository = mock(ClaimRepository.class);
ClaimsService claimsService = new ClaimsService(mockRepository, mockLookupService);
ClaimArticle claimArticle = new ClaimArticle();
claimArticle.setId(1);
when(mockLookupService.getClaimArticle(1)).thenReturn(claimArticle);
ClaimSource claimSource = new ClaimSource();
claimSource.setId(2);
when(mockLookupService.getClaimSource(2)).thenReturn(claimSource);
Country country = new Country();
country.setId("DE");
when(mockLookupService.getCountry("DE")).thenReturn(country);
Claim savedClaim = new Claim();
savedClaim.setId(UUID.randomUUID());
when(mockRepository.save(any(Claim.class))).thenReturn(savedClaim);
// ===== ACT (When) =====
Claim result = claimsService.createClaim(any(Claim.class));
// ===== ASSERT (Then) =====
assertNotNull(result);
assertEquals(claimArticle, result.getClaimArticle());
assertEquals(claimSource, result.getClaimSource());
assertEquals(country, result.getCountry());
verify(mockRepository).save(any(Claim.class)); // Verify method was called
}
Node.js equivalent (Jest):
test('should create claim with valid data', () => {
// === ARRANGE ===
const request = {
claimArticleId: 1,
claimSourceId: 2,
countryId: 'DE',
reference: 'REF123',
year: 2024,
};
const mockLookupService = {
getClaimArticle: jest.fn().mockReturnValue({ id: 1 }),
getClaimSource: jest.fn().mockReturnValue({ id: 2 }),
getCountry: jest.fn().mockReturnValue({ id: 'DE' }),
};
const mockRepository = {
save: jest.fn().mockReturnValue({ id: 'uuid-123' }),
};
const claimsService = createClaimsService(mockRepository, mockLookupService);
// === ACT ===
const result = claimsService.createClaim(request);
// === ASSERT ===
expect(result).toBeDefined();
expect(result.id).toBe('uuid-123');
expect(mockRepository.save).toHaveBeenCalled();
});
4. Test Naming Convention¶
Standard Convention¶
test<SystemUnderTest>_<Condition/StateChange>_<ExpectedResult>
Examples¶
@Test
public void testClaimsServiceCreateClaim_WithValidData_ReturnsClaimWithId() {
// ...
}
@Test
public void testInvoiceService_WhenStatusIsAccepted_UpdatesLedger() {
// ...
}
@Test
public void testDeadlineCalculator_WithValidDate_CalculatesCorrectDeadline() {
// ...
}
@Test
public void testClaimRepository_FindNonExistentClaim_ThrowsNotFoundException() {
// ...
}
Naming Best Practices¶
✅ Good: - testClaimService_CreateClaim_WithNullRequest_ThrowsException - testInvoiceService_UpdateStatus_ToAccepted_IncrementsLedger - testPersonService_Anglicise_WithAsciiName_ReturnsSameName
❌ Bad: - test1() — Not descriptive - testStuff() — Too vague - testService() — What specifically? - test_update_invoice_status() — Missing "expected result"
5. Display Names¶
@DisplayName on Test Class¶
Makes test output readable in IDE and CI/CD reports.
@DisplayName("Invoice Service Tests")
public class InvoiceServiceTest {
@DisplayName("should update invoice status to ACCEPTED when valid status provided")
@Test
public void testUpdateInvoiceStatus() {
// ...
}
@DisplayName("should throw IllegalStateTransitionException when invalid transition attempted")
@Test
public void testInvalidTransition() {
// ...
}
}
Output in test report:
Invoice Service Tests
✓ should update invoice status to ACCEPTED when valid status provided
✓ should throw IllegalStateTransitionException when invalid transition attempted
Without @DisplayName¶
InvoiceServiceTest
✓ testUpdateInvoiceStatus
✓ testInvalidTransition
Much less readable! The @DisplayName version is much clearer in reports.
Node.js equivalent (Jest):
describe('Invoice Service Tests', () => {
test('should update invoice status to ACCEPTED when valid status provided', () => {
// ...
});
test('should throw error when invalid transition attempted', () => {
// ...
});
});
6. Lifecycle Methods¶
Lifecycle methods run at specific points during test execution. Think of them like Jest's beforeAll, beforeEach, afterAll, afterEach.
@BeforeAll (Static)¶
Runs once before ALL test methods in the class. Must be static.
@DisplayName("Claims Service Lifecycle Example")
public class ClaimsServiceLifecycleTest {
private static LookupService mockLookupService; // Shared across all tests
@BeforeAll
public static void setupOnce() {
System.out.println("🔧 Setting up database connection (runs ONCE before all tests)");
mockLookupService = mock(LookupService.class);
// Initialize expensive resources (DB connection, file I/O, etc.)
}
@Test
public void testOne() {
System.out.println(" Test 1 running...");
}
@Test
public void testTwo() {
System.out.println(" Test 2 running...");
}
@AfterAll
public static void cleanupOnce() {
System.out.println("🧹 Closing database connection (runs ONCE after all tests)");
// Clean up resources
}
}
Output:
🔧 Setting up database connection (runs ONCE before all tests)
Test 1 running...
Test 2 running...
🧹 Closing database connection (runs ONCE after all tests)
@BeforeEach¶
Runs before each test method (creates fresh state for every test).
@DisplayName("Invoice Service - Fresh Setup Per Test")
public class InvoiceServiceTest {
private InvoiceService invoiceService;
private InvoiceRepository mockRepository;
private LookupService mockLookupService;
@BeforeEach
public void setupBeforeEachTest() {
System.out.println("⚙️ Setting up fresh mocks and service instance");
mockRepository = mock(InvoiceRepository.class);
mockLookupService = mock(LookupService.class);
invoiceService = new InvoiceService(mockRepository, mockLookupService);
}
@Test
public void testUpdateInvoiceStatus() {
System.out.println(" Running Test 1 (fresh setup)");
// invoiceService is a NEW instance
}
@Test
public void testCreateInvoice() {
System.out.println(" Running Test 2 (fresh setup)");
// invoiceService is a NEW instance (different from Test 1)
}
@AfterEach
public void cleanupAfterEachTest() {
System.out.println("🧹 Cleaning up after test");
}
}
Output:
⚙️ Setting up fresh mocks and service instance
Running Test 1 (fresh setup)
🧹 Cleaning up after test
⚙️ Setting up fresh mocks and service instance
Running Test 2 (fresh setup)
🧹 Cleaning up after test
@AfterEach¶
Runs after each test method (cleanup/verification).
@DisplayName("Database Tests with Cleanup")
public class ClaimRepositoryTest {
private ClaimRepository repository;
@BeforeEach
public void setup() {
repository = new ClaimRepository();
}
@AfterEach
public void cleanup() {
// Verify no database connections are left open
assertTrue(repository.isClosed());
System.out.println("✓ Database cleaned up");
}
@Test
public void testInsertClaim() {
Claim claim = new Claim();
claim.setId(UUID.randomUUID());
repository.save(claim);
}
}
Lifecycle Order¶
@BeforeAll (static, runs ONCE)
↓
@BeforeEach (runs before each test)
↓
@Test (test method 1)
↓
@AfterEach (runs after test 1)
↓
@BeforeEach (fresh setup)
↓
@Test (test method 2)
↓
@AfterEach (cleanup after test 2)
↓
@AfterAll (static, runs ONCE)
Node.js equivalent (Jest):
describe('Claims Service', () => {
let mockLookupService;
let claimsService;
beforeAll(() => {
// Runs once before all tests
mockLookupService = {};
});
beforeEach(() => {
// Runs before each test
claimsService = new ClaimsService(mockLookupService);
});
afterEach(() => {
// Runs after each test
jest.clearAllMocks();
});
afterAll(() => {
// Runs once after all tests
});
test('test 1', () => { });
test('test 2', () => { });
});
7. Test Instance Behavior¶
Default Behavior: NEW Instance Per Test¶
By default, JUnit creates a new instance of the test class before executing each test method. This ensures: - Tests are isolated - State changes in one test don't affect another - Each test has a clean slate
public class TestInstanceBehaviorExample {
private int counter = 0; // Instance variable
@Test
public void testOne() {
counter++;
System.out.println("Test 1: counter = " + counter); // Output: 1
assertEquals(1, counter);
}
@Test
public void testTwo() {
counter++;
System.out.println("Test 2: counter = " + counter); // Output: 1 (fresh instance!)
assertEquals(1, counter); // Passes! Each test gets a new instance
}
}
Output:
Test 1: counter = 1
Test 2: counter = 1 ← NEW instance, counter reset to 0 before increment
Why This Matters¶
If JUnit reused the same instance:
// Test 1 runs: counter = 1
// Test 2 runs: counter = 2 ← Would depend on Test 1 running first!
This would create test interdependencies (bad!).
@TestInstance(Lifecycle.PER_CLASS)¶
You can change the default to reuse the same instance:
@TestInstance(Lifecycle.PER_CLASS) // Reuse same instance for all tests
public class ReusedInstanceTest {
private int counter = 0;
@Test
public void testOne() {
counter++;
System.out.println("Test 1: counter = " + counter); // Output: 1
}
@Test
public void testTwo() {
counter++;
System.out.println("Test 2: counter = " + counter); // Output: 2 (same instance!)
}
@Test
public void testThree() {
counter++;
System.out.println("Test 3: counter = " + counter); // Output: 3 (same instance!)
}
}
Output:
Test 1: counter = 1
Test 2: counter = 2 ← Same instance, counter persists!
Test 3: counter = 3
When to Use @TestInstance(PER_CLASS)?¶
Good use case: CRUD testing
@TestInstance(Lifecycle.PER_CLASS)
@DisplayName("CRUD Operations - Sequential")
public class ClaimCrudTest {
private Claim createdClaim;
private UUID claimId;
@Test
@Order(1)
public void step1_CreateClaim() {
createdClaim = claimService.createClaim(new CreateClaimRequest());
claimId = createdClaim.getId();
assertNotNull(claimId);
}
@Test
@Order(2)
public void step2_RetrieveClaim() {
// Uses claimId from step1
Claim retrieved = claimService.getClaimByUuid(claimId);
assertEquals(createdClaim.getId(), retrieved.getId());
}
@Test
@Order(3)
public void step3_UpdateClaim() {
// Uses claimId from step1
claimService.updateSedVersion(claimId, "2.5");
Claim updated = claimService.getClaimByUuid(claimId);
assertEquals("2.5", updated.getRinaSedVersion());
}
@Test
@Order(4)
public void step4_DeleteClaim() {
// Cleanup
claimService.deleteClaim(claimId);
assertNull(claimService.getClaimByUuid(claimId));
}
}
8. Disabling Tests¶
@Disabled¶
Skips a test without deleting it. Useful for work-in-progress tests.
@DisplayName("Skipping Tests Example")
public class DisabledTestsExample {
@Test
public void testActive() {
System.out.println("This test RUNS");
assertTrue(true);
}
@Disabled("Feature not yet implemented")
@Test
public void testDisabledPending() {
System.out.println("This test is SKIPPED");
// Won't run
}
@Disabled("Bug #123 needs to be fixed")
@Test
public void testDisabledBuggy() {
assertTrue(false); // Won't run
}
}
Test report output:
✓ testActive
⊘ testDisabledPending (Feature not yet implemented)
⊘ testDisabledBuggy (Bug #123 needs to be fixed)
Conditional Disabling¶
@Test
@DisabledOnOs(OS.WINDOWS) // Skip on Windows
public void testLinuxOnly() {
// ...
}
@Test
@EnabledOnOs(OS.MAC) // Only run on macOS
public void testMacOnly() {
// ...
}
@Test
@EnabledIfEnvironmentVariable(named = "ENV", matches = "production")
public void testProductionOnly() {
// ...
}
9. Exception Testing¶
AssertThrows()¶
Verifies that a method throws the expected exception.
@DisplayName("Exception Testing")
public class ExceptionTestingExample {
@Test
public void testClaimNotFoundThrowsException() {
UUID nonExistentId = UUID.randomUUID();
// Assert that this throws ClaimNotFoundException
ClaimNotFoundException exception = assertThrows(
ClaimNotFoundException.class,
() -> claimsService.getClaimByUuid(nonExistentId)
);
// Verify the exception message
assertTrue(exception.getMessage().contains(nonExistentId.toString()));
}
@Test
public void testInvalidStateTransitionThrows() {
Invoice invoice = new Invoice();
invoice.setInvoiceStatus(InvoiceStatusEnum.ACC); // Accepted
// Trying to transition to an invalid state should throw
assertThrows(
IllegalStateTransitionException.class,
() -> invoiceService.updateInvoiceStatus(invoice.getId(), "INVALID_STATUS")
);
}
@Test
public void testNullArgumentThrows() {
assertThrows(
IllegalArgumentException.class,
() -> claimsService.createClaim(null) // Should throw
);
}
}
Node.js equivalent (Jest):
test('should throw ClaimNotFoundError', () => {
const nonExistentId = 'uuid-123';
expect(() => {
claimsService.getClaimByUuid(nonExistentId);
}).toThrow(ClaimNotFoundError);
});
assertDoesNotThrow()¶
Verifies that a method does NOT throw.
@Test
public void testValidTransitionDoesNotThrow() {
Invoice invoice = new Invoice();
invoice.setInvoiceStatus(InvoiceStatusEnum.PRO); // Processing
// This should NOT throw an exception
assertDoesNotThrow(() -> {
invoiceService.updateInvoiceStatus(invoice.getId(), InvoiceStatusEnum.ACC.getId());
});
}
10. Parameterized Tests¶
@ParametrizedTest with @ValueSource¶
Test the same method with multiple different inputs.
@DisplayName("Parameterized Tests - Multiple Values")
public class ParametrizedTestExample {
// Test with multiple string values
@ParameterizedTest
@ValueSource(strings = { "DE", "FR", "IT", "ES" })
public void testValidEuropeanCountries(String countryCode) {
Country country = lookupService.getCountry(countryCode);
assertNotNull(country, "Country " + countryCode + " should exist");
}
// Test with multiple numeric values
@ParameterizedTest
@ValueSource(ints = { 1, 2, 3, 4, 5 })
public void testValidClaimArticles(int articleId) {
ClaimArticle article = lookupService.getClaimArticle(articleId);
assertNotNull(article);
}
// Test with multiple boolean values
@ParameterizedTest
@ValueSource(booleans = { true, false })
public void testValidationFlag(boolean flag) {
Invoice invoice = new Invoice();
invoice.setInValidationFlag(flag);
assertEquals(flag, invoice.getInValidationFlag());
}
}
Executed as:
✓ testValidEuropeanCountries("DE")
✓ testValidEuropeanCountries("FR")
✓ testValidEuropeanCountries("IT")
✓ testValidEuropeanCountries("ES")
✓ testValidClaimArticles(1)
✓ testValidClaimArticles(2)
✓ testValidClaimArticles(3)
✓ testValidClaimArticles(4)
✓ testValidClaimArticles(5)
@ParametrizedTest with @CsvSource¶
Test with multiple rows of data.
@ParameterizedTest
@CsvSource({
"1, ACTUAL_COST, true",
"2, AVERAGE_COST, true",
"3, INVALID_TYPE, false"
})
public void testClaimTypeValidity(int typeId, String typeName, boolean isValid) {
ClaimType claimType = lookupService.getClaimType(typeId);
assertEquals(typeName, claimType.getName());
assertEquals(isValid, claimType.isValid());
}
Executed as 3 separate tests with different parameters each time.
11. Repeated Tests¶
@RepeatedTest¶
Runs the same test method multiple times.
@DisplayName("Repeated Tests - Same Test N Times")
public class RepeatedTestExample {
@RepeatedTest(5)
@DisplayName("Generate unique UUIDs")
public void testUuidUniqueness(RepetitionInfo repetitionInfo) {
UUID uuid = UUID.randomUUID();
System.out.println("Repetition " + repetitionInfo.getCurrentRepetition() +
" of " + repetitionInfo.getTotalRepetitions() +
": " + uuid);
assertNotNull(uuid);
}
}
Output:
Repetition 1 of 5: 550e8400-e29b-41d4-a716-446655440000
Repetition 2 of 5: 6ba7b810-9dad-11d1-80b4-00c04fd430c8
Repetition 3 of 5: 6ba7b811-9dad-11d1-80b4-00c04fd430c8
Repetition 4 of 5: 6ba7b812-9dad-11d1-80b4-00c04fd430c8
Repetition 5 of 5: 6ba7b813-9dad-11d1-80b4-00c04fd430c8
Use case: Testing race conditions or randomness.
@RepeatedTest(100)
@DisplayName("Test concurrent invoice processing")
public void testConcurrentInvoiceProcessing() {
Invoice invoice = createRandomInvoice();
invoiceService.process(invoice);
assertTrue(invoice.isProcessed());
}
12. Test Method Ordering¶
By default, JUnit doesn't guarantee test order. Use @TestMethodOrder to control execution order.
@TestMethodOrder(OrderAnnotation.class) with @Order¶
@TestMethodOrder(OrderAnnotation.class)
@DisplayName("Ordered Tests")
public class OrderedTestExample {
@Test
@Order(1)
public void step1_CreateClaim() {
System.out.println("Step 1: Create claim");
}
@Test
@Order(2)
public void step2_SubmitClaim() {
System.out.println("Step 2: Submit claim");
}
@Test
@Order(3)
public void step3_ApproveClaim() {
System.out.println("Step 3: Approve claim");
}
}
Execution order guaranteed: 1 → 2 → 3
Other Ordering Strategies¶
// Alphabetical order
@TestMethodOrder(MethodOrderer.MethodName.class)
// Random order (good for finding hidden dependencies)
@TestMethodOrder(MethodOrderer.Random.class)
// Display name order
@TestMethodOrder(MethodOrderer.DisplayName.class)
13. Test Doubles (Mocks, Stubs, Fakes, Spies)¶
Terminology¶
| Test Double | Purpose | When to Use |
|---|---|---|
| Stub | Provides fixed responses | Replace external service calls |
| Mock | Verifies method calls | Check if a method was called |
| Fake | Real implementation (simplified) | Replace complex dependency |
| Spy | Wraps real object, tracks calls | Partial mocking |
Stub - Returns Fixed Data¶
@Test
public void testWithStub() {
// Stub: Always returns the same response
LookupService stubService = new LookupService() {
@Override
public Country getCountry(String id) {
return new Country("DE", "Germany"); // Fixed response
}
};
Country country = stubService.getCountry("DE");
assertEquals("Germany", country.getName());
}
Mock - Verifies Method Calls¶
@Test
public void testWithMock() {
// Create a mock
ClaimRepository mockRepository = mock(ClaimRepository.class);
// Define behavior
UUID claimId = UUID.randomUUID();
Claim expectedClaim = new Claim();
expectedClaim.setId(claimId);
when(mockRepository.findById(claimId)).thenReturn(Optional.of(expectedClaim));
// Test code
ClaimsService service = new ClaimsService(mockRepository);
Claim result = service.getClaimByUuid(claimId);
// Verify the mock was called
verify(mockRepository).findById(claimId); // Verifies method was called
verify(mockRepository, times(1)).findById(claimId); // Called exactly once
verify(mockRepository, never()).save(any()); // save() was never called
}
Fake - Simplified Real Implementation¶
// Fake implementation: Real logic but simplified
public class FakeInvoiceRepository implements InvoiceRepository {
private Map<UUID, Invoice> storage = new HashMap<>();
@Override
public Invoice save(Invoice invoice) {
if (invoice.getId() == null) {
invoice.setId(UUID.randomUUID());
}
storage.put(invoice.getId(), invoice);
return invoice;
}
@Override
public Optional<Invoice> findById(UUID id) {
return Optional.ofNullable(storage.get(id));
}
}
@Test
public void testWithFake() {
InvoiceRepository fakeRepo = new FakeInvoiceRepository();
InvoiceService service = new InvoiceService(fakeRepo);
Invoice invoice = new Invoice();
Invoice saved = fakeRepo.save(invoice);
assertEquals(saved.getId(), fakeRepo.findById(saved.getId()).get().getId());
}
Spy - Partial Mocking (Track Calls on Real Object)¶
@Test
public void testWithSpy() {
// Spy wraps a real object
ClaimRepository realRepository = new ClaimRepository();
ClaimRepository spyRepository = spy(realRepository);
UUID claimId = UUID.randomUUID();
// Call real method
Claim claim = spyRepository.findById(claimId); // Uses real implementation
// But track the call
verify(spyRepository).findById(claimId); // Verify it was called
}
Mockito Example: InvoiceService Test¶
@DisplayName("Invoice Service with Mocks")
public class InvoiceServiceMockTest {
private InvoiceService invoiceService;
private InvoiceRepository mockRepository;
private LookupService mockLookupService;
private ClaimLedgerService mockLedgerService;
@BeforeEach
public void setup() {
mockRepository = mock(InvoiceRepository.class);
mockLookupService = mock(LookupService.class);
mockLedgerService = mock(ClaimLedgerService.class);
invoiceService = new InvoiceService(mockRepository, mockLookupService, mockLedgerService);
}
@Test
public void testUpdateInvoiceStatus() {
// ===== ARRANGE =====
UUID invoiceId = UUID.randomUUID();
Invoice invoice = new Invoice();
invoice.setId(invoiceId);
invoice.setInvoiceStatus(InvoiceStatusEnum.PRO); // Processing
when(mockRepository.findById(invoiceId)).thenReturn(Optional.of(invoice));
when(mockLookupService.getInvoiceStatus(InvoiceStatusEnum.ACC.getId()))
.thenReturn(new InvoiceStatus(InvoiceStatusEnum.ACC.getId(), "Accepted"));
when(mockRepository.save(any(Invoice.class))).thenReturn(invoice);
// ===== ACT =====
invoiceService.updateInvoiceStatus(invoiceId, InvoiceStatusEnum.ACC.getId());
// ===== ASSERT =====
verify(mockRepository).findById(invoiceId);
verify(mockLedgerService).createLedgerEntry(any(), any(), any());
assertEquals(InvoiceStatusEnum.ACC, invoice.getInvoiceStatus());
}
}
14. Advanced Assertions¶
Assertions.assertAll()¶
Groups multiple assertions together. ALL assertions run, even if some fail.
@Test
public void testMultipleAssertions() {
Claim claim = claimsService.getClaimByUuid(validId);
// Without assertAll - stops at first failure
assertEquals("DE", claim.getCountry().getId());
assertEquals(2024, claim.getFinancialYear());
assertNotNull(claim.getClaimArticle());
// With assertAll - runs all, reports all failures
assertAll("Claim validation",
() -> assertEquals("DE", claim.getCountry().getId()),
() -> assertEquals(2024, claim.getFinancialYear()),
() -> assertNotNull(claim.getClaimArticle()),
() -> assertTrue(claim.getClaimStatus().isActive())
);
}
Output without assertAll (stops at first failure):
AssertionError: Country expected <DE> but got <FR>
Output with assertAll (reports all failures):
Claim validation
AssertionError: Country expected <DE> but got <FR>
AssertionError: Year expected <2024> but got <2023>
AssertionError: Expected non-null value for claimArticle
AssertTimeout()¶
Verifies a method completes within a time limit.
@Test
public void testLookupPerformance() {
// This operation must complete in less than 100ms
assertTimeout(
Duration.ofMillis(100),
() -> {
Country country = lookupService.getCountry("DE"); // Should be cached/fast
assertNotNull(country);
}
);
}
@Test
public void testSlowOperation() {
// This might take longer, but not more than 5 seconds
assertTimeoutPreemptively(
Duration.ofSeconds(5),
() -> invoiceService.processBatchInvoices(1000) // Process 1000 invoices
);
}
assertSame() - Object Identity¶
@Test
public void testObjectIdentity() {
Claim claim1 = createClaim();
Claim claim2 = claim1; // Same reference
Claim claim3 = createClaim(); // Different object
assertSame(claim1, claim2); // ✓ Passes - same object
assertNotSame(claim1, claim3); // ✓ Passes - different objects
}
15. Real-World Example from MIIHC Project¶
Complete Test Suite Example: ClaimsService¶
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@DisplayName("Claims Service Tests")
class ClaimsServiceTest {
private ClaimsService claimsService;
private ClaimRepository mockClaimRepository;
private LookupService mockLookupService;
private InvoiceRepository mockInvoiceRepository;
private ClaimSubmissionService mockClaimSubmissionService;
@BeforeEach
void setUp() {
mockClaimRepository = mock(ClaimRepository.class);
mockLookupService = mock(LookupService.class);
mockInvoiceRepository = mock(InvoiceRepository.class);
mockClaimSubmissionService = mock(ClaimSubmissionService.class);
claimsService = new ClaimsService(
mockClaimRepository,
mockLookupService,
mockInvoiceRepository,
mockClaimSubmissionService
);
}
// ===== BASIC CRUD TESTS =====
@Test
@DisplayName("should create a claim with valid data")
void testCreateClaim_WithValidData_ReturnsSavedClaim() {
// ARRANGE
Claim newClaim = new Claim();
newClaim.setCountry(new Country());
newClaim.setClaimArticle(new ClaimArticle());
newClaim.setClaimSource(new ClaimSource());
Claim savedClaim = new Claim();
savedClaim.setId(UUID.randomUUID());
savedClaim.setCountry(newClaim.getCountry());
ClaimStatus notStartedStatus = new ClaimStatus();
notStartedStatus.setId(ClaimStatusEnum.NOT.getId());
when(mockLookupService.getClaimStatus(ClaimStatusEnum.NOT.getId()))
.thenReturn(notStartedStatus);
when(mockClaimRepository.save(any(Claim.class)))
.thenReturn(savedClaim);
// ACT
Claim result = claimsService.createClaim(newClaim);
// ASSERT
assertNotNull(result);
assertEquals(savedClaim.getId(), result.getId());
assertEquals(notStartedStatus, result.getClaimStatus());
verify(mockClaimRepository).save(newClaim);
verify(mockLookupService).getClaimStatus(ClaimStatusEnum.NOT.getId());
}
@Test
@DisplayName("should throw exception when claim not found")
void testGetClaimByUuid_WithNonExistentId_ThrowsException() {
// ARRANGE
UUID nonExistentId = UUID.randomUUID();
when(mockClaimRepository.findById(nonExistentId))
.thenReturn(Optional.empty());
// ACT & ASSERT
assertThrows(
ClaimNotFoundException.class,
() -> claimsService.getClaimByUuid(nonExistentId)
);
verify(mockClaimRepository).findById(nonExistentId);
}
// ===== PARAMETERIZED TESTS =====
@ParameterizedTest
@ValueSource(ints = { 1, 2, 3, 4 })
@DisplayName("should fetch valid claim articles")
void testGetClaimArticles(int articleId) {
// ARRANGE
ClaimArticle article = new ClaimArticle();
article.setId(articleId);
when(mockLookupService.getClaimArticle(articleId)).thenReturn(article);
// ACT
ClaimArticle result = mockLookupService.getClaimArticle(articleId);
// ASSERT
assertNotNull(result);
assertEquals(articleId, result.getId());
}
// ===== DUPLICATE CHECK TESTS =====
@Test
@DisplayName("should update claims for duplicate check")
void testUpdateClaimForDuplicateCheck_FiltersValidClaims_UpdatesStatus() {
// ARRANGE
String username = "user123";
Claim claim1 = new Claim();
claim1.setId(UUID.randomUUID());
ClaimStatus proStatus = new ClaimStatus();
proStatus.setId(ClaimStatusEnum.PRO.getId());
claim1.setClaimStatus(proStatus);
when(mockClaimRepository.findByClaimStatus(proStatus))
.thenReturn(List.of(claim1));
ClaimSubmission submission = new ClaimSubmission();
submission.setId(UUID.randomUUID());
submission.setClaim(claim1);
submission.setExpectedRecordCount(5);
when(mockClaimSubmissionService.getClaimSubmissionsByClaimIdAndTypeId(
claim1.getId(), ClaimSubmissionTypeEnum.INT.getId()))
.thenReturn(submission);
when(mockInvoiceRepository.existsInvalidInvoice(
submission.getId(), List.of(InvoiceStatusEnum.PRO.getId())))
.thenReturn(List.of());
when(mockInvoiceRepository.countInvoicesByClaimSubmissionId(submission.getId()))
.thenReturn(5); // Expected count matches
when(mockClaimRepository.saveAll(anyList()))
.thenReturn(List.of(claim1));
// ACT
Integer result = claimsService.updateClaimForDuplicateCheck(username);
// ASSERT
assertEquals(1, result);
verify(mockClaimRepository).findByClaimStatus(proStatus);
verify(mockClaimRepository).saveAll(anyList());
}
// ===== EXCEPTION TESTS =====
@Test
@DisplayName("should not throw when updating valid SED version")
void testUpdateSedVersion_WithValidData_DoesNotThrow() {
// ARRANGE
UUID claimId = UUID.randomUUID();
Claim claim = new Claim();
claim.setId(claimId);
String newSedVersion = "2.5";
when(mockClaimRepository.findById(claimId))
.thenReturn(Optional.of(claim));
when(mockClaimRepository.save(any(Claim.class)))
.thenReturn(claim);
// ACT & ASSERT
assertDoesNotThrow(() -> {
claimsService.updateSedVersion(claim, newSedVersion);
});
assertEquals(newSedVersion, claim.getRinaSedVersion());
verify(mockClaimRepository).save(claim);
}
// ===== MULTIPLE ASSERTIONS =====
@Test
@DisplayName("should retrieve complete claim details")
void testGetClaimByUuid_ReturnsCompleteData() {
// ARRANGE
UUID claimId = UUID.randomUUID();
Country country = new Country();
country.setId("DE");
country.setName("Germany");
ClaimArticle article = new ClaimArticle();
article.setId(1);
article.setName("ACTUAL_COST");
Claim claim = new Claim();
claim.setId(claimId);
claim.setCountry(country);
claim.setClaimArticle(article);
claim.setFinancialYear(2024);
when(mockClaimRepository.findById(claimId))
.thenReturn(Optional.of(claim));
// ACT
Claim result = claimsService.getClaimByUuid(claimId);
// ASSERT ALL
assertAll("Claim details",
() -> assertNotNull(result),
() -> assertEquals(claimId, result.getId()),
() -> assertEquals("DE", result.getCountry().getId()),
() -> assertEquals("Germany", result.getCountry().getName()),
() -> assertEquals(1, result.getClaimArticle().getId()),
() -> assertEquals(2024, result.getFinancialYear())
);
}
}
Test Report Output¶
Claims Service Tests
✓ should create a claim with valid data
✓ should throw exception when claim not found
✓ should fetch valid claim articles [1]
✓ should fetch valid claim articles [2]
✓ should fetch valid claim articles [3]
✓ should fetch valid claim articles [4]
✓ should update claims for duplicate check
✓ should not throw when updating valid SED version
✓ should retrieve complete claim details
9 tests passed in 234ms
16. Common Difficulties & Challenges When Mocking Spring Boot Applications¶
Spring Boot introduces complexity that you don't face in pure Node.js testing. Here are the real-world challenges you'll encounter:
Challenge 1: Spring Context Loading (Slow Tests)¶
Problem: Tests load the entire Spring context, which is very slow. You might wait 5-30 seconds per test.
// ❌ BAD - Loads full Spring context (slow!)
@SpringBootTest
public class SlowTestExample {
@Autowired
private ClaimsService claimsService;
@Autowired
private InvoiceService invoiceService;
@Test
public void testCreateClaim() {
// Test runs, but took 5+ seconds just to load context
}
}
Output:
Starting Spring Boot Application (5 seconds to load context)
✓ testCreateClaim (0.5 seconds actual test)
Total: 5.5 seconds for ONE test!
Solution 1: Use Unit Tests (No Spring Context)
// ✅ GOOD - No Spring context, super fast
public class FastUnitTest {
private ClaimsService claimsService;
private ClaimRepository mockRepository;
private LookupService mockLookupService;
@BeforeEach
public void setup() {
// No @SpringBootTest - just plain mocks
mockRepository = mock(ClaimRepository.class);
mockLookupService = mock(LookupService.class);
claimsService = new ClaimsService(mockRepository, mockLookupService);
}
@Test
public void testCreateClaim() {
// Test runs instantly (< 100ms)
}
}
Output:
✓ testCreateClaim (0.08 seconds)
Total: 0.08 seconds!
Solution 2: Use Test Slices for Integration Tests
// ✅ BETTER - Only loads Spring Web layer (faster than full context)
@WebMvcTest(ClaimServiceApiResponseDelegate.class)
public class ClaimControllerTest {
@MockBean // Mock the service
private ClaimsService claimsService;
@Autowired
private MockMvc mockMvc; // Spring provides this
@Test
public void testGetClaimEndpoint() throws Exception {
when(claimsService.getClaimByUuid(any(UUID.class)))
.thenReturn(new Claim());
mockMvc.perform(get("/v1/claims/{id}", UUID.randomUUID()))
.andExpect(status().isOk());
}
}
Time comparison: - @SpringBootTest: 5-10 seconds (full app) - @WebMvcTest: 1-2 seconds (only web layer) - Unit test (no Spring): 0.1 seconds
Challenge 2: Autowiring Issues - Field vs Constructor Injection¶
Problem: Spring uses different injection mechanisms. Mocking one but not the other causes confusion.
// ❌ CONFUSING - Field injection in service
@Service
public class ClaimsService {
@Autowired // Field injection - harder to mock in tests
private ClaimRepository claimRepository;
@Autowired
private LookupService lookupService;
public Claim createClaim(Claim claim) {
// If mock isn't injected, this uses the REAL repository!
}
}
// Test that DOESN'T mock properly
@SpringBootTest
public class BadMockingExample {
@Autowired
private ClaimsService claimsService;
@MockBean // This might not override field injection
private ClaimRepository mockRepository;
@Test
public void testCreateClaim() {
// Problem: mockRepository might not be used!
// The autowired field might still use the real repository
}
}
Solution: Constructor Injection (Best Practice)
// ✅ GOOD - Constructor injection (used in this MIIHC project)
@Service
@RequiredArgsConstructor // Lombok generates constructor
public class ClaimsService {
private final ClaimRepository claimRepository; // Final field
private final LookupService lookupService; // Final field
public Claim createClaim(Claim claim) {
// Dependencies are immutable and always injected
}
}
// Unit test with constructor injection
public class GoodMockingExample {
private ClaimsService claimsService;
private ClaimRepository mockRepository;
private LookupService mockLookupService;
@BeforeEach
public void setup() {
mockRepository = mock(ClaimRepository.class);
mockLookupService = mock(LookupService.class);
// Explicitly inject mocks via constructor
claimsService = new ClaimsService(mockRepository, mockLookupService);
}
@Test
public void testCreateClaim() {
// Guaranteed: mocks are injected
}
}
Challenge 3: @Transactional Behavior - Implicit Rollbacks¶
Problem: @Transactional on test methods causes automatic rollbacks, hiding real issues.
@SpringBootTest
public class TransactionRollbackProblem {
@Autowired
private ClaimRepository claimRepository;
@Transactional // ⚠️ Dangerous on test methods!
@Test
public void testSaveAndRetrieveClaim() {
Claim claim = new Claim();
claim.setId(UUID.randomUUID());
claimRepository.save(claim); // Saved in-memory
Claim retrieved = claimRepository.findById(claim.getId()).get();
assertNotNull(retrieved); // Passes!
// But when test method ends, @Transactional ROLLBACKS everything
// So in real app, the claim was never actually saved!
}
}
The transaction rollback means the test passes but the code is broken in production!
Solution: Don't use @Transactional on Tests
@SpringBootTest
public class ProperTransactionTest {
@Autowired
private ClaimRepository claimRepository;
@BeforeEach
public void setup() {
claimRepository.deleteAll(); // Manual cleanup
}
@Test // NO @Transactional
public void testSaveAndRetrieveClaim() {
Claim claim = new Claim();
claim.setId(UUID.randomUUID());
claimRepository.save(claim);
Claim retrieved = claimRepository.findById(claim.getId()).get();
assertNotNull(retrieved); // Really saved!
}
@AfterEach
public void cleanup() {
claimRepository.deleteAll();
}
}
Challenge 4: Proxy/AOP Issues - Mocking Methods That Are Intercepted¶
Problem: Spring uses proxies for @Transactional, @Cacheable, @Retry, etc. When you mock a method, the proxy might not intercept it.
// MIIHC Project uses @Cacheable
@Service
public class LookupService {
@Cacheable(value = "invoiceStatusCache", key = "#invoiceStatusID")
public InvoiceStatus getInvoiceStatus(int invoiceStatusID) {
// First call: loads from DB
// Second call: returns from cache
}
}
// ❌ Problem: Mocking doesn't respect cache
@Test
public void testCacheNotWorking() {
LookupService mockService = mock(LookupService.class);
// Mock doesn't know about @Cacheable!
when(mockService.getInvoiceStatus(1)).thenReturn(status);
LookupService result1 = mockService.getInvoiceStatus(1);
LookupService result2 = mockService.getInvoiceStatus(1);
// Both calls hit the mock, cache was never used
verify(mockService, times(2)).getInvoiceStatus(1); // Called TWICE!
}
Solution: Use Spy + Real Object for Partial Mocking
@SpringBootTest
public class CacheTest {
@Autowired
private LookupService lookupService; // Real Spring-managed bean
@Test
public void testCacheWorks() {
// First call: loads from DB
InvoiceStatus result1 = lookupService.getInvoiceStatus(1);
// Second call: returns from cache (decorator intercepted by Spring)
InvoiceStatus result2 = lookupService.getInvoiceStatus(1);
// Both are the same object (cache worked)
assertSame(result1, result2);
}
}
Challenge 5: MockBean vs @Mock Confusion¶
Problem: Spring provides @MockBean, but JUnit/Mockito provide @Mock. They work differently!
// ❌ WRONG - Mixing annotations
@SpringBootTest
public class ConfusingMockExample {
@Mock // JUnit/Mockito annotation (not registered in Spring!)
private ClaimRepository mockRepository;
@Autowired
private ClaimsService claimsService;
@Test
public void testCreateClaim() {
// Problem: mockRepository is NOT in Spring context
// claimsService gets the REAL ClaimRepository from Spring!
// @Mock only affects Mockito, not Spring DI
}
}
// ✅ CORRECT - Use @MockBean in Spring tests
@SpringBootTest
public class CorrectMockExample {
@MockBean // Spring annotation - replaces bean in context
private ClaimRepository mockRepository;
@Autowired
private ClaimsService claimsService; // Gets mock from Spring
@Test
public void testCreateClaim() {
// Now claimsService receives the mocked repository
when(mockRepository.findById(any())).thenReturn(Optional.empty());
}
}
// ✅ BETTER - Use @Mock in unit tests (no Spring)
public class UnitTestExample {
@Mock // Works fine without Spring
private ClaimRepository mockRepository;
private ClaimsService claimsService;
@BeforeEach
public void setup() {
MockitoAnnotations.openMocks(this); // Initialize @Mock
claimsService = new ClaimsService(mockRepository); // Manual injection
}
}
When to use what: - @MockBean → @SpringBootTest, @WebMvcTest, integration tests - @Mock → Unit tests, no Spring context - mock() → When you need fine control in any test
Challenge 6: State Machine Testing - Mocking Complex Behavior¶
Problem: MIIHC uses custom state machines. Mocking them is tricky because they have complex behavior.
// MIIHC's StateMachine - hard to mock
public record StateMachine<S extends Enum<S>, A>(
Map<S, List<Transition<S, A>>> transitionsBySource,
Function<A, S> stateExtractor) {
public void fire(S target, TransitionContext<A> context) {
// Complex logic: find transition, validate guard, execute action
}
}
// ❌ PROBLEM - Trying to mock the state machine
@Test
public void testInvoiceStateTransition() {
StateMachine<InvoiceStatusEnum, Invoice> mockStateMachine =
mock(StateMachine.class);
when(mockStateMachine.fire(any(), any())).thenReturn(null);
// Problem: State machine actually changes invoice status internally
// Mocking it means we're NOT testing the real state change logic!
}
Solution: Test with Real State Machine, Mock Dependencies
// ✅ GOOD - Test real state machine logic
@Test
public void testInvoiceStateTransition() {
// Create real state machine (from config)
StateMachine<InvoiceStatusEnum, Invoice> stateMachine =
createInvoiceStateMachine();
// Mock only the dependencies (repository, services)
InvoiceRepository mockRepo = mock(InvoiceRepository.class);
InvoiceService service = new InvoiceService(mockRepo, stateMachine);
Invoice invoice = new Invoice();
invoice.setInvoiceStatus(InvoiceStatusEnum.PRO);
// Test real state machine behavior
stateMachine.fire(InvoiceStatusEnum.ACC, new TransitionContext<>(invoice, "user"));
// Verify state actually changed
assertEquals(InvoiceStatusEnum.ACC, invoice.getInvoiceStatus());
}
Challenge 7: JPA/Lazy Loading - N+1 Query Problems in Tests¶
Problem: Test passes locally but fails in production due to lazy loading issues.
// ❌ PROBLEM - Lazy loading works in test but fails in production
@SpringBootTest
public class LazyLoadingProblem {
@Autowired
private InvoiceRepository invoiceRepository;
@Test
public void testInvoiceLazyLoading() {
Invoice invoice = invoiceRepository.findById(invoiceId).get();
// In test: transaction is open, lazy loading works
ClaimSubmission submission = invoice.getClaimSubmission(); // ✓ Loads
assertNotNull(submission); // Passes!
}
}
// But in production:
public class InvoiceService {
public void processInvoice(UUID invoiceId) {
Invoice invoice = invoiceRepository.findById(invoiceId).get(); // Get in transaction
// Transaction ENDS here
// This happens OUTSIDE transaction - LAZY LOADING FAILS!
ClaimSubmission submission = invoice.getClaimSubmission(); // ❌ LazyInitializationException!
}
}
Solution: Explicitly Fetch or Use DTO
// ✅ GOOD - Explicitly fetch relationships
public interface InvoiceRepository extends JpaRepository<Invoice, UUID> {
@Query("SELECT i FROM Invoice i " +
"LEFT JOIN FETCH i.claimSubmission " +
"LEFT JOIN FETCH i.entitlement " +
"WHERE i.id = :id")
Optional<Invoice> findByIdWithDetails(UUID id);
}
@Test
public void testInvoiceWithDetails() {
Invoice invoice = invoiceRepository.findByIdWithDetails(invoiceId).get();
// Now it works reliably
ClaimSubmission submission = invoice.getClaimSubmission();
assertNotNull(submission);
}
// Alternative: Use DTO
public record InvoiceDetailDTO(
UUID invoiceId,
String individualReference,
String claimSubmissionId // Just IDs, not relationships
) {}
@Query("SELECT new com.example.InvoiceDetailDTO(...) " +
"FROM Invoice i WHERE i.id = :id")
InvoiceDetailDTO findDetailsById(UUID id);
Challenge 8: Event Listeners & Async - Testing @Transactional + @Async¶
Problem: MIIHC uses @TransactionalEventListener with @Async. Hard to test because of timing.
// MIIHC uses this pattern
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleSubmissionOpeningEvent(SubmissionOpeningEvent event) {
// Runs AFTER transaction commits, in separate thread
}
// ❌ PROBLEM - Test runs too fast
@Test
public void testEventListenerIsNotCalled() throws InterruptedException {
applicationEventPublisher.publishEvent(new SubmissionOpeningEvent(...));
// Assertion runs immediately, but listener runs ASYNC
// Test finishes before listener even starts!
}
Solution: Use CountDownLatch for Async Verification
// ✅ GOOD - Wait for async event
@SpringBootTest
public class EventListenerTest {
private CountDownLatch latch = new CountDownLatch(1);
private boolean eventProcessed = false;
@Autowired
private ApplicationEventPublisher eventPublisher;
// Spy on the event listener
@SpyBean
private SubmissionOpeningEventListener eventListener;
@Test
public void testEventListenerIsCalled() throws InterruptedException {
// Publish event
eventPublisher.publishEvent(new SubmissionOpeningEvent(...));
// Wait for async listener (max 5 seconds)
boolean processed = latch.await(5, TimeUnit.SECONDS);
// Verify listener was called
verify(eventListener).handleSubmissionOpeningEvent(any());
assertTrue(processed);
}
}
// Alternative: Use @Transactional(propagation = REQUIRES_NEW)
@SpringBootTest
public class TransactionEventTest {
@Autowired
private ApplicationEventPublisher eventPublisher;
@Transactional
@Test
public void testEventWithinTransaction() {
ClaimSubmission submission = createSubmission();
submissionRepository.save(submission);
// Event is published and processed synchronously in test
eventPublisher.publishEvent(new SubmissionOpeningEvent(submission.getId()));
// Listener runs immediately (still in transaction)
}
}
Challenge 9: Test Data Setup - Managing Complex Entity Graphs¶
Problem: MIIHC has complex relationships. Setting up test data is tedious.
// ❌ TEDIOUS - Manual setup
@Test
public void testComplexClaim() {
Country country = new Country();
country.setId("DE");
ClaimArticle article = new ClaimArticle();
article.setId(1);
ClaimSource source = new ClaimSource();
source.setId(2);
ClaimStatus status = new ClaimStatus();
status.setId(1);
Claim claim = new Claim();
claim.setCountry(country);
claim.setClaimArticle(article);
claim.setClaimSource(source);
claim.setClaimStatus(status);
// ... 50 more lines of setup
}
Solution: Use Test Builders or Factories
// ✅ GOOD - Builder pattern
public class ClaimTestBuilder {
private Claim claim = new Claim();
public ClaimTestBuilder withCountry(String countryId) {
claim.setCountry(new Country(countryId));
return this;
}
public ClaimTestBuilder withArticle(int articleId) {
claim.setClaimArticle(new ClaimArticle(articleId));
return this;
}
public Claim build() {
return claim;
}
}
// Usage
@Test
public void testComplexClaim() {
Claim claim = new ClaimTestBuilder()
.withCountry("DE")
.withArticle(1)
.build();
// Much cleaner!
}
// Alternative: Use @Bean in test config
@TestConfiguration
public class TestDataConfig {
@Bean
public Country germany() {
return new Country("DE", "Germany");
}
@Bean
public ClaimArticle actualCostArticle() {
return new ClaimArticle(1, "ACTUAL_COST");
}
}
@SpringBootTest
public class ClaimTest {
@Autowired
private Country germany;
@Autowired
private ClaimArticle actualCostArticle;
@Test
public void testClaim() {
Claim claim = new Claim();
claim.setCountry(germany);
claim.setClaimArticle(actualCostArticle);
}
}
Challenge 10: Testing Private Methods - Should You?¶
Problem: Spring services have private methods. Should you test them?
@Service
public class ContestedRecordService {
// Private helper method
private boolean isValidContestation(Invoice invoice, BigDecimal amount) {
return invoice.getCostClaimAmount().compareTo(amount) >= 0;
}
public void checkInvoiceIndividualReference(Invoice invoice, BigDecimal contested) {
if (isValidContestation(invoice, contested)) {
// Process...
}
}
}
// ❌ TEMPTATION - Use reflection to test private method
@Test
public void testPrivateMethod() throws Exception {
Method method = ContestedRecordService.class
.getDeclaredMethod("isValidContestation", Invoice.class, BigDecimal.class);
method.setAccessible(true);
Boolean result = (Boolean) method.invoke(service, invoice, amount);
assertTrue(result);
}
Don't do this! Test private methods indirectly through public methods.
// ✅ GOOD - Test private method via public API
@Test
public void testContestedRecordIsNotCreatedForInvalidAmount() {
Invoice invoice = new Invoice();
invoice.setCostClaimAmount(BigDecimal.valueOf(100));
BigDecimal contestedAmount = BigDecimal.valueOf(200); // Larger than claimed
// Call public method, which uses private isValidContestation()
service.checkInvoiceIndividualReference(invoice, contestedAmount);
// Verify private logic worked by checking public results
verify(mockRepository, never()).save(any()); // No contested record created
}
Spring Boot Testing Best Practices¶
| Do | Don't |
|---|---|
| Use unit tests (no Spring) for service logic | Don't load full @SpringBootTest unless necessary |
Use @WebMvcTest for controller tests | Don't use @SpringBootTest for everything |
| Use constructor injection | Don't use field @Autowired |
| Mock external dependencies | Don't mock everything (mock fakes are unrealistic) |
| Test via public API | Don't test private methods with reflection |
Use @MockBean in Spring tests | Don't mix @Mock with @SpringBootTest |
Avoid @Transactional on tests | Don't rely on automatic rollbacks |
| Test real state machines | Don't mock complex business logic |
| Explicitly fetch lazy relationships | Don't rely on lazy loading in tests |
| Wait for async events (CountDownLatch) | Don't run assertions before async completes |
Quick Reference: Common Assertions¶
| Assertion | Usage |
|---|---|
assertEquals(expected, actual) | Check equality |
assertNotEquals(unexpected, actual) | Check inequality |
assertTrue(condition) | Check boolean is true |
assertFalse(condition) | Check boolean is false |
assertNull(value) | Check value is null |
assertNotNull(value) | Check value is not null |
assertSame(expected, actual) | Check object identity (==) |
assertNotSame(unexpected, actual) | Check not same instance |
assertThrows(Exception.class, () -> {...}) | Verify exception thrown |
assertDoesNotThrow(() -> {...}) | Verify no exception thrown |
assertTimeout(Duration, () -> {...}) | Verify completes in time |
assertAll("group", () -> {...}, () -> {...}) | Group multiple assertions |
Quick Reference: Mockito Methods¶
| Method | Purpose |
|---|---|
mock(Class.class) | Create a mock object |
spy(object) | Create a spy (partial mock) |
when(mock.method()).thenReturn(value) | Define mock behavior |
doThrow(Exception.class).when(mock).method() | Make mock throw exception |
verify(mock).method() | Verify method was called |
verify(mock, times(n)).method() | Verify called n times |
verify(mock, never()).method() | Verify never called |
verify(mock, atLeast(n)).method() | Verify called at least n times |
any(), any(Class.class) | Match any argument |
eq(value) | Match specific argument value |
argumentCaptor.capture() | Capture argument values |
Node.js Comparison Table¶
| Java JUnit | Node.js Jest |
|---|---|
@Test | test() or it() |
@BeforeAll | beforeAll() |
@BeforeEach | beforeEach() |
@AfterEach | afterEach() |
@AfterAll | afterAll() |
assertEquals() | expect().toBe() |
assertTrue() | expect().toBe(true) |
assertThrows() | expect().toThrow() |
mock() | jest.fn() |
when().thenReturn() | mockFn.mockReturnValue() |
verify() | expect(mockFn).toHaveBeenCalled() |
@DisplayName | test('description', ...) |
@ParametrizedTest | test.each() |
@RepeatedTest | test.each(Array(n).fill()) |
Best Practices Summary¶
✅ DO: - Use AAA pattern (Arrange-Act-Assert) - Write descriptive test names (follow convention) - Use @DisplayName for readability - Keep tests independent (no test interdependencies) - Test one thing per test - Use parameterized tests for multiple inputs - Mock external dependencies - Use @BeforeEach for setup - Verify mocks to ensure correct interactions - Use assertAll() for multiple assertions
❌ DON'T: - Write tests that depend on other tests - Test implementation details (test behavior instead) - Create shared state between tests (use @BeforeEach) - Mock objects you don't own (prefer fakes) - Test multiple scenarios in one test - Skip @DisplayName for clarity - Ignore test failures (fix them immediately) - Write tests that pass for the wrong reasons
💡 Pro Tip: Follow the Given-When-Then pattern for BDD-style tests: - Given (setup state) - When (action taken) - Then (expected outcome)