Testing Guidelines
This document outlines the testing standards and practices for the easy-db-lab project.
Core Testing Principles
1. Use BaseKoinTest for Dependency Injection
All tests should extend BaseKoinTest to take advantage of automatic dependency injection setup and teardown.
class MyCommandTest : BaseKoinTest() {
// Your test code here
}
BaseKoinTest provides:
- Automatic Koin lifecycle management
- Core modules that are always mocked (AWS, SSH, OutputHandler)
- Ability to add test-specific modules via
additionalTestModules()
2. Use AssertJ for Assertions
Tests should use AssertJ assertions, not JUnit assertions. AssertJ provides more readable and powerful assertion methods.
// Good - AssertJ style
import org.assertj.core.api.Assertions.assertThat
assertThat(result).isNotNull()
assertThat(result.value).isEqualTo("expected")
assertThat(list).hasSize(3).contains("item1", "item2")
// Avoid - JUnit style
import org.junit.jupiter.api.Assertions.assertEquals
assertEquals("expected", result.value)
3. Create Custom Assertions for Non-Trivial Classes
When testing non-trivial classes, create custom AssertJ assertions to implement Domain-Driven Design in tests. This decouples business logic from implementation details and makes tests more maintainable during refactoring.
Custom Assertions Pattern
Custom assertions provide a fluent, domain-specific language for testing that improves readability and maintainability.
Example: Custom Assertion for a Domain Class
Here's a complete example showing how to create and use custom assertions:
// Domain class to be tested
data class CassandraNode(
val nodeId: String,
val datacenter: String,
val rack: String,
val status: NodeStatus,
val tokens: Int
)
enum class NodeStatus {
UP, DOWN, JOINING, LEAVING
}
// Custom assertion class
import org.assertj.core.api.AbstractAssert
class CassandraNodeAssert(actual: CassandraNode?) :
AbstractAssert<CassandraNodeAssert, CassandraNode>(actual, CassandraNodeAssert::class.java) {
companion object {
fun assertThat(actual: CassandraNode?): CassandraNodeAssert {
return CassandraNodeAssert(actual)
}
}
fun hasNodeId(nodeId: String): CassandraNodeAssert {
isNotNull
if (actual.nodeId != nodeId) {
failWithMessage("Expected node ID to be <%s> but was <%s>", nodeId, actual.nodeId)
}
return this
}
fun isInDatacenter(datacenter: String): CassandraNodeAssert {
isNotNull
if (actual.datacenter != datacenter) {
failWithMessage("Expected datacenter to be <%s> but was <%s>", datacenter, actual.datacenter)
}
return this
}
fun hasStatus(status: NodeStatus): CassandraNodeAssert {
isNotNull
if (actual.status != status) {
failWithMessage("Expected status to be <%s> but was <%s>", status, actual.status)
}
return this
}
fun isUp(): CassandraNodeAssert {
return hasStatus(NodeStatus.UP)
}
fun isDown(): CassandraNodeAssert {
return hasStatus(NodeStatus.DOWN)
}
fun hasTokenCount(tokens: Int): CassandraNodeAssert {
isNotNull
if (actual.tokens != tokens) {
failWithMessage("Expected token count to be <%s> but was <%s>", tokens, actual.tokens)
}
return this
}
}
// Usage in tests
import CassandraNodeAssert.Companion.assertThat
@Test
fun `test cassandra node configuration`() {
val node = CassandraNode(
nodeId = "node1",
datacenter = "dc1",
rack = "rack1",
status = NodeStatus.UP,
tokens = 256
)
// Fluent assertions with domain language
assertThat(node)
.hasNodeId("node1")
.isInDatacenter("dc1")
.isUp()
.hasTokenCount(256)
}
Project-Wide Assertions Helper
Create a central assertions class to provide access to all custom assertions:
// MyProjectAssertions.kt
object MyProjectAssertions {
// Cassandra domain assertions
fun assertThat(actual: CassandraNode?): CassandraNodeAssert {
return CassandraNodeAssert(actual)
}
fun assertThat(actual: Host?): HostAssert {
return HostAssert(actual)
}
fun assertThat(actual: TFState?): TFStateAssert {
return TFStateAssert(actual)
}
// Add more domain assertions as needed
}
Then import statically in tests:
import com.rustyrazorblade.easydblab.assertions.MyProjectAssertions.assertThat
@Test
fun `test complex scenario`() {
val node = createTestNode()
val host = createTestHost()
// All domain assertions available through single import
assertThat(node).isUp()
assertThat(host).hasPrivateIp("10.0.0.1")
}
Benefits of Custom Assertions
- Domain-Driven Design: Tests use business language, not implementation details
- Refactoring Safety: Changes to class internals don't break test logic
- Readability: Tests read like specifications
- Reusability: Common assertions are centralized
- Maintainability: Single place to update assertion logic
- Type Safety: Compile-time checking of assertion methods
When to Create Custom Assertions
Create custom assertions for:
- Domain entities (e.g.,
Host,TFState,CassandraNode) - Complex value objects with multiple properties
- Classes that appear in multiple test scenarios
- Any class where you find yourself writing repetitive assertion code
Testing Best Practices
-
Test Names: Use descriptive names with backticks
@Test fun `should start cassandra node when status is DOWN`() { } -
Test Structure: Follow Arrange-Act-Assert pattern
@Test fun `test node startup`() { // Arrange val node = createTestNode(status = NodeStatus.DOWN) // Act val result = nodeManager.startNode(node) // Assert assertThat(result).isUp() } -
Mock External Dependencies: Always mock AWS, SSH, and other external services
class MyTest : BaseKoinTest() { override fun additionalTestModules() = listOf( module { single { mockRemoteOperationsService() } } ) } -
Test Edge Cases: Include tests for error conditions and boundary cases
-
Keep Tests Focused: Each test should verify one specific behavior
Testing Interactive Commands with TestPrompter
Commands that require user input (like profile setup) can be tested deterministically using TestPrompter. This test utility replaces the real Prompter interface and returns predefined responses.
Basic Usage
class MyCommandTest : BaseKoinTest() {
private lateinit var testPrompter: TestPrompter
override fun additionalTestModules() = listOf(
module {
single<Prompter> { testPrompter }
}
)
@BeforeEach
fun setup() {
// Configure responses - keys can be exact matches or partial matches
testPrompter = TestPrompter(
mapOf(
"email" to "test@example.com",
"region" to "us-west-2",
"AWS Access Key" to "AKIAIOSFODNN7EXAMPLE",
)
)
}
@Test
fun `should collect user credentials`() {
// Run command that prompts for input
val command = SetupProfile()
command.call()
// Verify prompts were called
assertThat(testPrompter.wasPromptedFor("email")).isTrue()
assertThat(testPrompter.wasPromptedFor("region")).isTrue()
}
}
Response Matching
TestPrompter supports two matching strategies:
- Exact match: The question text matches a key exactly
- Partial match: The question text contains the key (case-insensitive)
val prompter = TestPrompter(
mapOf(
// Exact match - only matches "email" exactly
"email" to "test@example.com",
// Partial match - matches any question containing "AWS Profile"
"AWS Profile" to "my-profile",
)
)
Sequential Responses for Retry Testing
For testing retry logic (e.g., credential validation failures), use addSequentialResponses():
@Test
fun `should retry on invalid credentials`() {
testPrompter = TestPrompter()
// First call returns invalid credentials, second returns valid ones
testPrompter.addSequentialResponses(
"AWS Access Key",
"invalid-key", // First attempt
"AKIAVALIDKEY123" // Second attempt (after retry)
)
testPrompter.addSequentialResponses(
"AWS Secret",
"invalid-secret",
"valid-secret-key"
)
val command = SetupProfile()
command.call()
// Verify the command handled retry correctly
val callLog = testPrompter.getCallLog()
val accessKeyCalls = callLog.filter { it.question.contains("Access Key") }
assertThat(accessKeyCalls).hasSize(2)
}
Verifying Prompt Behavior
TestPrompter records all prompt calls for verification:
@Test
fun `should not prompt for credentials when using AWS profile`() {
testPrompter = TestPrompter(
mapOf(
"AWS Profile" to "my-profile", // Non-empty = use profile auth
)
)
val command = SetupProfile()
command.call()
// Verify credential prompts were skipped
assertThat(testPrompter.wasPromptedFor("Access Key")).isFalse()
assertThat(testPrompter.wasPromptedFor("Secret")).isFalse()
// Check detailed call log
val callLog = testPrompter.getCallLog()
assertThat(callLog).anyMatch { it.question.contains("email") }
}
TestPrompter API Reference
| Method | Description |
|---|---|
prompt(question, default, secret) | Returns configured response or default |
addSequentialResponses(key, vararg responses) | Configure different responses for retry scenarios |
getCallLog() | Returns list of all prompt calls with details |
wasPromptedFor(questionContains) | Check if any prompt contained the given text |
clear() | Reset call log and sequential state |
PromptCall Data Class
Each recorded call contains:
question: The prompt question textdefault: The default value offeredsecret: Whether input was masked (for passwords)returnedValue: The value that was returned