Verdict
No
Do not test the status text that a program prints for the person running it; unit test the values that the text reports.
Why
- Blast radiusinternal
- Change frequencyrarely
- Detectabilitysame-day
- Reversibilitytrivial
- Test costmoderate
I do not test the text a program prints for the person running it; I test the values behind the text. For status lines such as Imported 120 rows, Blast radius is internal, because the reader is on your team and no customer sees the terminal. Detectability is same-day: the next person who runs the command sees a missing or garbled line. Reversibility is trivial, since a console line leaves nothing behind, and Change frequency is rarely. Test cost is moderate, because the test captures a global stream such as System.out and breaks whenever someone rewords a message.
| When | Decision | Why |
|---|---|---|
| Scripts parse the output, such as a column that a deploy script reads with awk | Test minimally: one test that asserts the column order or the JSON field names | Blast radius rises to users, Detectability to eventually and Reversibility to with-effort: a moved column feeds a plausible wrong value to the script |
| The printed result is the feature of a command-line tool that customers install | Test: capture the output and assert on the result lines | Blast radius rises to users, Change frequency to regularly, Detectability to eventually and Reversibility to with-effort |
| The program prints its configuration, and CI stores the console output of every run | Test mandatory: assert that tokens and passwords appear masked | Blast radius rises to safety-or-legal, Detectability to never and Reversibility to impossible: a secret in a stored log is public |
| The summary line is the only place an operator learns how many rows an import skipped | Test: make one row fail and assert that the summary reports it as skipped | Blast radius rises to users, Detectability to never and Reversibility to costly: a wrong count hides skipped rows, which raise no error and are repaired by hand. Change frequency stays rarely, because the summary code seldom changes |
| The prints are debug lines left from a debugging session | Do not test debug prints; delete them or move them to a logger | Blast radius falls to none, since only the developer reads the lines, so the decision stays Do not test |
What breaks if you don't test
A refactor prints the result object instead of its count, and the terminal shows Imported ImportResult@1b6d3586. The developer who runs the import next sees the line and fixes it in a minute. Nothing is stored, and the database rows do not change. The failure that hides is a wrong number inside a correct-looking line, and it lives in the code that computes the number.
What you lose if you over-test
A test that calls System.setOut swaps a stream for the whole JVM. In the Stack Overflow question behind this page, a teardown that called System.setOut(null) made the coverage run fail with a NullPointerException in Cobertura's thread. Under parallel execution, JUnit 5 needs such tests marked with @ResourceLock(Resources.SYSTEM_OUT), or they read each other's output. Each assertion also pins the wording, so a fixed typo fails the build.
What to do instead
Keep print statements at the edge of the program and move every value they show into a function that returns it. Unit test that function: assert that the import returns 120 imported rows and 3 skipped rows. For a pass or fail signal, return an exit code, as the Command Line Interface Guidelines ask. Delete debug prints. Keep code review and a look at the output after each change.
When the answer changes
- A CI step greps the output, a script reads a column with
awk, or users pipe it intojq. Add a--jsonflag or an exit code and test those. - The printed result is the feature of a tool that customers install. Capture it with the pytest
capsysfixture and assert on the result lines. - CI or a log collector stores console output that includes configuration. Test that secrets appear masked.
Real incident + Code example
The checker that CI read with grep
On a monorepo I worked on, CI failed the build when grep '^FAIL' found a line in the output of our migration checker. A developer restyled the output with coloured markers, and no test covered the text. Grep matched nothing, and for 12 days CI passed branches with broken migrations, until one with a syntax error failed on staging and blocked the release. The fix moved the signal to the exit code:
def main(argv: list[str]) -> int:
failures = check_migrations(Path(argv[0]))
for failure in failures:
print(f"x {failure.name}: {failure.reason}", file=sys.stderr)
return 1 if failures else 0
def test_broken_migration_fails_the_build(tmp_path):
(tmp_path / "0042_drop_email.sql").write_text("ALTER TABLE users DROP COLUMN;")
assert main([str(tmp_path)]) == 1
The print line stays untested, so anyone can restyle the message without breaking the build.
Related questions
FAQ
- Should we unit test console outputs?
Do not unit test console output that only reports progress; unit test the values that the output shows. Capture the output only when another program parses it or when users rely on the printed result.
- Should I test System.out.println calls to reach 100% coverage?
Do not write a test only to cover a
System.out.printlnline, because the test checks the JDK's print method and pins the wording. Move the printed value into a function that a test covers, and exclude the print line from the coverage gate.- How do I capture console output in a test?
In Python, the pytest
capsysfixture captures stdout and stderr, andcapsys.readouterr()returns both. In Java, saveSystem.out, redirect it withSystem.setOut, and restore the saved stream in teardown, because a stream left redirected breaks later tests.- Should I test the output of my command-line tool?
Test a command-line tool's output when the printed result is the feature or when scripts parse it. Assert on the result lines or the
--jsonoutput and the exit code.