Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Read/write table data to a file #3197

Merged
merged 21 commits into from Oct 28, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -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
jmfayard marked this conversation as resolved.
Show resolved Hide resolved
```

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"),
jmfayard marked this conversation as resolved.
Show resolved Hide resolved
source = testResources.resolve("users.table"),
transform = { a: String, b: String, c: String ->
row(a.toInt(), b, c)
}
jmfayard marked this conversation as resolved.
Show resolved Hide resolved
)
```

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()
}
}
jmfayard marked this conversation as resolved.
Show resolved Hide resolved

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)
}
Kantis marked this conversation as resolved.
Show resolved Hide resolved
}
}
})
@@ -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("\\\\", "\\")
}
}
}
}