Skip to content

Testing Strategy

Tiana_ edited this page May 30, 2026 · 1 revision

Testing Strategy

Categories, frameworks, expectations. How we trust the code we ship. Companion to Code-Rules.

Test pyramid (FinCore-tuned)

              ┌────────────────────┐
              │   Manual / E2E     │  ~5 tests, smoke only
              └────────────────────┘
            ┌────────────────────────┐
            │  Contract tests        │  per service interface
            └────────────────────────┘
          ┌──────────────────────────────┐
          │  Integration (Testcontainers)│  ~30%
          └──────────────────────────────┘
       ┌──────────────────────────────────────┐
       │  Unit tests + Property tests         │  ~70%
       └──────────────────────────────────────┘

We bias toward integration tests (Testcontainers) for fintech because mocked DB tests miss invariant-trigger behavior.


Category 1: Unit tests

Framework JUnit 5 + MockK + Kotest assertions
Scope Single class, mocked dependencies
Speed Sub-second per test
Run Every PR, every commit
class AccountServiceImplTest {

    private val repo = mockk<AccountRepository>()
    private val mapper = mockk<AccountEntityMapper>()
    private val outbox = mockk<OutboxEventPublisher>(relaxed = true)
    private val idempotency = mockk<IdempotencyService>()

    private val service = AccountServiceImpl(repo, mapper, outbox, idempotency)

    @Test
    fun `should reject when name is blank`() {
        val cmd = CreateAccountCommand(name = "", type = USER_WALLET, currency = "EUR", idempotencyKey = "k1")

        shouldThrow<ValidationException> {
            service.create(cmd)
        }
    }
}

Category 2: Integration tests (Testcontainers)

Framework JUnit 5 + Spring Boot Test + Testcontainers
Scope Real Spring context + real Postgres + real Redpanda
Speed 5-30 seconds per class
Run Every PR, before tagging release
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@AutoConfigureMockMvc
class TransactionPostingIntegrationTest {

    companion object {
        @Container
        @JvmStatic
        val postgres = PostgreSQLContainer("postgres:17-alpine")
            .withDatabaseName("fincore_test")
            .withUsername("test")
            .withPassword("test")

        @Container
        @JvmStatic
        val redpanda = RedpandaContainer("redpandadata/redpanda:v24.3.1")

        @JvmStatic
        @DynamicPropertySource
        fun props(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url", postgres::getJdbcUrl)
            registry.add("spring.kafka.bootstrap-servers", redpanda::getBootstrapServers)
        }
    }

    @Autowired lateinit var mvc: MockMvc

    @Test
    fun `should reject transaction when sum is non-zero`() {
        val req = """
        {
          "reference": "test-1",
          "entries": [
            { "accountId": "$accountA", "amount": "-100.00", "currency": "EUR", "direction": "DEBIT" },
            { "accountId": "$accountB", "amount":  "90.00", "currency": "EUR", "direction": "CREDIT" }
          ]
        }
        """

        mvc.perform(post("/v1/transactions")
            .header("Idempotency-Key", "test-1")
            .contentType(APPLICATION_JSON)
            .content(req))
            .andExpect(status().is(422))
            .andExpect(jsonPath("$.title").value("Invariant violation"))
    }
}

Critical integration tests:

  • Ledger invariants under concurrent load
  • Idempotency under retry storms
  • Outbox publish under Kafka outage / restart
  • Decision Engine determinism replay
  • Webhook delivery retries

Category 3: Property tests (Kotest)

Framework Kotest property testing
Scope Generative - random inputs, assert invariants
Speed 1-10 seconds per property
Run Every PR for critical paths
class LedgerInvariantPropertyTest : DescribeSpec({

    describe("ledger invariants") {

        it("balance(account) equals sum of entries") {
            checkAll(
                iterations = 1000,
                Arb.list(arbValidTransaction(), 1..50),
            ) { txns ->
                ledger.reset()
                txns.forEach { ledger.post(it) }

                val accountIds = txns.flatMap { it.entries }.map { it.accountId }.distinct()
                accountIds.forEach { id ->
                    val derivedBalance = txns.flatMap { it.entries }
                        .filter { it.accountId == id }
                        .sumOf { it.amount }
                    val viewBalance = ledger.getBalance(id)
                    derivedBalance shouldBe viewBalance
                }
            }
        }

        it("never violates SUM=0 invariant under 100 concurrent posters") {
            // 100 threads × 50 random transactions each
            // assert all entries sum to zero per currency
        }
    }
})

Critical property tests:

  • Ledger SUM=0 under any sequence of valid posts
  • Idempotency: replay produces identical response
  • Decision Engine: same input + same rules → same output
  • State machine: only legal transitions reached

Category 4: Contract tests

Framework Spring Cloud Contract or REST Assured against OpenAPI
Scope Service interfaces match published contract (OpenAPI)
Speed Minutes for full suite
Run Every PR; mandatory for cross-service changes

Verifies:

  • Every endpoint in OpenAPI spec is implemented
  • Every endpoint matches request/response schema
  • Error responses conform to RFC 7807

Category 5: E2E / smoke tests

Framework k6 (HTTP) + custom
Scope Real docker compose up stack, real flows
Speed 1-5 minutes
Run Before tagging, in CI on main
// k6 script: smoke.js
import http from 'k6/http';
import { check } from 'k6';

export default function () {
    const token = http.post(`${KEYCLOAK}/protocol/openid-connect/token`, {...}).json('access_token');

    const account = http.post(`${API}/v1/accounts`, JSON.stringify({...}), {
        headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': `e2e-${__ITER}` },
    });
    check(account, { 'account 201': r => r.status === 201 });

    // ... post transaction, verify balance
}

Category 6: Performance / load tests

Framework k6 + Gatling
Scope Sustained throughput, p99 latency under load
Speed 5-30 minutes
Run Pre-release; weekly cron in main

Targets per Architecture-SLA-SLI-SLO:

  • Ledger post: 1000 tx/sec sustained, < 300ms p99
  • Decision evaluate: 5000 evals/sec, < 10ms p99

Category 7: Chaos tests

Framework Custom + Toxiproxy
Scope Inject failures (timeouts, partitions)
Speed 5-10 minutes
Run Pre-release; nightly in main

Verifies invariants hold under:

  • Bank adapter timeouts (5%)
  • Partial successes
  • Webhook duplicates
  • Kafka outages
  • Postgres slow queries
  • Redis unavailability

Coverage targets

Layer Coverage
Domain (pure logic) > 95%
Application services > 90%
Infrastructure (mappers, repos) > 70%
API controllers > 80% (covered by integration tests too)
Configuration not measured
Generated code (MapStruct) excluded

Enforced via JaCoCo coverageVerification in CI.


Mocking philosophy

What Approach
External HTTP services WireMock (contract-tested)
Bank Adapter sandbox implementation (real, in-memory)
KYC Provider sandbox (real, in-memory)
LLM Provider mock with deterministic responses
Database NEVER mocked in integration tests - use Testcontainers
Kafka Embedded Kafka or Testcontainers Redpanda
Time (Clock) Clock.fixed() injected via Spring

Test data builders (avoid duplication)

// In test/kotlin/com/fincore/test/builders/
fun anAccount(
    id: AccountId = AccountId(UUID.randomUUID()),
    name: String = "test-account",
    type: AccountType = USER_WALLET,
    currency: String = "EUR",
    status: AccountStatus = ACTIVE,
): Account = Account(
    id = id,
    name = name,
    type = type,
    currency = currency,
    status = status,
    metadata = emptyMap(),
    version = 0,
    createdAt = Instant.now(),
    updatedAt = Instant.now(),
)

Each test specifies only what it cares about; defaults provide the rest.


What NOT to test

  • Spring Framework itself - assume @Transactional works
  • Generated MapStruct code - covered by mapper integration test
  • Hibernate - covered by integration tests
  • Trivial getters/setters - JPA entities don't need accessor tests
  • Mock library behavior - testing MockK is not our job

Bias toward observable behavior over implementation details.


CI test execution order

  1. Unit tests (fast, fail-fast on logic bugs)
  2. Property tests (catch invariants)
  3. Integration tests (full stack)
  4. Contract tests (API conformance)
  5. E2E smoke (real flows)
  6. Performance/load (release-only)
  7. Chaos (release-only)

Total CI time target: < 10 minutes for unit + integration on PR. Performance/chaos run nightly.


Related

Clone this wiki locally