diff --git a/TESTING.md b/TESTING.md index 0349ee09..b8757ca4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/core/src/test/kotlin/com/kakao/actionbase/test/TableSourceTest.kt b/core/src/test/kotlin/com/kakao/actionbase/test/TableSourceTest.kt new file mode 100644 index 00000000..28dbb79a --- /dev/null +++ b/core/src/test/kotlin/com/kakao/actionbase/test/TableSourceTest.kt @@ -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 { + extension.parseTableSource(TableSource("")) + } + assertTrue(e.message!!.contains("value")) + } + + @Test + fun `missing columns is rejected`() { + val e = + assertThrows { + extension.parseTableSource( + TableSource( + """ + rows: + - [1, 2] + """.trimIndent(), + ), + ) + } + assertTrue(e.message!!.contains("columns")) + } + + @Test + fun `missing rows is rejected`() { + val e = + assertThrows { + extension.parseTableSource( + TableSource( + """ + columns: [a, b] + """.trimIndent(), + ), + ) + } + assertTrue(e.message!!.contains("rows")) + } + + @Test + fun `row size mismatch is rejected`() { + val e = + assertThrows { + 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 { + 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 { + 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 } +} diff --git a/core/src/testFixtures/kotlin/com/kakao/actionbase/test/documentations/params/ObjectSourceExtension.kt b/core/src/testFixtures/kotlin/com/kakao/actionbase/test/documentations/params/ObjectSourceExtension.kt index 024463df..716ae933 100644 --- a/core/src/testFixtures/kotlin/com/kakao/actionbase/test/documentations/params/ObjectSourceExtension.kt +++ b/core/src/testFixtures/kotlin/com/kakao/actionbase/test/documentations/params/ObjectSourceExtension.kt @@ -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 { - 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> = + 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> { require(annotation.value.isBlank() || annotation.cases.isBlank()) { "@ObjectSource: specify either 'value' or 'cases', not both" } @@ -31,19 +57,36 @@ class ObjectSourceExtension : TestTemplateInvocationContextProvider { val allFields: Map = 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> { + 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 = 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( diff --git a/core/src/testFixtures/kotlin/com/kakao/actionbase/test/documentations/params/TableSource.kt b/core/src/testFixtures/kotlin/com/kakao/actionbase/test/documentations/params/TableSource.kt new file mode 100644 index 00000000..968f3f81 --- /dev/null +++ b/core/src/testFixtures/kotlin/com/kakao/actionbase/test/documentations/params/TableSource.kt @@ -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, +)