Skip to content

Backend Testing

Reference guide for writing reliable backend tests in Kotlin. Covers unit testing patterns with Kotest and MockK, followed by test infrastructure setup for containerized databases and AWS service emulation.


PurposeLibrary
Test frameworkkotlin.test + org.junit.jupiter or kotest (pick one per module)
Assertionskotlin.test assertions or Kotest matchers; prefer JUnit assertions
MockingMockK
Containerized dependenciestestcontainers via ContainerizedPostgres
AWS service emulationLocalStack via MockAWS

  • Use meaningful test names that describe the behavior being tested, not the method name.
  • Provide assertion messages so failures are self-explanatory during diagnostics.
  • Never use println for logging in test code committed to the repository.
  • Move common setup code to a Harness object (see The Harness Pattern) to keep tests focused on the behavior under test.

Never pass mockk() inline in constructors. When the mock later requires stubs, a refactor is needed. Always assign mocks to named variables even when no stubs are initially needed.

// Avoid
val service = ItemService.Impl(mockk(), mockk(), itemDb)
// Prefer
val supplierService = mockk<SupplierService>()
val itemDb = mockk<Database>()
val service = ItemService.Impl(supplierService, itemDb)

Reset all mocks between tests to avoid state leakage. Use clearMocks() or clearAllMocks() in beforeEach.

Never Use Relaxed Mocks for Complex Generics

Section titled “Never Use Relaxed Mocks for Complex Generics”

relaxed = true cannot generate valid default return values for deeply nested generic types like Result<EntityRecord<X, Y>?>. It returns Result.success(Object()), which causes a ClassCastException at runtime.

Do not use relaxed mocks unless the dependency is a low-level service not directly exercised by the code under test. Always provide explicit coEvery stubs for complex generic returns.

Set Up Stubs with Specific Parameter Values

Section titled “Set Up Stubs with Specific Parameter Values”

Set up every and coEvery blocks in each test case. Use specific parameter values when known instead of any().

Always verify the exact number of invocations using exactly = n where n is the expected count. Use verifyOrder or verifySequence when the order of invocations matters.

Use slot to capture parameters passed to mocks for verification when exact argument values matter.

When mocking methods returning DBIO (a suspend lambda), use the lambda form:

// Correct
coEvery { universe.create(any(), any(), any(), any()) } returns { Result.success(record) }
// Incorrect — will not work for DBIO
coEvery { universe.create(any(), any(), any(), any()) } returns Result.success(record)

Simplify Matchers to Avoid Reflection Errors

Section titled “Simplify Matchers to Avoid Reflection Errors”

Mocking the Universe interface’s invoke pattern (universe.create(...)()) can cause KotlinReflectionInternalError. Use any<Type>() matchers with returnsMany instead of complex match<> lambdas.

shouldBeSuccess() and shouldBeFailure() interpret string arguments as expected values, not assertion messages. To include a diagnostic message, use:

result.isSuccess shouldBe true

Verify Test Coverage Before Writing New Tests

Section titled “Verify Test Coverage Before Writing New Tests”

Before creating new tests, verify existing coverage at different layers to avoid duplicating what is already there.


All auxiliary methods, functions, and classes that support tests belong in a Harness object rather than scattered inline. This keeps test bodies focused on the behavior under test.

Visibility rules:

  • Private to the test file when the harness is not shared outside that file.
  • internal to the module when the harness is shared across multiple test files. In this case, define it in a Harness.kt file in the same package as the test code that references it most closely.
private object Harness {
val supplierService = mockk<SupplierService>()
val itemDb = mockk<Database>()
val service by lazy { ItemService.Impl(supplierService, itemDb) }
}

For infrastructure-dependent tests, the harness also owns the container lifecycle — see the Test Infrastructure section below.


This section covers containerized dependencies for tests that require a real Postgres instance or AWS service emulation. Both tools are provided by common-module; source is on GitHub.

cards.arda.common.lib.testing.persistence.ContainerizedPostgres runs a real Postgres 16 container via testcontainers. There are two factory methods — choose based on your test layer:

FactoryWhen to useExample tests
fromTestConfig(config, initFile)Persistence / Universe layer tests that load an application.conf via ConfigurationProvider. Config values come from the conf file.BusinessRoleUniverseTest, DbMigrationTest, ComponentTest
fromValues(db, user, pwd, initFile)Service-layer / E2E tests where the init SQL creates multiple databases and users. Hard-coded values match those in the init script.SupplyServiceMethodsTest, ItemCsvUpdateE2ETest, DbScopedSequenceServiceTest

Key properties on a cpg instance:

  • cpg.dataSourceDataSource (for raw SqlDataSource / Flyway)
  • cpg.newExposedDatabaseDatabase (for Exposed transactions)
  • cpg.postgresContainer — the raw PostgreSQLContainer (for jdbcUrl, host, firstMappedPort)

An init file is a SQL script executed when the container starts, before any test runs. Two flavours exist in the codebase:

  1. Simple (testcontainers/init.sql, persistence-test/database/init.sql): Creates tables and extensions in the default database. Used by fromTestConfig tests.
  2. Multi-database (kanban-service-test/database/db-init.sql, item-service-test/database/db-init.sql): Uses dblink to create separate databases, users, roles, and grants. Used by fromValues tests where the container DB name is typically "dummy_db" and the init script bootstraps the real application database.

Place init scripts under src/test/resources/<test-suite-name>/database/.


Strategy A — Persistence and Universe Layer Tests (fromTestConfig)

Section titled “Strategy A — Persistence and Universe Layer Tests (fromTestConfig)”

Use this strategy when testing persistence classes (Persistence, Universe, UniverseTable, Component) that work directly with Exposed.

Create an application.conf under src/test/resources/<test-suite>/:

dataSource {
db {
url = "jdbc:postgres://localhost:5432/DUMMY" # Replaced by the container at runtime.
user = "ba_test_user"
password = "ba_test_password"
}
pool { ... }
initFile = "testcontainers/init.sql"
flyway {
locations = ["reference/business-affiliate/database/migrations"]
adminTable = "business_affiliate_flyway_schema_history"
}
}

The url is a placeholder — ContainerizedPostgres replaces it with the container’s actual host and port at runtime.

private object Harness {
val cfgProvider = ConfigurationProvider.load("<test-suite>/application.conf")
val dsConfig: DataSourceConfig by lazy {
val defaults = cfgProvider.globalDsConfiguration!!
DataSourceConfig.fromDefaults(defaults).getOrThrow()
}
val cpg: ContainerizedPostgres by lazy {
ContainerizedPostgres.fromTestConfig(dsConfig.db, dsConfig.initFile)
}
}
class MyUniverseTest : StringSpec({
beforeSpec {
Harness.cpg.start()
// Optional: run Flyway migrations after starting the container.
DbMigration(Harness.dsConfig.flywayConfig!!, Harness.cpg.dataSource.newSqlDataSource())
.migrate(close = true).onFailure { throw it }
}
afterSpec { Harness.cpg.stop() }
"round-trip test" {
val db = Harness.cpg.newExposedDatabase
newSuspendedTransaction(db = db) {
// ... persistence operations ...
}
}
})

Reference files:

  • common-modulePersistenceHarness.kt in lib/src/test/.../persistence/bitemporal/
  • common-moduleComponentTest.kt in lib/src/test/.../persistence/bitemporal/universe/
  • operationsBusinessRoleUniverseTest.kt in src/test/.../reference/businessaffiliates/persistence/ (Universe + Flyway)
  • common-moduleDbMigrationTest.kt in lib/src/test/.../persistence/rdbms/

Strategy B — Service Layer and E2E Tests (fromValues)

Section titled “Strategy B — Service Layer and E2E Tests (fromValues)”

Use this strategy when the test requires a fully initialized database with Flyway migrations — typically to test a Service.Impl that owns a Database instance.

Create a multi-database init script (e.g., <test-suite>/database/db-init.sql):

CREATE EXTENSION IF NOT EXISTS dblink;
CREATE USER item_db_owner WITH PASSWORD 'item_db_owner_password';
CREATE DATABASE item_db CONNECTION_LIMIT = 100;
-- ... grant roles, permissions, set owner ...
ALTER DATABASE item_db OWNER TO item_db_owner;
private object Harness {
val cpg: ContainerizedPostgres by lazy {
ContainerizedPostgres.fromValues(
databaseName = "dummy_db", // Placeholder name for the container's default DB.
user = "dummy_user",
password = "dummy_pwd",
initFileName = "<test-suite>/database/db-init.sql"
)
}
fun setup(): TestEnv {
// Build a PostgresConfig pointing to the real database created by the init script,
// using the container's dynamic host and port.
val dbConfig = PostgresConfig(
user = "item_db_owner",
password = "item_db_owner_password",
url = JdbcUri(
"jdbc:postgresql://${cpg.postgresContainer.host}" +
":${cpg.postgresContainer.firstMappedPort}/item_db?LoggerLevel=INFO"
)
)
val ds = DataSource(dbConfig, PoolConfig())
val db = ds.newDb(FlywayConfig(
locations = listOf("reference/item/database/migrations"),
adminTable = "reference_item_flyway_history"
))
// ... build service under test using `db` ...
}
}
class MyServiceTest : StringSpec({
beforeSpec { Harness.cpg.start() }
afterSpec { Harness.cpg.stop() }
"creates entity successfully" {
val env = Harness.setup()
// ... test using env.service ...
}
})

Reference files:

  • operationsSupplyServiceMethodsTest.kt in src/test/.../reference/item/service/
  • operationsItemCsvUpdateE2ETest.kt in src/test/.../reference/item/service/ (S3 + DB)
  • common-moduleDbScopedSequenceServiceTest.kt in lib/src/test/.../persistence/sequence/ (standalone harness)

Harness pattern: Always define the cpg instance and setup logic in a Harness object (private or companion), not inline in the test body.

Lazy initialization: Use by lazy for cpg, DataSourceConfig, and ConfigurationProvider to avoid premature initialization.

Lifecycle choices:

  • Start in beforeSpec, stop in afterSpec when the database can be shared across all tests in the spec.
  • Start in beforeTest, stop in afterTest when test isolation requires a fresh database per test.
  • Use beforeTest to clear tables (e.g., table.deleteAll()) if sharing the container but requiring clean state per test.

Connectivity test: Always include a test that verifies the connection is alive:

"confirms database connectivity" {
val conn = cpg.dataSource.newSqlDataSource().connection
assertTrue(conn.isValid(5), "Expected a valid database connection")
conn.close()
}

Transaction isolation: When using TransactionManager directly, set isolation and retry:

TransactionManager.manager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE
TransactionManager.manager.defaultMaxAttempts = 3

Flyway migration paths: Use the same migration locations defined in main/resources for the module under test to ensure the test schema matches production.

LocalStack S3 flakiness: Add retry and backoff logic around bucket creation during startup for LocalStack S3 tests.



Copyright: (c) Arda Systems 2025-2026, All rights reserved