TableTest allows you to express how the system is expected to behave through multiple examples in a concise table format. This reduces the amount of test code and makes it easier to understand, extend, and maintain your tests.
TableTest-style test methods are declared using the @TableTest annotation. The annotation accepts a table of data as a text block (Java 15+), a string array (Java 8–14), a Kotlin raw string, or as an external resource.
@TableTest("""
Scenario | Year | Is leap year?
Years not divisible by 4 | 2001 | false
Years divisible by 4 | 2004 | true
Years divisible by 100 but not by 400 | 2100 | false
Years divisible by 400 | 2000 | true
""")
public void leapYearCalculation(Year year, boolean expectedResult) {
assertEquals(expectedResult, year.isLeap(), "Year " + year);
}On Java 8–14, where text blocks are unavailable, pass each row as an element of a string array:
@TableTest({
"Scenario | Year | Is leap year?",
"Years not divisible by 4 | 2001 | false",
"Years divisible by 4 | 2004 | true",
"Years divisible by 100 but not by 400 | 2100 | false",
"Years divisible by 400 | 2000 | true"
})
public void leapYearCalculation(Year year, boolean expectedResult) {
assertEquals(expectedResult, year.isLeap(), "Year " + year);
}Each array element represents one row. Empty string elements and comment lines (//) are ignored.
Tables use the pipe character (|) to separate columns and newline to separate rows. The first row is the header containing column names. The following lines are data rows.
Column values can be single values, lists, sets, or maps. Values are automatically converted to the type of the corresponding test parameter.
Each data row will invoke the test method with the cell values being passed as arguments. Optionally, the first column may contain a scenario name describing the situation being exemplified by each row. There must be a test method parameter for each value column (scenario name column excluded). Columns map to parameters based strictly on order, so the first value column maps to the first parameter, the second value column to the second parameter, etc. The column header names and parameter names can be different, but keeping them aligned improves readability.
Technically @TableTest is a JUnit @ParameterizedTest with a custom-format argument source. Like regular JUnit test methods, @TableTest methods must not be private or static and must not return a value.
The TableTest format supports four types of values: single values, lists, sets, and maps.
Single values can appear with or without quotes. Surrounding single (') or double (") quotes are required when the value contains a | character, or starts with [ or {. For single values appearing as elements in a list, set, or map (see below), the characters ,, :, ], and } also make the value require quotes to be successfully parsed.
Whitespace around unquoted values is trimmed. To preserve leading or trailing whitespace, use quotes. Empty values are represented by adjacent quote pairs ("" or ''). Null values are represented by a blank cell.
@TableTest("""
Value | Length?
Hello, world! | 13
"cat file.txt | wc -l" | 20
"[]" | 2
'' | 0
""")
void testString(String value, int expectedLength) {
assertEquals(expectedLength, value.length());
}Lists are enclosed in square brackets with comma-separated elements. Lists can contain single values or compound values (nested lists/sets/maps). Empty lists are represented by []. Lists also convert to array parameter types — see Array Parameters.
@TableTest("""
List | size? | sum?
[] | 0 | 0
[1] | 1 | 1
[3, 2, 1] | 3 | 6
""")
void integerList(List<Integer> list, int expectedSize, int expectedSum) {
assertEquals(expectedSize, list.size());
assertEquals(expectedSum, list.stream().mapToInt(Integer::intValue).sum());
}Sets are enclosed in curly braces with comma-separated elements. Sets can contain single values or compound values (nested lists/sets/maps). Empty sets are represented by {}.
@TableTest("""
Set | Size?
{1, 2, 3, 2, 1} | 3
{Hello, Hello} | 1
{} | 0
""")
void testSet(Set<String> set, int expectedSize) {
assertEquals(expectedSize, set.size());
}Maps use square brackets with comma-separated key-value pairs. Colons separate keys and values. Keys can be unquoted or quoted. Unquoted keys cannot contain characters ,, :, |, [, ], {, }, ', or ". To use keys containing these characters or whitespace, wrap them in single or double quotes. Values can be single (unquoted/quoted) or compound (list/set/map). Empty maps are represented by [:].
@TableTest("""
Map | Size?
[one: 1, two: 2, three: 3] | 3
["key with spaces": value] | 1
['key:with:colons': value, plain: 2] | 2
[:] | 0
""")
void testMap(Map<String, Integer> map, int expectedSize) {
assertEquals(expectedSize, map.size());
}TableTest supports nesting compound types (lists, sets, maps).
@TableTest("""
Student grades | Highest grade? | Average grade? | Pass count?
[Alice: [95, 87, 92], Bob: [78, 85, 90], Charlie: [98, 89, 91]] | 98 | 89.4 | 3
[David: [45, 60, 70], Emma: [65, 70, 75], Frank: [82, 78, 60]] | 82 | 67.2 | 2
[:] | 0 | 0.0 | 0
""")
void testNestedParameterizedTypes(
Map<String, List<Integer>> studentGrades,
int expectedHighestGrade,
double expectedAverageGrade,
int expectedPassCount
) {
Students students = fromGradesMap(studentGrades);
assertEquals(expectedHighestGrade, students.highestGrade());
assertEquals(expectedAverageGrade, students.averageGrade(), 0.1);
assertEquals(expectedPassCount, students.passCount());
}TableTest will attempt to convert values to the type required by the test method parameter. This eliminates the need for manual conversion in your test method, keeping tests focused on invoking the system under test and asserting the results.
Out of the box, TableTest is able to convert single values to many of the standard types. For this it uses JUnit's built-in type converters. Please see the JUnit documentation for a list of supported types and the required format for each.
@TableTest("""
Number | Text | Date | Class
1 | abc | 2025-01-20 | java.lang.Integer
""")
void singleValues(short number, String text, LocalDate date, Class<?> type) {
// test implementation
}TableTest will also use this automatic conversion for single values appearing as elements in lists and sets, and for values in a map when the test method parameter is a parameterized type. Map keys remain String type and are not converted.
Automatic conversion for parameterized types works even for nested values:
@TableTest("""
Grades | Highest Grade?
[Alice: [95, 87, 92], Bob: [78, 85, 90]] | 95
[Charlie: [98, 89, 91], David: [45, 60, 70]] | 98
""")
void testParameterizedTypes(Map<String, List<Integer>> grades, int expectedHighestGrade) {
// test implementation
}List syntax also converts to array parameter types. This works for object arrays, primitive arrays, nested arrays, and arrays of generic types:
@TableTest("""
Scenario | Numbers | Sum?
Single | [5] | 5
Multiple | [1, 2, 3] | 6
Empty | [] | 0
""")
void testIntArray(int[] numbers, int sum) {
assertEquals(sum, Arrays.stream(numbers).sum());
}Nested arrays and arrays of parameterized types are also supported:
@TableTest("""
Scenario | Grid | Rows?
2x2 | [[a, b], [c, d]] | 2
1x3 | [[x, y, z]] | 1
""")
void testNestedArray(String[][] grid, int rows) {
assertEquals(rows, grid.length);
}In Kotlin, array types use Array<T> for object arrays and IntArray, LongArray, DoubleArray for primitives:
@TableTest("""
Scenario | Numbers | Sum?
Single | [5] | 5
Multiple | [1, 2, 3] | 6
Empty | [] | 0
""")
fun testIntArray(numbers: IntArray, sum: Int) {
assertEquals(sum, numbers.sum())
}Before falling back to built-in conversion, TableTest will look for a custom converter method present in either the test class, or in a class listed by a @TypeConverterSources annotation. Custom converter methods are tagged with the @TypeConverter annotation. If found, TableTest will use this method to convert the parsed value to the required test parameter type.
A custom converter method will be used when it:
- Is annotated with
@TypeConverter - Is defined as a
public staticmethod in apublic class - Accepts exactly one parameter
- Returns an object of the target parameter type
- Is the only
@TypeConvertermethod matching the above criteria in the class
There is no specific naming pattern for custom converter methods, any method fulfilling the requirements above will be considered.
Having selected a custom converter method with a return type matching the test parameter type, TableTest will consider if the value matches the parameter type of the converter method. If it doesn't, TableTest will recursively attempt to convert the value to match the parameter type using the same mechanism.
In the example below, the map with student grades is being passed to the test as a StudentGrades domain value using the toStudentGrades custom converter method:
@TableTest("""
Grades | Highest Grade?
[Alice: [95, 87, 92], Bob: [78, 85, 90]] | 95
[Charlie: [98, 89, 91], David: [45, 60, 70]] | 98
""")
void testParameterizedTypes(StudentGrades grades, int expectedHighestGrade) {
// test implementation
}
@TypeConverter
public static StudentGrades toStudentGrades(Map<String, List<Integer>> grades) {
// conversion logic
}To enable reuse of custom converter methods across test classes, TableTest provides a class annotation @TypeConverterSources to list alternative classes to search for custom converter methods:
@TypeConverterSources({ClassWithFCustomConverters.class, AnotherClassWithCustomConverters.class})
public class ExampleTest {
//TableTest methods
}For tests written in Kotlin, there are two locations to declare static @TypeConverter methods local to the test class:
- In the companion object of a test class using
@JvmStaticannotation in addition to@TypeConverter. - At package-level in the file containing the test class.
TableTest supports both locations in a single test class.
Dedicated custom converter sources in Kotlin should be declared object with custom converter methods annotated with @JvmStatic and @TypeConverter:
object KotlinTypeConverterSource {
@JvmStatic
@TypeConverter
fun toStudentGrades(input: Map<String, List<Int>>): StudentGrades {
// implementation
}
}Usage:
@TypeConverterSources(KotlinTypeConverterSource::class)
class ExampleTest {
// TableTest methods
}Alternatively, regular Kotlin classes with custom converter methods defined as @JvmStatic and @TypeConverter in a companion object can also be referenced as a custom converter source.
TableTest uses the following strategy to search for custom converters in Java test classes:
- Search current test class, including inherited methods
- In case of a
@Nestedtest class, search enclosing classes, starting with the direct outer class - Search classes listed in a
@TypeConverterSourcesannotation on current test class in the order they are listed - In case of a
@Nestedtest class, search classes listed by@TypeConverterSourcesof enclosing classes, starting with the direct outer class
TableTest will stop searching as soon as it finds a matching custom converter method and use this for the conversion.
Kotlin does not support inheritance of static, companion object methods. Also, a @Nested test class must be declared inner class and these are not allowed to have companion objects. Hence, test class custom converters must be either declared in the companion object of the outer class (with @JvmStatic in addition to @TypeConverter) or at package level in the same file as the test class .
So for Kotlin, the search strategy becomes as follows:
- Search the current file (methods declared at package-level or in outer class companion object)
- Search classes listed by a
@TypeConverterSourcesannotation on current test class in the order they are listed - In case of a
@Nestedtest class, search classes listed by@TypeConverterSourcesof enclosing classes, starting with direct outer
As for Java, TableTest will stop searching as soon as it finds a matching @TypeConverter method and use this for the conversion.
As TableTest will prefer using a custom converter over the built-in conversion, it is possible to override the built-in conversion of specific types. The example below demonstrates this, allowing conversion to LocalDate to understand certain custom constant values.
@TableTest("""
This Date | Other Date | Is Before?
today | tomorrow | true
today | yesterday | false
2024-02-29 | 2024-03-01 | true
""")
void testIsBefore(LocalDate thisDate, LocalDate otherDate, boolean expectedIsBefore) {
assertEquals(expectedIsBefore, thisDate.isBefore(otherDate));
}
@TypeConverter
public static LocalDate parseLocalDate(String input) {
return switch (input) {
case "yesterday" -> LocalDate.parse("2025-06-06");
case "today" -> LocalDate.parse("2025-06-07");
case "tomorrow" -> LocalDate.parse("2025-06-08");
default -> LocalDate.parse(input);
};
}TableTest contains a number of other useful features for expressing examples in a table format.
TableTest supports providing a descriptive name for each row that will be used as the test display name. This makes the tests easier to understand and failures easier to diagnose.
@TableTest("""
Scenario | Year | Is leap year?
Years not divisible by 4 | 2001 | false
Years divisible by 4 | 2004 | true
Years divisible by 100 but not by 400 | 2100 | false
Years divisible by 400 | 2000 | true
""")
public void testLeapYear(Year year, boolean expectedResult) {
assertEquals(expectedResult, year.isLeap());
}Optionally, the scenario column can also be included as a test parameter. It will then need a @Scenario annotation to be picked up as display name. If additional arguments are provided by JUnit parameter resolvers (TestInfo, TestReporter, etc.), declaring a parameter for the scenario name column is required.
@TableTest("""
Scenario | Year | Is leap year?
Years not divisible by 4 | 2001 | false
Years divisible by 4 | 2004 | true
Years divisible by 100 but not by 400 | 2100 | false
Years divisible by 400 | 2000 | true
""")
public void testLeapYear(@Scenario String scenario, Year year, boolean expectedResult) {
assertEquals(expectedResult, year.isLeap(), "Failed for " + scenario);
}Blank cells translate to null for all parameter types except primitives. For primitives, it will cause an exception as they cannot represent a null value.
@TableTest("""
String | Integer | List | Map | Set
| | | |
""")
void blankConvertsToNull(String string, Integer integer, List<?> list, Map<String, ?> map, Set<?> set) {
assertNull(string);
assertNull(integer);
assertNull(list);
assertNull(map);
assertNull(set);
}TableTest supports using the set format to specify multiple examples of values that are applicable for the current scenario. This is a powerful feature that can be used to contract multiple rows that have identical expectations.
When a value is a set (enclosed in curly braces) and the corresponding parameter isn't declared as a Set type, TableTest will create multiple test invocations for this row, one for each value in the set.
In the example below, the test method will be invoked 12 times, three times for each row, once for each value in the set in column Example years.
@TableTest("""
Scenario | Example years | Is leap year?
Years not divisible by 4 | {2001, 2002, 2003} | false
Years divisible by 4 | {2004, 2008, 2012} | true
Years divisible by 100 but not by 400 | {2100, 2200, 2300} | false
Years divisible by 400 | {2000, 2400, 2800} | true
""")
public void testLeapYear(Year year, boolean expectedResult) {
assertEquals(expectedResult, year.isLeap(), "Year " + year);
}Using scenario names in combination with value sets, the display name will be the scenario name plus a description of the actual value used from the set for this invocation. This makes it easier to pinpoint which values caused problems in case of test failures.
Multiple values in the same row can be specified as value sets. TableTest will then perform a cartesian product, generating test invocations for all possible combinations of values. The example below will invoke the test method 6 times for each row, with each possible combination of the provided x and y values.
@TableTest("""
Scenario | x | y | even sum?
Even plus even | {2, 4, 6} | {8, 10} | true
Odd plus even | {1, 3, 5} | {6, 8} | false
""")
void testEvenOddSums(int x, int y, boolean expectedResult) {
boolean isEvenSum = (x + y) % 2 == 0;
assertEquals(expectedResult, isEvenSum);
}Use value sets judiciously. The number of test cases grows multiplicatively with each additional set (two sets of size 10 generate 100 test invocations), which can significantly increase test execution time.
Sets are only expanded when the parameter type doesn't match Set<?>. When the parameter is declared as a set type, the entire set is passed as a single argument:
@TableTest("""
Values | Size?
{1, 2, 3} | 3
{a, b, c, d} | 4
{} | 0
""")
void testSetParameter(Set<String> values, int expectedSize) {
assertEquals(expectedSize, values.size());
}Lines starting with // (ignoring leading whitespace) are treated as comments and ignored. Comments allow adding explanations or temporarily disabling data rows.
Blank lines are also ignored and can be used to visually group related rows.
@TableTest("""
String | Length?
Hello world | 11
// The next row is currently disabled
// "World, hello" | 12
// Special characters must be quoted
'|' | 1
'[:]' | 3
""")
void testComment(String string, int expectedLength) {
assertEquals(expectedLength, string.length());
}As an alternative to specifying the table as a multi-line string in the annotation, you can load it from an external file using the resource attribute. The file is located as a resource relative to the test class and is typically stored in the test resources directory or one of its subdirectories.
By default, the file is assumed to use UTF-8 encoding. If your file uses a different encoding, specify it with the encoding attribute.
@TableTest(resource = "/external.table")
void testExternalTable(int a, int b, int sum) {
assertEquals(sum, a + b);
}
@TableTest(resource = "/custom-encoding.table", encoding = "ISO-8859-1")
void testExternalTableWithCustomEncoding(String string, int expectedLength) {
assertEquals(expectedLength, string.length());
}TableTest method parameters correspond to columns following a one-to-one correlation between table column index and method parameter index (scenario name column can be excluded). For TableTest methods to receive additional arguments provided by a ParameterResolver (TestInfo, TestReporter, etc.), these must be declared last. Also, if the table includes a scenario name column, this now needs an explicit parameter with @Scenario annotation:
@TableTest("""
Scenario | value | double?
Zero | 0 | 0
Two | 2 | 4
""")
void testDoubleValue(@Scenario String scenario, int value, int expectedResult, TestInfo info) {
assertEquals(expectedResult, 2 * value);
assertNotNull(info);
}Escape sequence handling varies depending on the programming language used for the test.
When providing the table using text blocks in Java, all Java escape sequences like \t, \", \\, \uXXXX, \XXX, etc. are processed by the Java compiler before handed to TableTest:
@TableTest("""
Scenario | Input | Length?
Tab character processed by compiler | a\tb | 3
Quote marks processed by compiler | Say \"hi\" | 8
Backslash processed by compiler | path\\file | 9
Unicode character processed by compiler | \u0041B | 2
Octal character processed by compiler | \101B | 2
""")
void testEscapeSequences(String input, int expectedLength) {
assertEquals(expectedLength, input.length());
}When providing the table as a string array, each element is a regular Java string literal. Escape sequences are processed by the Java compiler, behaving identically to text blocks:
@TableTest({
"Scenario | Input | Length?",
"Tab character processed by compiler | a\tb | 3",
"Quote marks processed by compiler | Say \"hi\" | 8",
"Backslash processed by compiler | path\\file | 9"
})
void testEscapeSequences(String input, int expectedLength) {
assertEquals(expectedLength, input.length());
}Using Kotlin raw strings, escape sequences are not processed. They remain as literal backslash characters:
@TableTest(
"""
Scenario | Input | Length?
Tab character NOT processed by compiler | a\tb | 4
Quote marks NOT processed by compiler | Say \"hi\" | 10
Backslash NOT processed by compiler | path\\file | 10
Unicode character NOT processed by compiler | \u0041B | 7
Octal character NOT processed by compiler | \101B | 5
""")
fun testEscapeSequences(input: String, expectedLength: Int) {
assertEquals(expectedLength, input.length)
}Table files are read as raw text independent of the programming language, meaning escape sequences are not processed and remain literal.
If you need special characters in Kotlin or external Table files, you have three options:
- Use actual characters instead of escape sequences
- Use Kotlin regular strings for simple cases
- Consider switching to Java for tests requiring complex escape sequences.
In addition to implicitly called custom converter methods and built-in conversion, TableTest supports JUnit explicit argument conversion. This can be used for explicit conversion to custom types.
As there is no parameter type information available in the ArgumentConverter interface, custom ArgumentConverters will receive the parsed value. In the example below, the value of the source parameter received by PersonConverter.convert will be of type Map<String, String>. However, since the ArgumentConverter interface specifies source parameter as type Object, the value needs to be inspected and processed using instanceof.
@TableTest("""
Person | AgeCategory?
[name: Fred, age: 22] | ADULT
[name: Wilma, age: 19] | TEEN
""")
void testExplicitConversion(
@ConvertWith(PersonConverter.class) Person person,
AgeCategory expectedAgeCategory
) {
assertEquals(expectedAgeCategory, person.ageCategory());
}
record Person(String firstName, String lastName, int age) {
AgeCategory ageCategory() {
return AgeCategory.of(age);
}
}
enum AgeCategory {
CHILD, TEEN, ADULT;
static AgeCategory of(int age) {
if (age < 13) return AgeCategory.CHILD;
if (age < 20) return AgeCategory.TEEN;
return AgeCategory.ADULT;
}
}
private static class PersonConverter implements ArgumentConverter {
@Override
public Object convert(Object source, ParameterContext context) throws ArgumentConversionException {
if (source instanceof Map attributes) {
return new Person(
(String) attributes.getOrDefault("name", "Fred"),
"Flintstone",
Integer.parseInt((String) attributes.getOrDefault("age", "16"))
);
}
throw new ArgumentConversionException("Cannot convert " + source.getClass().getSimpleName() + " to Person");
}
}