How Clean Architecture makes domain and application tests run in milliseconds — with no database, no Spring, and no excuses.
The test that shouldn't need a database
Here's a test I've seen too many times:
@SpringBootTest
class PageSizeValidationTest {
@Autowired lateinit var repoService: GitHubRepoService
@Test
fun `should reject page size over 100`() {
assertThrows<IllegalArgumentException> {
repoService.list(pageSize = 101)
}
}
}
This test checks one thing: whether 101 is rejected as a page size. To do it, Spring boots up, all beans are wired, the database connection pool is initialized. Eight seconds of startup for a boundary check that has nothing to do with any of it.
The problem isn't the test. It's that the business logic is baked into a @Service that can't exist without the container.
Clean Architecture solves this with a single structural rule: domain and application layers have zero external dependencies. No Spring. No database. No infrastructure of any kind.
That rule has a direct consequence for testing: the entire business logic of the system — every rule, every validation, every failure case — can be tested with pure Kotlin. No framework startup. No database connection. Just code and assertions.
Domain + application = the specification
The key idea from Part 1: the application module is a direct translation of the product spec into Kotlin. Read the use case interfaces and you're reading the requirements. Read the domain errors and you're reading the failure scenarios the spec describes.
Tests make this claim verifiable. Because domain and application have no external dependencies, every business rule is a plain Kotlin test — write it, run it. Two workflows follow naturally:
Specification-driven development: Define use case interfaces and domain errors from the requirements document. Tests validate the implementation matches the spec.
Test-driven development: Write the use case test first — against the interface — before implementing anything. Infrastructure comes last.
Layer 1: Domain tests — pure Kotlin, zero dependencies
Domain tests have no mocks, no Spring, no database. The only imports are Kotest, kotest-property, and the Arrow assertions extension. Some tests use runTest — not because they touch coroutines, but because checkAll is a suspend function.
There's a natural fit between Either and property-based testing. When every code path must return Either<E, A>, the test question shifts from "does it handle the edge case?" to "does the invariant hold for the entire input space?" Property-based testing is the direct answer.
Property-based testing for value object invariants
The most natural test for a value object isn't "does it reject 101?" — it's "does it reject every value above 100?" Example-based tests can only sample a few points. Property-based tests state the invariant and verify it holds for hundreds of generated inputs.
PageSize enforces a business rule: values must be between 1 and 100. Instead of picking specific values, the tests express what must be true for any value in each region:
class PageSizeTest {
@Test
fun `of returns Right with correct value for any value in valid range`() =
runTest {
checkAll(Arb.int(PageSize.MIN_VALUE..PageSize.MAX_VALUE)) { n ->
PageSize.of(n).shouldBeRight().value shouldBe n
}
}
@Test
fun `of returns Left BelowMinimum for any value below minimum`() =
runTest {
checkAll(Arb.int(max = PageSize.MIN_VALUE - 1)) { n ->
PageSize.of(n).shouldBeLeft() shouldBe PageSizeError.BelowMinimum(n)
}
}
@Test
fun `of returns Left AboveMaximum for any value above maximum`() =
runTest {
checkAll(Arb.int(min = PageSize.MAX_VALUE + 1)) { n ->
PageSize.of(n).shouldBeLeft() shouldBe PageSizeError.AboveMaximum(n)
}
}
}
Arb.int(range) is an arbitrary — a generator that produces random integers in the specified range. checkAll runs 1000 iterations by default (configurable), sampling across the entire space. The first test doesn't just check 1 and 100 — it checks that of(n).value == n holds for any valid integer.
shouldBeRight() and shouldBeLeft() from kotest-assertions-arrow unwrap the Either and assert its branch in a single call.
No @SpringBootTest. No @ExtendWith. No application context. Every test here runs in under 10ms total.
Testing error messages as contracts
Error messages that flow to API clients are part of the interface. These are deterministic — no property generation needed:
@Test
fun `BelowMinimum error message contains the invalid value`() {
val error = PageSizeError.BelowMinimum(0)
error.message shouldBe "Page size must be at least ${PageSize.MIN_VALUE}, but was 0"
}
@Test
fun `AboveMaximum error message contains the invalid value`() {
val error = PageSizeError.AboveMaximum(200)
error.message shouldBe "Page size must be at most ${PageSize.MAX_VALUE}, but was 200"
}
If someone changes the format, the test fails. If the minimum value constant changes, the message updates automatically. The test pins the contract.
Property-based testing for string value objects
String-valued objects like GitHubOwner have a harder invariant to exhaustively sample: "any non-blank string is valid, any blank string is not." Property tests handle this without hand-picking examples:
class GitHubOwnerTest {
@Test
fun `of returns Right with correct value for any non-blank string`() =
runTest {
checkAll(Arb.string(1..100).filter { it.isNotBlank() }) { s ->
GitHubOwner.of(s).shouldBeRight().value shouldBe s
}
}
@Test
fun `of returns Left InvalidOwner for any blank string`() =
runTest {
checkAll(Arb.of("", " ", " ", "\t", "\n", "\r\n", "\u00A0")) { s ->
GitHubOwner.of(s).shouldBeLeft()
.shouldBeInstanceOf<GitHubError.InvalidOwner>()
}
}
}
The invalid-input test uses Arb.of(...) with an explicit set covering regular spaces, tabs, newlines, and Unicode non-breaking space (\u00A0). A blank-string check that only tests "" and " " will miss the unicode edge case. The set makes that coverage explicit and exhaustive for the failure class.
Testing numeric value objects
GitHubRepoId.of(Long) validates the domain invariant: IDs must be positive. The same property pattern applies:
class GitHubRepoIdTest {
@Test
fun `of returns Right with correct value for any positive id`() =
runTest {
checkAll(Arb.long(min = 1L)) { n ->
GitHubRepoId.of(n).shouldBeRight().value shouldBe n
}
}
@Test
fun `of returns Left InvalidId for any non-positive id`() =
runTest {
checkAll(Arb.long(max = 0L)) { n ->
GitHubRepoId.of(n).shouldBeLeft()
.shouldBeInstanceOf<GitHubError.InvalidId>()
}
}
@Test
fun `InvalidId error message contains the invalid value`() {
val error = GitHubError.InvalidId(-1L)
error.message shouldBe "Invalid GitHub repo ID: -1 (must be positive)"
}
@Test
fun `NotFound error message contains the id`() {
val error = GitHubError.NotFound(GitHubRepoId(42L))
error.message shouldBe "GitHub repo not found: 42"
}
}
Arb.long(min = 1L) generates random positive longs across the full range — not just 1L and 9999999L. If the validation logic had an off-by-one at Long.MAX_VALUE, a hand-picked example would never catch it. A property test would.
Layer 2: Application tests — the specification in executable form
Domain tests cover what values are legal. Application tests cover what the system does with legal and illegal values — and what callers see when it fails.
Use case tests are where specification-driven development becomes concrete. These tests describe what each operation does, in terms of the domain — with no database, no Spring, and no HTTP. Unlike domain tests, application tests use specific values: they're verifying error mapping, not input ranges.
Constructing use cases directly
class GitHubRepoListUseCaseImplTest {
private val repository = mockk<GitHubRepoRepository>()
private val useCase = GitHubRepoListUseCaseImpl(repository)
GitHubRepoListUseCaseImpl is a plain Kotlin class. Construct it with a mocked repository interface and it's ready to test. No Spring context, no bean factory, no @Autowired.
This is only possible because GitHubRepoListUseCaseImpl has no @Service annotation and no framework imports. It's a class — you instantiate classes with constructors.
Testing the happy path
@Test
fun `execute returns Right with page when parameters are valid`() = runTest {
val page = Page(totalCount = 1, items = listOf(sampleRepo()))
coEvery { repository.list(any(), any()) } returns Either.Right(page)
useCase.execute(1, 20).shouldBeRight(page)
}
runTest from kotlinx-coroutines-test runs suspend functions synchronously — no thread management needed. coEvery stubs the suspend fun list(...) call with a predefined Either.Right.
Testing validation errors — the repository is never called
Input validation fires inside the either { } block before any repository call. Tests for invalid input don't configure the mock at all:
@Test
fun `execute returns Left InvalidPageNumber when pageNumber is 0`() = runTest {
val error = useCase.execute(0, 20).shouldBeLeft()
error shouldBe GitHubRepoListError.InvalidPageNumber(PageNumberError.BelowMinimum(0))
}
@Test
fun `execute returns Left InvalidPageSize when pageSize exceeds 100`() = runTest {
val error = useCase.execute(1, 101).shouldBeLeft()
error shouldBe GitHubRepoListError.InvalidPageSize(PageSizeError.AboveMaximum(101))
}
The either { } + .bind() chain short-circuits at the first Left. The repository is never invoked. These tests confirm that invalid inputs are rejected at the use case boundary — before anything hits the database.
Testing error mapping
Each use case maps domain errors to application-level errors. The mapping is part of the spec — it determines what the caller sees when something goes wrong:
@Test
fun `execute returns Left FetchFailed when repository returns error`() = runTest {
val domainError = GitHubError.RepositoryError("DB error")
coEvery { repository.list(any(), any()) } returns Either.Left(domainError)
val error = useCase.execute(1, 20).shouldBeLeft()
error shouldBe GitHubRepoListError.FetchFailed(domainError)
}
For FindById, the mapping is conditional — the same domain layer produces different application errors depending on the failure type:
@Test
fun `execute returns Left InvalidId when id is not positive`() =
runTest {
val error = useCase.execute(0L).shouldBeLeft()
error shouldBe GitHubRepoFindByIdError.InvalidId(GitHubError.InvalidId(0L))
}
@Test
fun `execute returns Left NotFound when repo is not found`() =
runTest {
val domainError = GitHubError.NotFound(GitHubRepoId(1L))
coEvery { repository.findById(any()) } returns Either.Left(domainError)
val error = useCase.execute(1L).shouldBeLeft()
error shouldBe GitHubRepoFindByIdError.NotFound(domainError)
}
@Test
fun `execute returns Left FetchFailed when RepositoryError occurs`() =
runTest {
val domainError = GitHubError.RepositoryError("DB connection failed")
coEvery { repository.findById(any()) } returns Either.Left(domainError)
val error = useCase.execute(1L).shouldBeLeft()
error shouldBe GitHubRepoFindByIdError.FetchFailed(domainError)
}
GitHubRepoFindByIdError.InvalidId fires before the repository is ever called — ID validation is a use case responsibility, not infrastructure's. For valid IDs: GitHubError.NotFound → GitHubRepoFindByIdError.NotFound; GitHubError.RepositoryError → GitHubRepoFindByIdError.FetchFailed. Three domain outcomes. Three tests. The mapping is explicit, typed, and verified.
The development workflow this enables
Here's the concrete workflow that becomes available when domain and application have no external dependencies:
Step 1: The product spec says "users can list repositories, with pagination between 1 and 100 items per page."
Step 2: Write the use case interface and domain errors directly from the spec — before any implementation:
interface GitHubRepoListUseCase {
suspend fun execute(pageNumber: Int, pageSize: Int): Either<GitHubRepoListError, Page<GitHubRepo>>
}
sealed interface GitHubRepoListError : ApplicationError {
data class InvalidPageNumber(val error: PageNumberError) : GitHubRepoListError
data class InvalidPageSize(val error: PageSizeError) : GitHubRepoListError
data class FetchFailed(val cause: GitHubError) : GitHubRepoListError
}
Step 3: Write the tests. Value objects use property-based tests to verify invariants across the full input space. Use cases use specific values to verify that error types and mappings match the spec:
// Spec: valid pagination returns a page of results
useCase.execute(1, 20).shouldBeRight(page)
// Spec: page number must be at least 1
useCase.execute(0, 20).shouldBeLeft() // GitHubRepoListError.InvalidPageNumber
// Spec: page size must not exceed 100
useCase.execute(1, 101).shouldBeLeft() // GitHubRepoListError.InvalidPageSize
Step 4: Implement the use case to make the tests pass. The repository is a mock — no database needed.
Step 5: Implement the infrastructure. Wire it together. Run the integration tests.
The business logic is validated before a single line of infrastructure code is written. The tests are the spec. The spec is the tests.
What comes after: infrastructure tests
The one layer not covered here is infrastructure — actual database queries require Testcontainers or a real PostgreSQL instance. Those tests are slower and heavier by nature.
But they're also contained. Domain and application tests never touch the database. The infrastructure layer runs in isolation against a real database. Each layer has the right test strategy for its responsibilities.
The real value isn't just speed. It's that the barrier to writing a test is zero. There's no decision to make about whether a test is "worth spinning up the container for." If it's a business rule, it has a test. If it has a test, it runs in milliseconds. There's no tradeoff.
The full source is on GitHub: https://github.com/wakita181009/clean-architecture-kotlin/tree/v1
Top comments (0)