Skip to content

Testing Standards

Puneethkumar CK edited this page Mar 17, 2026 · 1 revision

Testing Standards

StableBridge uses a four-tier testing strategy with 3,500+ tests across 10 services.


Test Pyramid

          ╱╲
         ╱  ╲        Business Tests (~50)
        ╱    ╲       Full server + WireMock + DB
       ╱──────╲
      ╱        ╲     Integration Tests (~200)
     ╱          ╲    Spring context + Testcontainers + MockMvc
    ╱────────────╲
   ╱              ╲   Unit Tests (~3000+)
  ╱                ╲  Pure Mockito, no Spring context
 ╱──────────────────╲
       ArchUnit       Architecture boundary enforcement

Test Source Sets

Source Set Directory Scope Infrastructure
Unit src/test/ Single class, mocked dependencies Mockito (BDD), AssertJ
Test Fixtures src/testFixtures/ Shared factories, utilities N/A
Integration src/integration-test/ Spring context, real DB, WireMock Testcontainers (PostgreSQL)
Business src/business-test/ Full E2E flows, real server Full server + WireMock

1. Test Naming

Standard: should* in camelCase

void shouldActivateMerchant()
void shouldThrowWhenMerchantNotFound()
void shouldReturnLockedRateWithMargin()

2. Test Structure: Given / When / Then

Every test follows GWT with explicit comment markers:

@Test
void shouldActivateMerchant() {
    // given
    var merchant = MerchantFixtures.pendingApprovalMerchant();
    given(merchantRepository.findById(merchant.getMerchantId()))
        .willReturn(Optional.of(merchant));

    // when
    handler.activate(merchant.getMerchantId(), approver, scopes);

    // then
    then(merchantRepository).should().save(eqIgnoringTimestamps(expectedMerchant));
}

For exceptions, // when and // then are combined:

@Test
void shouldThrowWhenNotFound() {
    // given
    given(repo.findById(id)).willReturn(Optional.empty());

    // when/then
    assertThatThrownBy(() -> handler.activate(id))
        .isInstanceOf(MerchantNotFoundException.class);
}

3. Mocking: BDDMockito Exclusively

BDD Style (REQUIRED) Standard Style (FORBIDDEN)
given(...).willReturn(...) when(...).thenReturn(...)
then(...).should() verify(...)
then(...).should(never()) verify(..., never())

4. No Generic Argument Matchers (MANDATORY)

Never use any(), anyString(), anyLong(), eq(), or similar generic Mockito matchers. Always pass actual values.

// BAD — generic matchers give zero confidence
given(repo.findById(any())).willReturn(Optional.of(merchant));          // ❌
then(repo).should().save(any(Merchant.class));                           // ❌

// GOOD — actual values; test fails if arguments don't match
given(repo.findById(merchantId)).willReturn(Optional.of(merchant));     // ✅
then(repo).should().save(eqIgnoringTimestamps(expectedMerchant));       // ✅

Allowed custom matchers (verify content, not bypass matching):

  • eqIgnoringTimestamps(expected) — recursive comparison ignoring timestamp types
  • eqIgnoring(expected, "field1", "field2") — recursive comparison ignoring specific fields

5. Test Fixtures (MANDATORY)

All test helper factory methods MUST live in src/testFixtures/java/.../fixtures/*Fixtures.java — one per aggregate. Never leave as private methods in test classes.

// src/testFixtures/java/.../fixtures/MerchantFixtures.java
public final class MerchantFixtures {
    private MerchantFixtures() {}

    public static Merchant aPendingMerchant() {
        return Merchant.createNew("Acme Ltd", ...);
    }

    public static Merchant anActiveMerchant() {
        var merchant = aPendingMerchant();
        merchant.activate(anApprover(), defaultScopes());
        return merchant;
    }
}

Rules:

  • One fixture class per aggregate/entity
  • public final class with private constructor
  • All factory methods are public static — callers use static imports
  • Methods needing Spring beans stay in the test class (only pure factories go to testFixtures)

6. Single-Assert Test Style (MANDATORY)

Domain Model Tests

Build an expected object and use a single assertThat with usingRecursiveComparison():

@Test
void shouldTransitionToLockedStatus() {
    // given
    var quote = FxQuoteFixtures.activeQuote();

    // when
    var result = quote.lock();

    // then
    var expected = quote.toBuilder().status(LOCKED).build();
    assertThat(result)
        .usingRecursiveComparison()
        .ignoringFields("lockedAt")
        .isEqualTo(expected);
}

Anti-pattern — scattered field asserts:

// BAD — multiple individual asserts
assertThat(result.status()).isEqualTo(LOCKED);      // ❌
assertThat(result.paymentId()).isEqualTo(paymentId); // ❌
assertThat(result.amount()).isEqualTo(amount);       // ❌

Handler Tests (Interaction Verification)

Verify interactions only — use then(repo).should().save(expected), NOT assertThat on return values:

@Test
void shouldActivateMerchant() {
    // given
    var merchant = MerchantFixtures.pendingApprovalMerchant();
    given(merchantRepository.findById(merchant.getMerchantId()))
        .willReturn(Optional.of(merchant));

    var expectedMerchant = merchant.toBuilder().build();
    expectedMerchant.activate(approver, scopes);

    given(merchantRepository.save(eqIgnoringTimestamps(expectedMerchant)))
        .willAnswer(inv -> inv.getArgument(0));

    // when
    handler.activate(merchant.getMerchantId(), approver, scopes);

    // then
    then(merchantRepository).should().save(eqIgnoringTimestamps(expectedMerchant));
    then(eventPublisher).should().publish(eqIgnoring(expectedEvent, "eventId"));
}

When Individual Asserts ARE Acceptable

  • Exception tests: assertThatThrownBy(...).isInstanceOf(...)
  • Boolean checks: assertThat(pool.isBelowThreshold()).isTrue()
  • Collection checks: assertThat(results).hasSize(3).containsOnly(...)
  • Parameterized enum mapping: assertThat(RiskBand.forScore(score)).isEqualTo(expected)

7. TestUtils (Every Service)

Every service must have TestUtils.java in testFixtures:

public final class TestUtils {
    private TestUtils() {}

    public static <T> T eqIgnoringTimestamps(T expected) {
        return eqIgnoring(expected);
    }

    public static <T> T eqIgnoring(T expected, String... fieldsToIgnore) {
        return argThat(it -> {
            try {
                assertThat(it)
                    .usingRecursiveComparison()
                    .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class,
                                           LocalDate.class, Instant.class)
                    .ignoringFields(fieldsToIgnore)
                    .isEqualTo(expected);
                return true;
            } catch (Throwable t) {
                return false;
            }
        });
    }
}

8. Integration Test Setup

  • Base class: AbstractIntegrationTest with Testcontainers PostgreSQL singleton
  • Profile: @ActiveProfiles("integration-test")
  • Security: Set app.security.enabled=false to bypass auth in tests
  • Temporal: Excluded via spring.autoconfigure.exclude

9. Architecture Tests

Every service includes ArchitectureTest.java with 5 ArchUnit rules enforcing hexagonal architecture. See Architecture Overview for details.


10. Quality Gates

Gate Tool Enforcement
Architecture boundaries ArchUnit CI — fails build
Code formatting Spotless (Google Java Format) CI — spotlessCheck
Test coverage JaCoCo (unit tests only) CI — minimum thresholds
Static analysis SonarCloud PR — quality gate
Dependency vulnerabilities OWASP Dependency Check CI — block on critical

Quick Reference

What to Test Test Type Annotation
Domain logic, handlers Unit @ExtendWith(MockitoExtension.class)
MapStruct mappers Unit Plain JUnit
Controller HTTP behavior Integration @AutoConfigureMockMvc
JPA repository queries Integration @Transactional
Architecture layers ArchUnit @AnalyzeClasses
Full payment flows Business @SpringBootTest(DEFINED_PORT)

Related Pages

Clone this wiki locally