Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ fun `valid URI`(uri: String, namespace: String, table: String) {
- See `DatastoreUriTest`, `V3NameValidatorTest`, `ObjectSourceTest` for more
patterns (shared fields, nested cases, JSON block scalars).

### Dense matrices (`@TableSource`)

When a test is a table of primitive values with many columns (state
transitions, combinatorial matrices), the repeated YAML keys of
`@ObjectSource` hurt density. Use `@TableSource` instead — `columns` declares
parameter names once, `rows` carries one test case per list:

```kotlin
@ObjectSourceParameterizedTest
@TableSource("""
columns: [from, event, expected]
rows:
- [IDLE, START, RUNNING]
- [RUNNING, STOP, IDLE]
""")
fun `transition`(from: State, event: Event, expected: State) { ... }
```

Still pure YAML. Same runner (`@ObjectSourceParameterizedTest`) and same
name-based parameter binding. Use `~` for null entries. For nested or
heterogeneous cases, prefer `@ObjectSource`.

## Other tests

Write whatever is clearest for the case at hand.
Expand Down
218 changes: 218 additions & 0 deletions core/src/test/kotlin/com/kakao/actionbase/test/TableSourceTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package com.kakao.actionbase.test

import com.kakao.actionbase.test.documentations.params.ObjectSourceExtension
import com.kakao.actionbase.test.documentations.params.ObjectSourceParameterizedTest
import com.kakao.actionbase.test.documentations.params.TableSource

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class TableSourceTest {
@ObjectSourceParameterizedTest
@TableSource(
"""
columns: [number, string]
rows:
- [1, foo]
- [2, bar]
- [3, baz]
""",
)
fun `columns become parameter names`(
number: Int,
string: String,
) {
assertEquals(
when (number) {
1 -> "foo"
2 -> "bar"
3 -> "baz"
else -> error("unexpected number $number")
},
string,
)
}

@ObjectSourceParameterizedTest
@TableSource(
"""
columns: [a, b, c]
rows:
- [1, 2, 3]
- [4, ~, 6]
""",
)
fun `tilde maps to null for nullable parameters`(
a: Int,
b: Int?,
c: Int,
) {
when (a) {
1 -> assertEquals(2, b)
4 -> assertNull(b)
else -> error("unexpected a=$a")
}
assertEquals(a + 2, c)
}

@ObjectSourceParameterizedTest
@TableSource(
"""
columns: [from, event, expected]
rows:
- [IDLE, START, RUNNING]
- [RUNNING, STOP, IDLE]
""",
)
fun `enum columns bind by name`(
from: Status,
event: Event,
expected: Status,
) {
val result =
when (from to event) {
Status.IDLE to Event.START -> Status.RUNNING
Status.RUNNING to Event.STOP -> Status.IDLE
else -> error("unexpected $from + $event")
}
assertEquals(expected, result)
}

@Nested
inner class NestedClassTest {
@ObjectSourceParameterizedTest
@TableSource(
"""
columns: [a, b]
rows:
- [1, 2]
""",
)
fun `nested test classes work`(
a: Int,
b: Int,
) {
assertEquals(1, a)
assertEquals(2, b)
}
}

@Nested
inner class ErrorTest {
private val extension = ObjectSourceExtension()

@Test
fun `blank value is rejected`() {
val e =
assertThrows<IllegalArgumentException> {
extension.parseTableSource(TableSource(""))
}
assertTrue(e.message!!.contains("value"))
}

@Test
fun `missing columns is rejected`() {
val e =
assertThrows<IllegalStateException> {
extension.parseTableSource(
TableSource(
"""
rows:
- [1, 2]
""".trimIndent(),
),
)
}
assertTrue(e.message!!.contains("columns"))
}

@Test
fun `missing rows is rejected`() {
val e =
assertThrows<IllegalStateException> {
extension.parseTableSource(
TableSource(
"""
columns: [a, b]
""".trimIndent(),
),
)
}
assertTrue(e.message!!.contains("rows"))
}

@Test
fun `row size mismatch is rejected`() {
val e =
assertThrows<IllegalArgumentException> {
extension.parseTableSource(
TableSource(
"""
columns: [a, b, c]
rows:
- [1, 2]
""".trimIndent(),
),
)
}
assertTrue(e.message!!.contains("row size"))
assertTrue(e.message!!.contains("columns size"))
}

@Test
fun `non-string column entry is rejected`() {
val e =
assertThrows<IllegalArgumentException> {
extension.parseTableSource(
TableSource(
"""
columns: [a, 2]
rows:
- [1, 2]
""".trimIndent(),
),
)
}
assertTrue(e.message!!.contains("must be strings"))
}

@Test
fun `row that is not a list is rejected`() {
val e =
assertThrows<IllegalArgumentException> {
extension.parseTableSource(
TableSource(
"""
columns: [a, b]
rows:
- not-a-list
""".trimIndent(),
),
)
}
assertTrue(e.message!!.contains("must be a list"))
}

@Test
fun `empty rows list produces no test cases`() {
val result =
extension.parseTableSource(
TableSource(
"""
columns: [a, b]
rows: []
""".trimIndent(),
),
)
assertEquals(0, result.size)
}
}

enum class Status { IDLE, RUNNING }

enum class Event { START, STOP }
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,37 @@ import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider
import com.fasterxml.jackson.module.kotlin.readValue

class ObjectSourceExtension : TestTemplateInvocationContextProvider {
override fun supportsTestTemplate(context: ExtensionContext): Boolean = context.requiredTestMethod.isAnnotationPresent(ObjectSource::class.java)
override fun supportsTestTemplate(context: ExtensionContext): Boolean {
val method = context.requiredTestMethod
return method.isAnnotationPresent(ObjectSource::class.java) ||
method.isAnnotationPresent(TableSource::class.java)
}

override fun provideTestTemplateInvocationContexts(context: ExtensionContext): Stream<TestTemplateInvocationContext> {
val annotation = context.requiredTestMethod.getAnnotation(ObjectSource::class.java)
val method = context.requiredTestMethod
val objectSource = method.getAnnotation(ObjectSource::class.java)
val tableSource = method.getAnnotation(TableSource::class.java)

require(objectSource == null || tableSource == null) {
"@ObjectSource and @TableSource cannot be applied to the same method"
}

val testCases: List<Map<String, Any?>> =
when {
objectSource != null -> parseObjectSource(objectSource)
tableSource != null -> parseTableSource(tableSource)
else -> error("Either @ObjectSource or @TableSource must be present")
}

val parameterNames = getParameterNames(context.requiredTestClass, context.requiredTestMethod.name)

return testCases
.mapIndexed { index, testCase ->
ObjectSourceInvocationContext(index + 1, testCases.size, parameterNames, testCase) as TestTemplateInvocationContext
}.stream()
}

private fun parseObjectSource(annotation: ObjectSource): List<Map<String, Any?>> {
require(annotation.value.isBlank() || annotation.cases.isBlank()) {
"@ObjectSource: specify either 'value' or 'cases', not both"
}
Expand All @@ -31,19 +57,36 @@ class ObjectSourceExtension : TestTemplateInvocationContextProvider {
val allFields: Map<String, Any?> =
if (annotation.shared.isNotBlank()) ObjectMappers.YAML.readValue(annotation.shared) else emptyMap()

val mergedCases =
if (allFields.isNotEmpty()) {
testCases.map { allFields + it }
} else {
testCases
}
return if (allFields.isNotEmpty()) {
testCases.map { allFields + it }
} else {
testCases
}
}

val parameterNames = getParameterNames(context.requiredTestClass, context.requiredTestMethod.name)
fun parseTableSource(annotation: TableSource): List<Map<String, Any?>> {
require(annotation.value.isNotBlank()) { "@TableSource: 'value' must be provided" }

return mergedCases
.mapIndexed { index, testCase ->
ObjectSourceInvocationContext(index + 1, mergedCases.size, parameterNames, testCase) as TestTemplateInvocationContext
}.stream()
val parsed: Map<String, Any?> = ObjectMappers.YAML.readValue(annotation.value)

val columns =
(parsed["columns"] as? List<*>)?.map {
require(it is String) { "@TableSource: 'columns' entries must be strings, got ${it?.javaClass}" }
it
} ?: error("@TableSource: 'columns' key is required and must be a list of strings")

val rows =
(parsed["rows"] as? List<*>)?.map {
require(it is List<*>) { "@TableSource: each row must be a list, got ${it?.javaClass}" }
it
} ?: error("@TableSource: 'rows' key is required and must be a list of lists")

return rows.map { row ->
require(row.size == columns.size) {
"@TableSource: row size ${row.size} does not match columns size ${columns.size}: $row"
}
columns.zip(row).toMap()
}
}

private fun getParameterNames(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.kakao.actionbase.test.documentations.params

/**
* Dense-matrix data source for `@ObjectSourceParameterizedTest`.
*
* Body is YAML with two top-level keys — `columns` (list of parameter names)
* and `rows` (list of value lists). Each row is expanded into a test case
* whose keys come from `columns`. Use this when a test is a table of
* primitives where repeating YAML keys per case would hurt readability.
*
* For nested or heterogeneous data, use `@ObjectSource` instead.
*
* Example:
* ```
* @ObjectSourceParameterizedTest
* @TableSource("""
* columns: [from, event, expected]
* rows:
* - [IDLE, START, RUNNING]
* - [RUNNING, STOP, IDLE]
* """)
* fun `transition moves state`(from: State, event: Event, expected: State) { ... }
* ```
*/
@Retention
@Target(AnnotationTarget.FUNCTION)
annotation class TableSource(
val value: String,
)
Loading