Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Read/write table data to a file (#3197)
* Add doc for writing/reading table data to a file #3179 Readme-oriented programming to make sure the proposed API make sense * ✨table(headers3,fileContent) + StringTable All rows must have the right number of columns * ✨table happy path * 🥚table: '|' can be escaped with '\|' * ♻️move to StringTable * ✨validating table files * ✨table: reading from a file * ✨table: table.writeToFile() happy path * ✨map.toTable() * ✨table(headers(...), list.map { row(...) }) * ✨file.writeTable(headers, rows) * ✅file.write(headers, rows): validation * ✅escape pipe * 🎉 tables are now aligned * 👌escape escape, update doc * 👌better error message in rowsShouldHaveSize(int) * 👌separatorRegex * ✨up to 9 generics for table(rows), table(transform) table.mapRows Co-authored-by: Jean-Michel Fayard <459464+jmfayard@users.noreply.github.com> Co-authored-by: Sam <sam@sksamuel.com> Co-authored-by: Emil Kantis <emil.kantis@protonmail.com>
- Loading branch information
1 parent
0ab1b52
commit 632ac87
Showing
10 changed files
with
585 additions
and
44 deletions.
There are no files selected for viewing
96 changes: 96 additions & 0 deletions
96
documentation/versioned_docs/version-5.4/assertions/table_driven_testing.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 "" | ||
} | ||
} | ||
``` |
224 changes: 224 additions & 0 deletions
224
...ons/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/data/StringTableTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String, String, String> { | ||
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) | ||
} | ||
} | ||
} | ||
}) |
Empty file.
3 changes: 3 additions & 0 deletions
3
...assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-extension.csv
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
id | username | fullName | ||
4 | jmfayard | Jean-Michel Fayard | ||
6 | louis | Louis Caugnault |
3 changes: 3 additions & 0 deletions
3
...-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-invalid-header.table
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
id | username | ||
4 | jmfayard | ||
6 | louis |
3 changes: 3 additions & 0 deletions
3
kotest-assertions/kotest-assertions-core/src/jvmTest/resources/table/users-valid.table
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
id | username | fullName | ||
4 | jmfayard | Jean-Michel Fayard | ||
6 | louis | Louis Caugnault |
63 changes: 63 additions & 0 deletions
63
...t-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/data/StringTable.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
package io.kotest.data | ||
|
||
import io.kotest.assertions.fail | ||
|
||
internal data class StringTable( | ||
val headers: List<String>, | ||
val lines: List<String>, | ||
val skipFirstLine: Boolean = false, // for files | ||
) { | ||
|
||
fun <T> mapRows(fn: (List<String>) -> T): List<T> = | ||
rows.map { fn(it.value) } | ||
|
||
val rows: List<IndexedValue<List<String>>> = | ||
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<String> { | ||
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("\\\\", "\\") | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.