Skip to content

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

  1. Basic Test Structure
  2. Assertion Methods
  3. AAA Pattern (Arrange-Act-Assert)
  4. Test Naming Convention
  5. Display Names
  6. Lifecycle Methods
  7. Test Instance Behavior
  8. Disabling Tests
  9. Exception Testing
  10. Parameterized Tests
  11. Repeated Tests
  12. Test Method Ordering
  13. Test Doubles (Mocks, Stubs, Fakes, Spies)
  14. Advanced Assertions
  15. Real-World Example from MIIHC Project
  16. 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:

  1. Arrange (Given) — Set up test data and mocks
  2. Act (When) — Execute the method being tested
  3. 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)