Skip to content

Commit

Permalink
Read/write table data to a file (#3197)
Browse files Browse the repository at this point in the history
* 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
4 people committed Oct 28, 2022
1 parent 0ab1b52 commit 632ac87
Show file tree
Hide file tree
Showing 10 changed files with 585 additions and 44 deletions.
@@ -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 ""
}
}
```
@@ -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)
}
}
}
})
@@ -0,0 +1,3 @@
id | username | fullName
4 | jmfayard | Jean-Michel Fayard
6 | louis | Louis Caugnault
@@ -0,0 +1,3 @@
id | username
4 | jmfayard
6 | louis
@@ -0,0 +1,3 @@
id | username | fullName
4 | jmfayard | Jean-Michel Fayard
6 | louis | Louis Caugnault
@@ -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("\\\\", "\\")
}
}
}
}

0 comments on commit 632ac87

Please sign in to comment.