-
Notifications
You must be signed in to change notification settings - Fork 0
Testing Standards
StableBridge uses a four-tier testing strategy with 3,500+ tests across 10 services.
╱╲
╱ ╲ 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
| 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 |
Standard: should* in camelCase
void shouldActivateMerchant()
void shouldThrowWhenMerchantNotFound()
void shouldReturnLockedRateWithMargin()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);
}| BDD Style (REQUIRED) | Standard Style (FORBIDDEN) |
|---|---|
given(...).willReturn(...) |
when(...).thenReturn(...) |
then(...).should() |
verify(...) |
then(...).should(never()) |
verify(..., never()) |
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
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 finalclass withprivateconstructor - 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)
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); // ❌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"));
}- 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)
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;
}
});
}
}-
Base class:
AbstractIntegrationTestwith Testcontainers PostgreSQL singleton -
Profile:
@ActiveProfiles("integration-test") -
Security: Set
app.security.enabled=falseto bypass auth in tests -
Temporal: Excluded via
spring.autoconfigure.exclude
Every service includes ArchitectureTest.java with 5 ArchUnit rules enforcing hexagonal architecture. See Architecture Overview for details.
| 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 |
| 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) |
- Coding Standards — Code style conventions
- Design Patterns — Testing-related patterns
- Project Structure — Test source set layout
StableBridge Platform | Source Code | CI/CD | Built with Java 25 + Spring Boot 4 + Temporal + Kafka + Base L2
StableBridge Platform
Architecture & Design
Development
- Getting Started
- Project Structure
- Coding Standards
- Testing Standards
- Database Conventions
- Event Driven Architecture
Operations & Security
Project
Reference