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 |
Basic use
The extension consists of two parts:
-
The annotation
@StdIo
enables the extension and optionally defines input that is read fromSystem.in
without having to wait for user input. -
The parameters
StdIn
,StdOut
, andStdErr
can be injected into your test. TheircapturedString()
andcapturedLines()
methods allow you to access the strings read fromSystem.in
or written toSystem.out
orSystem.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 |
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 |
Here are some examples to illustrate this behavior (%n
corresponds to System.getProperty("line.separator")
):
prints | capturedString() |
capturedLines() |
---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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("…")
andStdIn
-
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
andStdOut
-
System.in
is not replaced (because no input lines are defined), butSystem.out
is, so the written lines can be verified with theStdOut
parameter.@Test @StdIo void stdinNotReplacedButStdout(StdOut out) { // `System.out` is replaced, so the written lines // are captured and can be verified with `StdOut` }
@StdIo("…")
andStdOut
-
System.in
is replaced, so the code can read the input lines andSystem.out
is replaced, so the written lines can be verified with theStdOut
parameter.@Test @StdIo() void bothReplaced(StdOut out) { // `System.in` and `System.out` are replaced // and written lines can be verified with `StdOut` }
@StdIo("…")
,StdIn
, andStdOut
-
A combination of the two previous cases -
System.in
andSystem.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
// assertThat(lines).containsExactly("line1", "line2");
// This is passing
// assertThat(lines).containsExactly("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.