diff --git a/documentation/versioned_docs/version-5.4/assertions/table_driven_testing.md b/documentation/versioned_docs/version-5.4/assertions/table_driven_testing.md new file mode 100644 index 00000000000..302aad2c6af --- /dev/null +++ b/documentation/versioned_docs/version-5.4/assertions/table_driven_testing.md @@ -0,0 +1,96 @@ +# Table-driven testing + +> TK: what current exists is not really documented anywhere, am I right? + +## Define a table in code + +You can define a table of data that will be used for your test like this: + +```kotlin +val table = table( + headers("id", "name", "username"), + row(4, "Jean-Michel Fayard", "jmfayard"), + row(6, "Louis CAD", "LouisCAD"), +) +``` + +It's now easy to run your asserts for all rows of the table: + +## Run asserts forAll rows of the table + +```kotlin +test("table-driven testing") { + table.forAll { id, name, username -> + id shouldBeGreaterThan 0 + username shouldNotBe "" + } +} +``` + +The test will not fail at the first error. Instead, it will always run on all the rows, and report multiple errors if they are present. + +Defining a table of data in code is convenient... until you start to have too much rows. + +## Export a table to a text file + +You can export your data to a text file with the `.table` extension. + +```kotlin +val tableFile = testResources.resolve("users.table") +table.writeTo(tableFile) +``` + +The `users.table` file looks like this: + +```csv +id | username | fullName +4 | jmfayard | Jean-Michel Fayard +6 | louis | Louis Caugnault +``` + +Curious why it's not just a .csv file? + +Well CSV is not a well defined format. Everyone has its flavor and we have too. The `.table` has its rules: + +- it always uses `|` as separator +- it must have an header +- cells are trimmed and cannot contain new lines +- it can have comments and blank lines + +Basically it's optimized for putting table data in a `.table` file. + +We hope you don't use Microsoft Excel to edit the CSV-like file. IntelliJ with the [CSV plugin from Martin Sommer](https://plugins.jetbrains.com/plugin/10037-csv) does that better. You can associate the `.table` extension with it and configure `|` as your CSV separator. It has a table edition mode too! + +Now that your table data lives in a file, it's time to read it! + +## Read table from files and execute your asserts + +Here is how you read your `.table` file: + +```kotlin +val tableFromFile = table( + headers = headers("id", "username", "fullName"), + source = testResources.resolve("users.table"), + transform = { a: String, b: String, c: String -> + row(a.toInt(), b, c) + } +) +``` + +The arguments are: +- the file where your table is stored +- the same headers as before: `headers("id", "username", "fullName")` +- a lambda to convert from strings (everything is a string in the text file) to the typed row you had before + + +The rest works just like before: + +```kotlin +test("table-driven testing from the .table file") { + // asserts like before + tableFromFile.forAll { id, name, username -> + id shouldBeGreaterThan 0 + username shouldNotBe "" + } +} +``` diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/data/StringTableTest.kt b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/data/StringTableTest.kt new file mode 100644 index 00000000000..a1decf0d6f5 --- /dev/null +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/data/StringTableTest.kt @@ -0,0 +1,224 @@ +package com.sksamuel.kotest.data + +import io.kotest.assertions.throwables.shouldThrowMessage +import io.kotest.core.spec.style.FunSpec +import io.kotest.data.Row3 +import io.kotest.data.headers +import io.kotest.data.mapRows +import io.kotest.data.row +import io.kotest.data.table +import io.kotest.data.toTable +import io.kotest.data.writeTable +import io.kotest.engine.spec.tempfile +import io.kotest.matchers.shouldBe +import java.io.File + +class StringTableTest : FunSpec({ + + val headers = headers("id", "username", "fullName") + val transform = { a: String, b: String, c: String -> + row(a.toInt(), b, c) + } + val expectedTable = table( + headers, + row(4, "jmfayard", "Jean-Michel Fayard"), + row(6, "louis", "Louis Caugnault"), + ) + + val validFileContent = """ + 4 | jmfayard | Jean-Michel Fayard + 6 | louis | Louis Caugnault + """.trimIndent() + + context("creating tables from collections") { + test("create table from map") { + val map = mapOf( + "fr" to "French", + "es" to "Spanish" + ) + val table = map.toTable(headers("code", "language")) + table shouldBe table( + headers("code", "language"), + row("fr", "French"), + row("es", "Spanish"), + ) + } + + val languagesTable = table( + headers("code", "name", "english"), + row("fr", "Français", "French"), + row("es", "Español", "Spanish"), + ) + + data class Language(val code: String, val english: String, val name: String) + + val languages = listOf( + Language("fr", "French", "Français"), + Language("es", "Spanish", "Español"), + ) + + test("create table from list") { + val table = table( + headers("code", "name", "english"), + languages.map { row(it.code, it.name, it.english) } + ) + table shouldBe languagesTable + } + } + + test("happy path") { + table(headers, validFileContent, transform) shouldBe expectedTable + } + + test("empty lines and comments starting with # are accepted") { + val fileContent = """ + |4 | jmfayard | Jean-Michel Fayard + |# this is a comment + |# newlines are allowed + | + |6 | louis | Louis Caugnault + """.trimMargin() + val table = table(headers, fileContent, transform) + + table shouldBe expectedTable + } + + test("All rows must have the right number of columns") { + val invalidRows = """ + 4 | jmfayard | Jean-Michel Fayard + 5 | victor | Victor Hugo | victor.hugo@guernesey.co.uk + 6 | louis | Louis Caugnault + 7 | edgar + """.trimIndent() + + val expectedMessage = """ +Expected all rows to have 3 columns, but 2 rows differed +- Row 1 has 4 columns: [5, victor, Victor Hugo, victor.hugo@guernesey.co.uk] +- Row 3 has 2 columns: [7, edgar] + """.trimIndent() + + shouldThrowMessage(expectedMessage) { + table(headers, invalidRows, transform) + } + } + + test("The '|' character can be escaped") { + val fileContent = """ + 1 | prefix\|middle\|suffix | hello\|world + 2 | prefix\suffix | nothing + 3 | prefix\\|suffix + """.trimIndent() + table(headers, fileContent, transform) shouldBe table( + headers, + row(1, "prefix|middle|suffix", "hello|world"), + row(2,"prefix\\suffix", "nothing"), + row(3, "prefix\\", "suffix"), + ) + } + + val resourcesDir = File("src/jvmTest/resources/table") + + test("happy path for reading a table from a file") { + val file = resourcesDir.resolve("users-valid.table") + table(headers, file, transform) shouldBe expectedTable + } + + test("Validating table files") { + + shouldThrowMessage("Can't read table file") { + val file = resourcesDir.resolve("users-does-not-exist.table") + table(headers, file, transform) + } + + shouldThrowMessage("Table file must have a .table extension") { + val file = resourcesDir.resolve("users-invalid-extension.csv") + table(headers, file, transform) + } + + shouldThrowMessage("Table file must have a header") { + val file = resourcesDir.resolve("users-invalid-empty.table") + table(headers, file, transform) + } + + shouldThrowMessage( + """ + Missing elements from index 2 + expected:<["id", "username", "fullName"]> but was:<["id", "username"]> + """.trimIndent() + ) { + val file = resourcesDir.resolve("users-invalid-header.table") + table(headers, file, transform) + } + } + + data class UserInfo(val username: String, val fullName: String) + val usersTable = table( + headers("id", "UserInfo"), + row(4, UserInfo("jmfayard", "Jean-Michel Fayard")), + row(6, UserInfo("louis", "Louis Caugnault")) + ) + + context("file.writeTable - success") { + val expectedFileContent = """ +id | username | fullName +4 | jmfayard | Jean-Michel Fayard +6 | louis | Louis Caugnault + """.trim() + + test("happy path") { + val file = tempfile(suffix = ".table") + val rows = usersTable.mapRows { (id, userInfo) -> + row(id.toString(), userInfo.username, userInfo.fullName) + } + val fileContent = file.writeTable(headers("id", "username", "fullName"), rows) + file.readText() shouldBe expectedFileContent + fileContent shouldBe expectedFileContent + } + + test("columns should be aligned") { + fun row(i: Int): Row3 { + val value = "$i".repeat(i) + return row(value, value, value) + } + + val table = table( + headers("a", "b", "c"), + row(2), + row(4), + row(6), + ) + tempfile(suffix = ".table").writeTable(table.headers, table.rows) shouldBe """ +a | b | c +22 | 22 | 22 +4444 | 4444 | 4444 +666666 | 666666 | 666666 + """.trimIndent() + } + + test("| should be escaped") { + val table = mapOf("greeting" to "Hello || world").toTable() + val file = tempfile(suffix = ".table") + file.writeTable(table.headers, table.rows) shouldBe """ + key | value + greeting | Hello \|\| world + """.trimIndent() + } + } + + context("file.writeTable - validation") { + test("Table file must have a .table extension") { + shouldThrowMessage(testCase.name.testName) { + val fileMissingTableExtension = tempfile() + fileMissingTableExtension.writeTable(usersTable.headers, emptyList()) + } + } + + test("Cells con't contain new lines") { + val tableWithNewLines = + mapOf("1" to "one\n", "two" to "two", "three" to "three\nthree").toTable() + shouldThrowMessage(testCase.name.testName) { + tempfile(suffix = ".table").writeTable(tableWithNewLines.headers, tableWithNewLines.rows) + } + } + } +}) diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-empty.table b/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-empty.table new file mode 100644 index 00000000000..e69de29bb2d diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-extension.csv b/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-extension.csv new file mode 100644 index 00000000000..b50ed0bc3ef --- /dev/null +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-extension.csv @@ -0,0 +1,3 @@ +id | username | fullName +4 | jmfayard | Jean-Michel Fayard +6 | louis | Louis Caugnault diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-header.table b/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-header.table new file mode 100644 index 00000000000..fe7bf6629ae --- /dev/null +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-header.table @@ -0,0 +1,3 @@ +id | username +4 | jmfayard +6 | louis diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-valid.table b/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-valid.table new file mode 100644 index 00000000000..b50ed0bc3ef --- /dev/null +++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-valid.table @@ -0,0 +1,3 @@ +id | username | fullName +4 | jmfayard | Jean-Michel Fayard +6 | louis | Louis Caugnault diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/StringTable.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/StringTable.kt new file mode 100644 index 00000000000..96786cd0c95 --- /dev/null +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/StringTable.kt @@ -0,0 +1,63 @@ +package io.kotest.data + +import io.kotest.assertions.fail + +internal data class StringTable( + val headers: List, + val lines: List, + val skipFirstLine: Boolean = false, // for files +) { + + fun mapRows(fn: (List) -> T): List = + rows.map { fn(it.value) } + + val rows: List>> = + lines + .withIndex() + .filterNot { (index, line) -> + val skipHeader = index == 0 && skipFirstLine + skipHeader || line.startsWith("#") || line.isBlank() + } + .map { (index, line) -> IndexedValue(index, parseRow(line)) } + + init { + rowsShouldHaveSize(headers.size) + } + + private fun rowsShouldHaveSize(size: Int) { + val maxRows = 5 + val invalidRows = rows + .filter { it.value.size != size } + val formattedRows = invalidRows + .take(maxRows) + .joinToString("\n") { (i, row) -> + "- Row $i has ${row.size} columns: $row" + } + val andMore = if (invalidRows.size <= maxRows) "" else "... and ${invalidRows.size - maxRows} other rows" + + if (invalidRows.isNotEmpty()) fail( + """ + |Expected all rows to have $size columns, but ${invalidRows.size} rows differed + |$formattedRows + |$andMore + """.trimMargin().trim() + ) + } + + companion object { + val separatorRegex = Regex("([\\\\]{2}|[^\\\\])\\|") + + internal fun parseRow(line: String): List { + val trimmed = line.replace(" ", "") + return line + .split(separatorRegex) + .map { + val cell = it.trim() + val suffix = if ("$cell\\\\|" in trimmed) "\\" else "" + cell.plus(suffix) + .replace("\\|", "|") + .replace("\\\\", "\\") + } + } + } +} diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/rows.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/rows.kt index 7ac33b0f6be..2f76b39a81c 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/rows.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/rows.kt @@ -23,90 +23,94 @@ fun row(a: A, b: B, fun row(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L, m: M, n: N, o: O, p: P, q: Q, r: R, s: S, t: T, u: U) = Row21(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u) fun row(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L, m: M, n: N, o: O, p: P, q: Q, r: R, s: S, t: T, u: U, v: V) = Row22(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v) -data class Row1(val a: A) { - fun values() = listOf(a) +interface Row { + fun values(): List } -data class Row2(val a: A, val b: B) { - fun values() = listOf(a, b) +data class Row1(val a: A): Row { + override fun values() = listOf(a) } -data class Row3(val a: A, val b: B, val c: C) { - fun values() = listOf(a, b, c) +data class Row2(val a: A, val b: B): Row { + override fun values() = listOf(a, b) } -data class Row4(val a: A, val b: B, val c: C, val d: D) { - fun values() = listOf(a, b, c, d) +data class Row3(val a: A, val b: B, val c: C): Row { + override fun values() = listOf(a, b, c) } -data class Row5(val a: A, val b: B, val c: C, val d: D, val e: E) { - fun values() = listOf(a, b, c, d, e) +data class Row4(val a: A, val b: B, val c: C, val d: D): Row { + override fun values(): List = listOf(a, b, c, d) } -data class Row6(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F) { - fun values() = listOf(a, b, c, d, e, f) +data class Row5(val a: A, val b: B, val c: C, val d: D, val e: E): Row { + override fun values() = listOf(a, b, c, d, e) } -data class Row7(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G) { - fun values() = listOf(a, b, c, d, e, f, g) +data class Row6(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F): Row { + override fun values() = listOf(a, b, c, d, e, f) } -data class Row8(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H) { - fun values() = listOf(a, b, c, d, e, f, g, h) +data class Row7(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G): Row { + override fun values() = listOf(a, b, c, d, e, f, g) } -data class Row9(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I) { - fun values() = listOf(a, b, c, d, e, f, g, h, i) +data class Row8(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h) } -data class Row10(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j) +data class Row9(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i) } -data class Row11(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k) +data class Row10(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j) } -data class Row12(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l) +data class Row11(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k) } -data class Row13(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m) +data class Row12(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l) } -data class Row14(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n) +data class Row13(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m) } -data class Row15(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o) +data class Row14(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n) } -data class Row16(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p) +data class Row15(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o) } -data class Row17(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q) +data class Row16(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p) } -data class Row18(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r) +data class Row17(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q) } -data class Row19(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R, val s: S) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s) +data class Row18(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r) } -data class Row20(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R, val s: S, val t: T) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t) +data class Row19(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R, val s: S): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s) } -data class Row21(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R, val s: S, val t: T, val u: U) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u) +data class Row20(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R, val s: S, val t: T): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t) } -data class Row22(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R, val s: S, val t: T, val u: U, val v: V) { - fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v) +data class Row21(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R, val s: S, val t: T, val u: U): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u) +} + +data class Row22(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F, val g: G, val h: H, val i: I, val j: J, val k: K, val l: L, val m: M, val n: N, val o: O, val p: P, val q: Q, val r: R, val s: S, val t: T, val u: U, val v: V): Row { + override fun values() = listOf(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v) } diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/tables.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/tables.kt index fce13e882f2..37596b6aeda 100644 --- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/tables.kt +++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/tables.kt @@ -1,5 +1,29 @@ package io.kotest.data +fun Map.toTable( + headers: Headers2 = headers("key", "value"), +) = table(headers, entries.map { row(it.key, it.value) }) + +fun table(headers: Headers1, rows: List>) = Table1(headers, rows) +fun table(headers: Headers2, rows: List>) = Table2(headers, rows) +fun table(headers: Headers3, rows: List>) = Table3(headers, rows) +fun table(headers: Headers4, rows: List>) = Table4(headers, rows) +fun table(headers: Headers5, rows: List>) = Table5(headers, rows) +fun table(headers: Headers6, rows: List>) = Table6(headers, rows) +fun table(headers: Headers7, rows: List>) = Table7(headers, rows) +fun table(headers: Headers8, rows: List>) = Table8(headers, rows) +fun table(headers: Headers9, rows: List>) = Table9(headers, rows) + +fun Table1.mapRows(fn: (Row1) -> ARow): List = rows.map(fn) +fun Table2.mapRows(fn: (Row2) -> ARow): List = rows.map(fn) +fun Table3.mapRows(fn: (Row3) -> ARow): List = rows.map(fn) +fun Table4.mapRows(fn: (Row4) -> ARow): List = rows.map(fn) +fun Table5.mapRows(fn: (Row5) -> ARow): List = rows.map(fn) +fun Table6.mapRows(fn: (Row6) -> ARow): List = rows.map(fn) +fun Table7.mapRows(fn: (Row7) -> ARow): List = rows.map(fn) +fun Table8.mapRows(fn: (Row8) -> ARow): List = rows.map(fn) +fun Table9.mapRows(fn: (Row9) -> ARow): List = rows.map(fn) + fun table(headers: Headers1, vararg rows: Row1) = Table1(headers, rows.asList()) fun table(headers: Headers2, vararg rows: Row2) = Table2(headers, rows.asList()) fun table(headers: Headers3, vararg rows: Row3) = Table3(headers, rows.asList()) @@ -45,3 +69,53 @@ data class Table19(val headers: Headers20, val rows: List>) data class Table21(val headers: Headers21, val rows: List>) data class Table22(val headers: Headers22, val rows: List>) + + +fun table(headers: Headers1, fileContent: String, transform: (String) -> Row1) = Table1( + headers = headers, + rows = StringTable(headers.values(), fileContent.lines()) + .mapRows { transform(it[0]) } +) +fun table(headers: Headers2, fileContent: String, transform: (String, String) -> Row2) = Table2( + headers = headers, + rows = StringTable(headers.values(), fileContent.lines()) + .mapRows { transform(it[0], it[1]) } +) + +fun table(headers: Headers3, fileContent: String, transform:(String, String, String) -> Row3 +): Table3 = Table3( + headers = headers, + rows = StringTable(headers.values(), fileContent.lines()) + .mapRows { transform(it[0], it[1], it[2]) } +) + +fun table(headers: Headers4, fileContent: String, transform:(String, String, String, String) -> Row4) = Table4( + headers = headers, + rows = StringTable(headers.values(), fileContent.lines()) + .mapRows { transform(it[0], it[1], it[2], it[3])} +) +fun table(headers: Headers5, fileContent: String, transform:(String, String, String, String, String) -> Row5) = Table5( + headers = headers, + rows = StringTable(headers.values(), fileContent.lines()) + .mapRows { transform(it[0], it[1], it[2], it[3], it[4])} +) +fun table(headers: Headers6, fileContent: String, transform:(String, String, String, String, String, String) -> Row6) = Table6( + headers = headers, + rows = StringTable(headers.values(), fileContent.lines()) + .mapRows { transform(it[0], it[1], it[2], it[3], it[4],it[5]) } +) +fun table(headers: Headers7, fileContent: String, transform:(String, String, String, String, String, String, String) -> Row7) = Table7( + headers = headers, + rows = StringTable(headers.values(), fileContent.lines()) + .mapRows { transform(it[0], it[1], it[2], it[3], it[4],it[5], it[6])} +) +fun table(headers: Headers8, fileContent: String, transform:(String, String, String, String, String, String, String, String) -> Row8) = Table8( + headers = headers, + rows = StringTable(headers.values(), fileContent.lines()) + .mapRows { transform(it[0], it[1], it[2], it[3], it[4],it[5], it[6], it[7])} +) +fun table(headers: Headers9, fileContent: String, transform:(String, String, String, String, String, String, String, String, String) -> Row9) = Table9( + headers = headers, + rows = StringTable(headers.values(), fileContent.lines()) + .mapRows { transform(it[0], it[1], it[2], it[3], it[4],it[5], it[6], it[7], it[8])} +) diff --git a/kotest-assertions/kotest-assertions-shared/src/jvmMain/kotlin/io/kotest/data/TableFile.kt b/kotest-assertions/kotest-assertions-shared/src/jvmMain/kotlin/io/kotest/data/TableFile.kt new file mode 100644 index 00000000000..b71a75cc716 --- /dev/null +++ b/kotest-assertions/kotest-assertions-shared/src/jvmMain/kotlin/io/kotest/data/TableFile.kt @@ -0,0 +1,71 @@ +package io.kotest.data + +import io.kotest.matchers.shouldBe +import java.io.File + +// TODO I'm only supporting table with 3 arguments until the API stabilizes + +fun table( + headers: Headers3, + file: File, + transform: (String, String, String) -> Row3 +): Table3 { + val rows = file.readStringTable(headers.values()) + .mapRows { (a, b, c) -> transform(a, b, c) } + return Table3(headers, rows) +} + +fun File.writeTable(headers: Headers1, rows: List>): String = + writeTable(headers.values(), rows.map { it.strings() } ) +fun File.writeTable(headers: Headers2, rows: List>): String = + writeTable(headers.values(), rows.map { it.strings() } ) +fun File.writeTable(headers: Headers3, rows: List>): String = + writeTable(headers.values(), rows.map { it.strings() } ) +// TODO + +private fun Row.strings(): List = values().map { it.toString() } + + +internal fun File.readStringTable(headers: List): StringTable { + if (exists().not()) throw AssertionError("Can't read table file") + if (extension != "table") throw AssertionError("Table file must have a .table extension") + val lines = readLines() + if (lines.isEmpty()) throw AssertionError("Table file must have a header") + StringTable.parseRow(lines.first()) shouldBe headers + return StringTable(headers, lines, skipFirstLine = true) +} + +fun File.writeTable(headers: List, cells: List>): String { + if (extension != "table") throw AssertionError("Table file must have a .table extension") + val containsNewLines = cells.any { it.any { cell -> cell.contains("\n") } } + if (containsNewLines) throw AssertionError("Cells con't contain new lines") + + val columnSizes = headers.mapIndexed { index, header -> + val rowsSize = cells.map { it[index].length }.maxOrNull() ?: 0 + maxOf(header.length, rowsSize) + } + + fun String.formatCell(index: Int) = + this.plus(" ".repeat(maxOf(0, columnSizes[index] - length))) + + val separator = " | " + + val formattedHeader = headers + .mapIndexed { index, header -> header.formatCell(index) } + .joinToString(separator) + .trimEnd() + + val formattedContent = cells.joinToString("\n") { row -> + val list = row.mapIndexed { index, cell -> + cell.replace("|", "\\|").formatCell(index) + } + list.joinToString(separator).trimEnd() + } + + val fileContent = """ +$formattedHeader +$formattedContent + """.trimIndent() + writeText(fileContent) + return fileContent +}