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.
Tools and Libraries
Section titled “Tools and Libraries”| Purpose | Library |
|---|---|
| Test framework | kotlin.test + org.junit.jupiter or kotest (pick one per module) |
| Assertions | kotlin.test assertions or Kotest matchers; prefer JUnit assertions |
| Mocking | MockK |
| Containerized dependencies | testcontainers via ContainerizedPostgres |
| AWS service emulation | LocalStack via MockAWS |
Unit Testing
Section titled “Unit Testing”General Practices
Section titled “General Practices”- 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
printlnfor logging in test code committed to the repository. - Move common setup code to a
Harnessobject (see The Harness Pattern) to keep tests focused on the behavior under test.
Testing with MockK
Section titled “Testing with MockK”Always Use Named Mock Variables
Section titled “Always Use Named Mock Variables”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.
// Avoidval service = ItemService.Impl(mockk(), mockk(), itemDb)
// Preferval supplierService = mockk<SupplierService>()val itemDb = mockk<Database>()val service = ItemService.Impl(supplierService, itemDb)Reset Mocks Between Tests
Section titled “Reset Mocks Between Tests”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().
Verify Exact Invocation Counts
Section titled “Verify Exact Invocation Counts”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.
Capture Parameters with Slots
Section titled “Capture Parameters with Slots”Use slot to capture parameters passed to mocks for verification when exact argument values matter.
DBIO Mock Return Syntax
Section titled “DBIO Mock Return Syntax”When mocking methods returning DBIO (a suspend lambda), use the lambda form:
// CorrectcoEvery { universe.create(any(), any(), any(), any()) } returns { Result.success(record) }
// Incorrect — will not work for DBIOcoEvery { 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.
Kotest-Specific Notes
Section titled “Kotest-Specific Notes”Matchers Do Not Accept Message Parameters
Section titled “Matchers Do Not Accept Message Parameters”shouldBeSuccess() and shouldBeFailure() interpret string arguments as expected values, not assertion messages. To include a diagnostic message, use:
result.isSuccess shouldBe trueVerify 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.
The Harness Pattern
Section titled “The Harness Pattern”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.
internalto the module when the harness is shared across multiple test files. In this case, define it in aHarness.ktfile 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.
Test Infrastructure
Section titled “Test Infrastructure”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.
ContainerizedPostgres
Section titled “ContainerizedPostgres”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:
| Factory | When to use | Example 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.dataSource—DataSource(for rawSqlDataSource/ Flyway)cpg.newExposedDatabase—Database(for Exposed transactions)cpg.postgresContainer— the rawPostgreSQLContainer(forjdbcUrl,host,firstMappedPort)
Init SQL Scripts
Section titled “Init SQL Scripts”An init file is a SQL script executed when the container starts, before any test runs. Two flavours exist in the codebase:
- Simple (
testcontainers/init.sql,persistence-test/database/init.sql): Creates tables and extensions in the default database. Used byfromTestConfigtests. - Multi-database (
kanban-service-test/database/db-init.sql,item-service-test/database/db-init.sql): Usesdblinkto create separate databases, users, roles, and grants. Used byfromValuestests 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.
1. Configuration File
Section titled “1. Configuration File”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.
2. Test Harness
Section titled “2. Test Harness”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) }}3. Lifecycle and Database Access
Section titled “3. Lifecycle and Database Access”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-module—PersistenceHarness.ktinlib/src/test/.../persistence/bitemporal/common-module—ComponentTest.ktinlib/src/test/.../persistence/bitemporal/universe/operations—BusinessRoleUniverseTest.ktinsrc/test/.../reference/businessaffiliates/persistence/(Universe + Flyway)common-module—DbMigrationTest.ktinlib/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.
1. Init Script
Section titled “1. Init Script”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;2. Test Harness
Section titled “2. Test Harness”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` ... }}3. Lifecycle
Section titled “3. Lifecycle”class MyServiceTest : StringSpec({ beforeSpec { Harness.cpg.start() } afterSpec { Harness.cpg.stop() }
"creates entity successfully" { val env = Harness.setup() // ... test using env.service ... }})Reference files:
operations—SupplyServiceMethodsTest.ktinsrc/test/.../reference/item/service/operations—ItemCsvUpdateE2ETest.ktinsrc/test/.../reference/item/service/(S3 + DB)common-module—DbScopedSequenceServiceTest.ktinlib/src/test/.../persistence/sequence/(standalone harness)
General Database Testing Rules
Section titled “General Database Testing Rules”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 inafterSpecwhen the database can be shared across all tests in the spec. - Start in
beforeTest, stop inafterTestwhen test isolation requires a fresh database per test. - Use
beforeTestto 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_SERIALIZABLETransactionManager.manager.defaultMaxAttempts = 3Flyway 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.
Related Pages
Section titled “Related Pages”- Mocking Patterns — MockK and Kotest quick-reference patterns
- Dev Workflows — local development setup
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved