Testing Strategy
Overview
All v2 services must follow a test pyramid approach. Testing is a core engineering discipline — every code change must include corresponding test updates regardless of the domain (payments, customers, merchants, reporting, etc.).
This document defines the cross-domain testing standards. Domain-specific test scenarios are documented within each domain guide.
Test Pyramid
| Layer | Quantity | Speed | What It Tests | Required For |
|---|---|---|---|---|
| Unit | Many (≥80% coverage) | Fast (ms) | Business logic, state transitions, validators, mappers, error mapping | Every PR |
| Contract | Moderate | Fast | Request/response schema compliance with OpenAPI spec | Every PR |
| Integration | Moderate | Medium (s) | Service layer + real database (testcontainers) + mocked external services | Every PR |
| E2E | Few | Slow (min) | Full flow against staging environment with real external dependencies | Release gate |
Unit Test Standards
Unit tests must cover every public method in the service, adapter, and validator layers.
What to Test
| Component Type | What to Cover |
|---|---|
| Service methods | Happy path, state transitions, business rule enforcement, edge cases |
| Validators | Every field constraint, cross-field rules, boundary values |
| Error mappers | External error → internal ErrorGroup mapping, Problem DTO construction |
| Event/webhook builders | Correct event name, payload field mapping from domain model |
| Adapter/client wrappers | Request construction, response parsing, error translation |
What NOT to Unit Test
| Skip | Why |
|---|---|
| Getter/setter boilerplate | Lombok-generated; no logic to verify |
| Framework wiring | Covered by integration tests |
| Database queries | Covered by integration tests with testcontainers |
Integration Test Standards
Integration tests verify the service layer works correctly with real infrastructure (database) and mocked external dependencies.
Setup Requirements
| Component | Approach |
|---|---|
| Database | Testcontainers (PostgreSQL) — real schema, real queries |
| External APIs (Stripe, etc.) | WireMock or similar — deterministic, no network dependency |
| Message brokers | Testcontainers or in-memory alternatives |
| Other internal services | Mocked via WireMock or stub |
Standard Scenarios Every Domain Must Cover
| Scenario | Description |
|---|---|
| Happy path — create | Create a resource → verify persisted state in database |
| Happy path — read | Retrieve a resource → verify response structure |
| Validation rejection | Submit invalid request → verify 400 response |
| Business rule rejection | Submit valid but disallowed request → verify 422 response |
| External dependency failure | Mock external service returning error → verify graceful handling |
| Idempotency | Submit same idempotency key twice → verify no duplicate created |
| Not found | Request non-existent resource → verify 404 response |
| Authorization | Request with wrong merchant → verify 403 response |
Contract Test Standards
Contract tests verify that API requests and responses conform to the OpenAPI specification.
| What | How |
|---|---|
| Request schemas | Validate sample payloads against openapi/v2/schemas/{domain}/request/*.yaml |
| Response schemas | Validate actual responses from integration tests against response schemas |
| Webhook schemas | Validate webhook payloads against openapi/v2/schemas/{domain}/webhook/*.yaml |
| Error schemas | Validate error responses against openapi/v2/schemas/common/error/*.yaml |
E2E Test Standards
E2E tests run against the staging environment with real external dependencies (Stripe in test mode, real database, etc.).
| Requirement | Standard |
|---|---|
| Environment | Staging (X-Upstream-Env: stage) |
| Scope | Cover the primary happy path and the most critical failure path per domain |
| Data isolation | Use dedicated test merchant and customer identifiers |
| Cleanup | Tests must clean up created resources or use short-lived test data |
| Stability | E2E tests must not be flaky; disable or fix within 24 hours if intermittent |
Test Naming Convention
All v2 services must follow this naming pattern:
test_<methodUnderTest>_<scenario>_<expectedResult>
Examples (domain-agnostic):
test_create_validRequest_returnsAccepted
test_create_duplicateIdempotencyKey_returnsExistingResource
test_create_missingRequiredField_returnsBadRequest
test_getById_nonExistentId_returnsNotFound
test_getById_wrongMerchant_returnsForbidden
test_update_invalidStateTransition_returnsUnprocessableEntity
Required Tests for Every PR
Every PR that modifies v2 service logic must include:
- Unit test for the happy path of the changed flow
- Unit test for at least 2 error/edge cases
- Integration test if service layer or repository logic changed
- Contract test if request/response structure changed
- Webhook/event payload test if terminal states or event emission affected
- Existing tests pass — no test left broken
Code Examples
Validator Unit Tests
Test every constraint including boundary values and cross-field rules:
@ExtendWith(MockitoExtension.class)
class PaymentRequestValidatorTest {
private static Validator validator;
@BeforeAll
static void setup() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
validator = factory.getValidator();
}
}
@Nested
@DisplayName("Amount validation")
class AmountValidationTests {
@Test
@DisplayName("valid amount — passes")
void validAmount_passes() {
var request = validPaymentRequest().amount(BigDecimal.valueOf(10000)).build();
assertThat(validator.validate(request)).isEmpty();
}
@Test
@DisplayName("amount = 0 — fails")
void zeroAmount_fails() {
var request = validPaymentRequest().amount(BigDecimal.ZERO).build();
var violations = validator.validate(request);
assertThat(violations).hasSize(1);
assertThat(violations.iterator().next().getMessage())
.contains("must be an integer between 1 and 99999999");
}
@Test
@DisplayName("amount with decimal fraction — fails")
void decimalAmount_fails() {
var request = validPaymentRequest().amount(new BigDecimal("100.50")).build();
assertThat(validator.validate(request)).isNotEmpty();
}
}
}
Controller Tests (@WebFluxTest)
Test the full HTTP contract including headers, validation errors, and error envelope shapes:
@WebFluxTest(PaymentControllerV2.class)
class PaymentsControllerV2Test {
@Autowired
private WebTestClient webTestClient;
@MockBean
private TransactionService transactionService;
@Nested
@DisplayName("POST /v2/payments")
class CreatePaymentTests {
@Test
@DisplayName("valid request — returns 202 Accepted")
void validRequest_returns202() {
when(transactionService.createTransaction(any(), any(), any(), any(), any()))
.thenReturn(Mono.just(aPaymentResponse()));
webTestClient.post().uri("/v2/payments")
.header("X-Merchant-Id", UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(validPaymentRequestJson())
.exchange()
.expectStatus().isAccepted()
.expectBody()
.jsonPath("$.data.id").isNotEmpty()
.jsonPath("$.data.status").isEqualTo("PENDING");
}
@Test
@DisplayName("missing merchantTransactionId — returns 400")
void missingMerchantTransactionId_returns400() {
webTestClient.post().uri("/v2/payments")
.header("X-Merchant-Id", UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(paymentRequestWithout("merchantTransactionId"))
.exchange()
.expectStatus().isBadRequest()
.expectBody()
.jsonPath("$.title").isEqualTo("INVALID_REQUEST");
}
@Test
@DisplayName("processor decline — returns 422 with allocation error detail")
void processorDecline_returns422WithAllocationErrors() {
when(transactionService.createTransaction(any(), any(), any(), any(), any()))
.thenReturn(Mono.error(new PaymentFailedException(
"Payment processing failed", buildFailedPaymentResponse())));
webTestClient.post().uri("/v2/payments")
.header("X-Merchant-Id", UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(validPaymentRequestJson())
.exchange()
.expectStatus().isEqualTo(422)
.expectBody()
.jsonPath("$.title").isEqualTo("PAYMENT_ERROR")
.jsonPath("$.data.paymentAllocations[0].status").isEqualTo("FAILED")
.jsonPath("$.data.paymentAllocations[0].error.code").isNotEmpty();
}
}
}
Test Data Builders
Use builder factories to reduce duplication and keep tests readable:
// In PaymentTestFixtures.java
public class PaymentTestFixtures {
public static PaymentRequest.PaymentRequestBuilder validPaymentRequest() {
return PaymentRequest.builder()
.amount(BigDecimal.valueOf(10000))
.merchantTransactionId("test-tx-" + UUID.randomUUID())
.customer(Customer.builder().hsid("test-hsid").build())
.paymentAllocations(List.of(validPaymentAllocation()));
}
public static PaymentAllocation validPaymentAllocation() {
return PaymentAllocation.builder()
.amount(BigDecimal.valueOf(10000))
.paymentMethodId(UUID.randomUUID().toString())
.build();
}
public static PaymentResponse aPaymentResponse() {
return PaymentResponse.builder()
.id(UUID.randomUUID())
.status(MerchantPaymentStatus.PENDING)
.amount(10000L)
.merchantTransactionId("test-tx-001")
.build();
}
}
Coverage Targets
| Metric | Minimum | Target |
|---|---|---|
| Unit test line coverage | 70% | ≥80% |
| Integration test coverage | 50% | ≥60% |
| Critical path coverage (state transitions, error handling) | 90% | 100% |
Coverage is measured via JaCoCo and reported in the CI pipeline. PRs that decrease coverage below the minimum threshold are blocked.