The standard IO extension adds a simple way to test classes that read from the standard input (System.in) or write to the standard output (System.out or System.err). It redirects the original input/output streams to make predefined lines readable from System.in and to inject arguments that capture what was written to System.out or System.err for assertions.

Warning

Depending on the configuration, the extension redirects the standard input and/or output, in which case nothing gets forwarded to the original System.in and/or System.out / System.err. This becomes particularly important when running tests in parallel, where other tests may interfere with tests annotated with @StdIo. More on that in the section Thread-Safety.

Basic use

The extension consists of two parts:

  • The annotation @StdIo enables the extension and optionally defines input that is read from System.in without having to wait for user input.

  • The parameters StdIn, StdOut, and StdErr can be injected into your test. Their capturedString() and capturedLines() methods allow you to access the strings read from System.in or written to System.out or System.err, so you can verify them with common assertions.

For example, after calling System.out.println("Hello") and System.out.println("World"), StdOut::capturedString would return "Hello%nWorld%n". With System.out.print("Hello") and System.out.println("World") (note that the first method does not print a line break), the method would return "HelloWorld%n". (In both cases, %n corresponds to System.getProperty("line.separator").)

Captured strings and lines

Since the strings passed to @StdIo or System.out/err.print/println don’t need to end with a line separator, the question arises how they are separated for the purpose of capturedString(), i.e. how does the method’s return distinguish between println("Hello"); println("World"); and print("Hello"); println("World");?

Important

This extension operates under the basic assumption that every string passed to @StdIo, System.out.println, or System.err.println implicitly ends with a line separator and hence adds it explicitly to the string returned by capturedString(). This is not the case for strings passed to System.out.print and System.err.print.

The classes StdIn, StdOut, and StdErr also offer a method String[] capturedLines(), which divides the captured string on the line separator and includes leading, inner, and trailing empty lines but does not include the potential empty string that comes after a trailing line separator. (The exact algorithm is based on but behaves differently from String::split.)

Warning

The combination of these two approaches leads to the unfortunate situation that capturedLines() can’t be used to distinguish the cases where the last line was created by a print or a println (see the first two lines in the table below) because regardless of whether the string ends with a line separator or not, there will be no trailing empty string in the array. Use capturedString() to distinguish these cases.

Here are some examples to illustrate this behavior (%n corresponds to System.getProperty("line.separator")):

prints capturedString() capturedLines()

println("A");

"A%n"

["A"]

print("A");

"A"

["A"]

print("A"); println("B");

"AB%n"

["AB"]

println("A"); println("B");

"A%nB%n"

["A", "B"]

println("A"); println(); println("B");

"A%n%nB%n"

["A", "", "B"]

println(); println("A"); println();

"%nA%n%n"

["", "A", ""]

Valid configurations

Here are the valid combinations of the annotation (with or without values for the read lines) and parameters:

@StdIo("…​")

In this case System.in gets replaced and the code under test will read the specified lines (in the snippet, that’s just the line "…​").

@Test
@StdIo({ "Hello", "World" })
void stdinReplaceInput() {
    // `System.in` is replaced and the code under
    // test reads lines "Hello" and "World"
}
@StdIo("…​") and StdIn

Like before, but the lines read by the code under test can be verified with the StdIn parameter.

@Test
@StdIo({ "Hello", "World" })
void stdinReplaceAndVerify(StdIn in) {
    // `System.in` is replaced, the code under
    // test reads lines "Hello" and "World",
    // and `StdIn` can be used to verify that
}
@StdIo and StdOut

System.in is not replaced (because no input lines are defined), but System.out is, so the written lines can be verified with the StdOut parameter.

@Test
@StdIo
void stdinNotReplacedButStdout(StdOut out) {
    // `System.out` is replaced, so the written lines
    // are captured and can be verified with `StdOut`
}
@StdIo("…​") and StdOut

System.in is replaced, so the code can read the input lines and System.out is replaced, so the written lines can be verified with the StdOut parameter.

@Test
@StdIo()
void bothReplaced(StdOut out) {
    // `System.in` and `System.out` are replaced
    // and written lines can be verified with `StdOut`
}
@StdIo("…​"), StdIn, and StdOut

A combination of the two previous cases - System.in and System.out get replaced.

@Test
@StdIo({ "Hello", "World" })
void bothReplaceAndVerify(StdIn in, StdOut out) {
    // `System.in` is replaced, the code under
    // test reads lines "Hello" and "World",
    // and `StdIn` can be used to verify that;
    // `System.out` is also replaced, so the
    // written lines can be verified with `StdOut`
}
Note
Omitted from these examples is StdErr which behaves exactly the same way as StdOut and works in combination with it.

The remaining combinations of the annotation, its values, and StdIn/StdOut/StdErr are considered misconfigurations and lead to exceptions.

Thread-Safety

Since System.in and System.out are global state, reading and writing them during parallel test execution can lead to unpredictable results and flaky tests. The @StdIo extension is prepared for that and tests annotated with it will never execute in parallel (thanks to resource locks) to guarantee correct test results.

However, this does not cover all possible cases. Tested code that reads System.in or System.out or calls System.setIn() or System.setOut() independently of the extensions can still run in parallel to them and may thus behave erratically if, for example, it unexpectedly uses System.out that was set by the extension in another thread. Tests that cover code that reads or writes System.in, System.out, or System.err need to be annotated with the respective annotation:

  • @ReadsStdIo

  • @WritesStdIo

Tests annotated in this way will never execute in parallel with tests annotated with @StdIo.

Edge cases and unexpected behavior

Empty input (with or without StdIn)

Using just @StdIo does not redirect System.in, which means code reading from it will still block until input is provided and a StdIn parameter can’t be resolved. To have the code under test read empty input and/or use StdIn to verify that, use @StdIo("").

Unexpected behavior with eager/buffering readers

Some readers read all lines from StdIo eagerly (e.g.: BufferedReader) which can lead to unexpected behavior. Take the following example:

class ConsoleReader {

    private List<String> lines = new ArrayList<>();

    public void readLines() throws IOException {
        InputStreamReader is = new InputStreamReader(System.in);
        BufferedReader reader = new BufferedReader(is);
        for (int i = 0; i < 2; i++) {
            String line = reader.readLine();
            lines.add(line);
        }
    }

}

This is a straightforward example class. It reads two lines from System.in, using a BufferedReader. This is the unit test for this class, using StdIoExtension:

class ConsoleReaderTest {

    @Test
    @StdIo({ "line1", "line2", "line3" })
    void testReadLines(StdIn in) throws IOException {
        ConsoleReader consoleReader = new ConsoleReader();

        consoleReader.readLines();

        String[] lines = in.capturedLines();

        // This is failing
        // assertEquals(lines, "line1", "line2");

        // This is passing
        // assertEquals(lines, "line1", "line2", "line3");
    }

}

The underlying BufferedReader eagerly reads all three supplied lines during the first readLine call in the loop (that’s why it’s called buffered reader). This means that the assertion fails, because in.capturedLines() contains three lines - even though consoleReader.lines only contains two.