-
Notifications
You must be signed in to change notification settings - Fork 0
Testing Strategy
Tiana_ edited this page May 30, 2026
·
1 revision
Categories, frameworks, expectations. How we trust the code we ship. Companion to Code-Rules.
┌────────────────────┐
│ 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.
| 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)
}
}
}| 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
| 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
| 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
| 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
}| 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
| 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
| 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.
| 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 |
// 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.
-
Spring Framework itself - assume
@Transactionalworks - 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.
- Unit tests (fast, fail-fast on logic bugs)
- Property tests (catch invariants)
- Integration tests (full stack)
- Contract tests (API conformance)
- E2E smoke (real flows)
- Performance/load (release-only)
- Chaos (release-only)
Total CI time target: < 10 minutes for unit + integration on PR. Performance/chaos run nightly.
- Code-Rules#tests - naming, structure
- Architecture-Resilience#10-chaos-engineering-hooks - chaos test setup
- Overview
- Services
- Data Model
- Domain Model
- Event Flow
- Security
- Observability
- Resilience
- SLA / SLI / SLO