Skip to main content
Version: v2

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

LayerQuantitySpeedWhat It TestsRequired For
UnitMany (≥80% coverage)Fast (ms)Business logic, state transitions, validators, mappers, error mappingEvery PR
ContractModerateFastRequest/response schema compliance with OpenAPI specEvery PR
IntegrationModerateMedium (s)Service layer + real database (testcontainers) + mocked external servicesEvery PR
E2EFewSlow (min)Full flow against staging environment with real external dependenciesRelease gate

Unit Test Standards

Unit tests must cover every public method in the service, adapter, and validator layers.

What to Test

Component TypeWhat to Cover
Service methodsHappy path, state transitions, business rule enforcement, edge cases
ValidatorsEvery field constraint, cross-field rules, boundary values
Error mappersExternal error → internal ErrorGroup mapping, Problem DTO construction
Event/webhook buildersCorrect event name, payload field mapping from domain model
Adapter/client wrappersRequest construction, response parsing, error translation

What NOT to Unit Test

SkipWhy
Getter/setter boilerplateLombok-generated; no logic to verify
Framework wiringCovered by integration tests
Database queriesCovered 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

ComponentApproach
DatabaseTestcontainers (PostgreSQL) — real schema, real queries
External APIs (Stripe, etc.)WireMock or similar — deterministic, no network dependency
Message brokersTestcontainers or in-memory alternatives
Other internal servicesMocked via WireMock or stub

Standard Scenarios Every Domain Must Cover

ScenarioDescription
Happy path — createCreate a resource → verify persisted state in database
Happy path — readRetrieve a resource → verify response structure
Validation rejectionSubmit invalid request → verify 400 response
Business rule rejectionSubmit valid but disallowed request → verify 422 response
External dependency failureMock external service returning error → verify graceful handling
IdempotencySubmit same idempotency key twice → verify no duplicate created
Not foundRequest non-existent resource → verify 404 response
AuthorizationRequest with wrong merchant → verify 403 response

Contract Test Standards

Contract tests verify that API requests and responses conform to the OpenAPI specification.

WhatHow
Request schemasValidate sample payloads against openapi/v2/schemas/{domain}/request/*.yaml
Response schemasValidate actual responses from integration tests against response schemas
Webhook schemasValidate webhook payloads against openapi/v2/schemas/{domain}/webhook/*.yaml
Error schemasValidate 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.).

RequirementStandard
EnvironmentStaging (X-Upstream-Env: stage)
ScopeCover the primary happy path and the most critical failure path per domain
Data isolationUse dedicated test merchant and customer identifiers
CleanupTests must clean up created resources or use short-lived test data
StabilityE2E 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

MetricMinimumTarget
Unit test line coverage70%≥80%
Integration test coverage50%≥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.