Learn Java Io Modern Io Resource Boundaries Part 030 Testing Io Systems
title: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries - Part 030 description: Testing Java IO systems with TempDir, fake filesystems, golden files, fault injection streams and channels, deterministic cleanup, parser fixtures, resource leak checks, and production-grade IO test strategy. series: learn-java-io-modern-io-resource-boundaries seriesTitle: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries order: 30 partTitle: Testing IO Systems tags:
- java
- io
- testing
- junit
- tempdir
- filesystem
- fault-injection
- golden-files
- resource-boundaries
- series date: 2026-06-30
Part 030 — Testing IO Systems
IO bugs hide in boundaries: partial reads, partial writes, path semantics, cleanup failure, encoding mismatch, file replacement races, and streams that fail after doing some work.
Testing IO systems is not just about verifying that a file exists. Production-grade IO tests ask harder questions:
What if read returns fewer bytes than requested?
What if write succeeds partially then fails?
What if output already exists?
What if temp cleanup fails?
What if filenames contain spaces, Unicode, or newline?
What if the filesystem is case-insensitive?
What if archive entries try to escape the target directory?
What if decoding fails halfway through the file?
This part gives you a testing strategy for IO code that is deterministic, fast enough for CI, and realistic enough to catch boundary bugs.
1. Learning Objectives
After this part, you should be able to:
- Use temporary directories and files safely in tests.
- Decide when to test against the real filesystem and when to use a fake filesystem.
- Build golden-file tests without making them brittle.
- Inject partial read/write, slow stream, and failure-after-N-bytes behavior.
- Test parser and framing code against malformed input.
- Test crash-consistency logic with failpoints.
- Validate cleanup and resource ownership.
- Avoid test pollution from working directory, default charset, locale, and line endings.
- Design IO APIs that are easy to test.
- Build an IO regression suite that catches real production failure modes.
2. Kaufman Skill Slice
The skill for this part is:
Given IO code that reads, writes, transforms, or transfers data, design tests that validate happy path, boundary semantics, partial failure, cleanup, and cross-environment behavior.
Break it down:
| Sub-skill | Production question |
|---|---|
| Isolation | Does each test get its own filesystem workspace? |
| Fixture design | Are test inputs realistic but minimal? |
| Path coverage | Are edge-case names and directory layouts tested? |
| Stream behavior | Does code handle partial read/write? |
| Failure injection | Can failures happen after some bytes are processed? |
| Cleanup | Are temp files, streams, locks, and processes released? |
| Encoding | Are charset and malformed input behavior explicit? |
| Atomicity | Are staging and commit steps tested independently? |
| Portability | Does the test depend on OS-specific semantics? |
| Diagnostics | Do failures show enough information to debug? |
3. The IO Test Pyramid
Not all IO tests need the same realism.
A practical distribution:
| Layer | Purpose | Speed | Example |
|---|---|---|---|
| Pure parser/encoder | Validate format logic | very fast | byte array to records |
| Fault injection stream/channel | Validate partial/failing IO | fast | stream throws after 100 bytes |
| Fake filesystem | Validate path logic | fast | Jimfs Unix/Windows config |
| Real filesystem | Validate actual OS/provider behavior | medium | atomic move, permissions, symlink |
| Production-like integration | Validate tool/container/runtime assumptions | slower | external process, real mounted volume |
The mistake is relying only on real filesystem happy-path tests. They rarely exercise partial stream behavior.
4. Temporary Directory Discipline
Use a fresh temporary directory per test.
With JUnit Jupiter:
class FileIngestorTest {
@TempDir
Path tempDir;
@Test
void ingestsFileIntoStagingThenCommit() throws IOException {
Path input = tempDir.resolve("input.txt");
Files.writeString(input, "hello", StandardCharsets.UTF_8);
Path output = tempDir.resolve("out.txt");
new FileIngestor().ingest(input, output);
assertEquals("hello", Files.readString(output, StandardCharsets.UTF_8));
}
}
Rules:
- Never write test artifacts into the project root.
- Never depend on the JVM working directory unless that is what you are testing.
- Do not reuse the same temp directory across tests unless testing shared-state behavior.
- Assert meaningful file content, not just existence.
- Use explicit charsets.
- Avoid
deleteOnExit()in tests; it hides leaks until JVM exit.
5. What to Test on the Real Filesystem
Some semantics require the real filesystem or a specific provider.
Test with real filesystem for:
| Behavior | Why fake may be insufficient |
|---|---|
| atomic move | Provider/OS support differs |
| file locking | Platform semantics differ |
| symlink behavior | Requires OS permissions and provider support |
| POSIX permissions | Not available on all filesystems |
| case sensitivity | OS/filesystem dependent |
| timestamp precision | Provider dependent |
| directory fsync | Platform-dependent behavior |
| process redirects | Need real files |
Example atomic move contract test:
@Test
void safeReplaceCreatesFinalFileOnlyAfterCompleteWrite(@TempDir Path dir) throws IOException {
Path target = dir.resolve("target.dat");
Path temp = dir.resolve("target.dat.tmp");
Files.writeString(temp, "complete", StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
Files.move(temp, target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
assertEquals("complete", Files.readString(target, StandardCharsets.UTF_8));
assertFalse(Files.exists(temp));
}
Do not assume ATOMIC_MOVE is supported everywhere. Test your target deployment environment when the guarantee matters.
6. Fake Filesystems
A fake filesystem is useful when you want fast, isolated path behavior without touching the host filesystem.
Jimfs is a commonly used in-memory filesystem that implements Java NIO filesystem APIs.
Example:
try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) {
Path root = fs.getPath("/work");
Files.createDirectory(root);
Path file = root.resolve("input.txt");
Files.writeString(file, "hello", StandardCharsets.UTF_8);
assertEquals("hello", Files.readString(file, StandardCharsets.UTF_8));
}
Use fake filesystem for:
- path normalization logic
- containment checks
- directory traversal tests
- large numbers of small path cases
- Unix vs Windows path assumptions
- avoiding host filesystem pollution
Do not use fake filesystem as your only proof for:
- OS-specific permissions
- actual disk durability
- real symlink permissions
- file locking semantics
- process redirect behavior
7. Path Edge Cases
Your tests should include filenames that reflect real hostile or weird data.
Examples:
normal.txt
with space.txt
unicode-東京.txt
semi;colon.txt
quote'file.txt
brackets[1].txt
dots...txt
.hidden
UPPER.txt
upper.txt
nested/a/b/c.txt
Be careful with path names containing slash, backslash, colon, newline, or NUL. Not all filesystems allow them. For parser-level tests, you can test logical names without creating real files.
Containment test:
static Path resolveInside(Path root, String userName) throws IOException {
Path resolved = root.resolve(userName).normalize();
if (!resolved.startsWith(root)) {
throw new IOException("path escapes root: " + userName);
}
return resolved;
}
@Test
void rejectsParentTraversal(@TempDir Path root) {
assertThrows(IOException.class, () -> resolveInside(root, "../escape.txt"));
}
Stronger variant when symlinks matter:
Path realRoot = root.toRealPath(LinkOption.NOFOLLOW_LINKS);
Path realCandidateParent = candidate.getParent().toRealPath(LinkOption.NOFOLLOW_LINKS);
That belongs in tests only if the production contract follows symlinks or prevents symlink escape.
8. Golden Files
Golden files are expected-output fixtures committed to the repository.
Good for:
- binary encoder output
- textual report output
- serialized compatibility fixtures
- archive structure output
- parser regression cases
Bad golden files are huge and impossible to understand.
Guidelines:
- Keep golden files small.
- Name them by scenario.
- Store input and expected output together.
- Provide a documented regeneration command if output intentionally changes.
- Normalize line endings when comparing text unless line ending is part of the contract.
- For binary files, compare bytes and show hex diff snippets on failure.
Example structure:
src/test/resources/golden/invoice-report/
input.json
expected.txt
README.md
Text comparison with explicit charset:
String expected = Files.readString(expectedPath, StandardCharsets.UTF_8)
.replace("\r\n", "\n");
String actual = report.replace("\r\n", "\n");
assertEquals(expected, actual);
Binary comparison:
byte[] expected = Files.readAllBytes(expectedPath);
byte[] actual = encoder.encode(value);
assertArrayEquals(expected, actual);
For large binary diffs, write a helper that reports:
first differing offset
expected byte
actual byte
nearby hex window
length difference
9. Fault Injection Streams
Real InputStream.read(byte[]) is allowed to return fewer bytes than requested. Your tests should prove your code handles that.
9.1 One-byte-at-a-time input
final class OneByteAtATimeInputStream extends FilterInputStream {
OneByteAtATimeInputStream(InputStream delegate) {
super(delegate);
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
return super.read(b, off, Math.min(len, 1));
}
}
Test:
@Test
void parserHandlesPartialReads() throws IOException {
byte[] frame = makeFrame("hello".getBytes(StandardCharsets.UTF_8));
try (InputStream in = new OneByteAtATimeInputStream(new ByteArrayInputStream(frame))) {
assertEquals("hello", parser.readFrame(in));
}
}
9.2 Failure after N bytes
final class FailingInputStream extends FilterInputStream {
private final long failAfter;
private long read;
FailingInputStream(InputStream delegate, long failAfter) {
super(delegate);
this.failAfter = failAfter;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
if (read >= failAfter) {
throw new IOException("injected read failure after " + failAfter + " bytes");
}
int allowed = (int) Math.min(len, failAfter - read);
int n = super.read(b, off, allowed);
if (n > 0) {
read += n;
}
return n;
}
}
Use it to assert:
- partial outputs are not committed
- temp files are cleaned or quarantined
- exception type preserves cause
- metrics/logging distinguish read failure from format failure
9.3 Failing output stream
final class FailingOutputStream extends FilterOutputStream {
private final long failAfter;
private long written;
FailingOutputStream(OutputStream delegate, long failAfter) {
super(delegate);
this.failAfter = failAfter;
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
if (written >= failAfter) {
throw new IOException("injected write failure after " + failAfter + " bytes");
}
int allowed = (int) Math.min(len, failAfter - written);
out.write(b, off, allowed);
written += allowed;
if (allowed < len) {
throw new IOException("injected partial write failure");
}
}
@Override
public void write(int b) throws IOException {
if (written >= failAfter) {
throw new IOException("injected write failure after " + failAfter + " bytes");
}
out.write(b);
written++;
}
}
This is how you test staging/commit correctness.
10. Fault Injection Channels
If your production code uses ByteBuffer and WritableByteChannel, stream-only tests are insufficient.
Partial-write channel:
final class PartialWritableByteChannel implements WritableByteChannel {
private final WritableByteChannel delegate;
private final int maxBytesPerWrite;
private boolean open = true;
PartialWritableByteChannel(WritableByteChannel delegate, int maxBytesPerWrite) {
this.delegate = delegate;
this.maxBytesPerWrite = maxBytesPerWrite;
}
@Override
public int write(ByteBuffer src) throws IOException {
int originalLimit = src.limit();
int allowed = Math.min(src.remaining(), maxBytesPerWrite);
src.limit(src.position() + allowed);
try {
return delegate.write(src);
} finally {
src.limit(originalLimit);
}
}
@Override
public boolean isOpen() {
return open && delegate.isOpen();
}
@Override
public void close() throws IOException {
open = false;
delegate.close();
}
}
Test loop correctness:
while (buffer.hasRemaining()) {
channel.write(buffer);
}
A single channel.write(buffer) is not enough unless the API contract guarantees full consumption, which many channel APIs do not.
11. Malformed Text Tests
Text IO tests should include malformed bytes and explicit decoder behavior.
@Test
void strictUtf8RejectsMalformedInput() {
byte[] malformed = {(byte) 0xC3, (byte) 0x28};
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
assertThrows(CharacterCodingException.class, () -> decoder.decode(ByteBuffer.wrap(malformed)));
}
Also test:
- UTF-8 BOM
- empty file
- file without trailing newline
- CRLF vs LF
- very long line
- embedded NUL
- invalid code point sequence
- normalization-sensitive strings
For line-based parsers, test:
last line without newline
blank lines
comment lines
spaces around delimiter
quoted delimiter
escaped delimiter
12. Binary Parser Tests
A binary parser should be tested against malformed boundaries.
For a length-prefixed frame:
[magic:4][version:1][length:4][payload:n][crc:4]
Test cases:
| Case | Expected behavior |
|---|---|
| empty input | EOF/protocol error |
| partial header | EOF/protocol error |
| wrong magic | reject |
| unsupported version | reject |
| negative length | reject |
| huge length | reject before allocation |
| partial payload | EOF/protocol error |
| wrong CRC | reject |
| extra bytes | reject or expose next record according to contract |
Example:
@Test
void rejectsHugeLengthBeforeAllocation() {
byte[] header = ByteBuffer.allocate(9)
.putInt(0xCAFE_BABE)
.put((byte) 1)
.putInt(Integer.MAX_VALUE)
.array();
assertThrows(FrameTooLargeException.class, () -> parser.read(new ByteArrayInputStream(header)));
}
The test is about memory safety as much as correctness.
13. Cleanup Tests
Cleanup should be observable.
Example:
@Test
void deletesStagingFileWhenWriteFails(@TempDir Path dir) throws IOException {
Path target = dir.resolve("target.dat");
Path staging = dir.resolve("target.dat.tmp");
IOException ex = assertThrows(IOException.class, () ->
writer.writeAtomically(
new ByteArrayInputStream(new byte[10_000]),
target,
new FailingOutputStreamFactory(100)
)
);
assertFalse(Files.exists(target), "target must not be committed");
assertFalse(Files.exists(staging), "staging should be cleaned or moved to quarantine");
assertNotNull(ex.getCause());
}
If your policy is quarantine instead of deletion, assert quarantine content explicitly:
quarantine contains failed partial file
manifest records failure cause
target remains absent
14. Resource Ownership Tests
If an API accepts a stream, test whether it closes it according to contract.
Tracking stream:
final class CloseTrackingInputStream extends FilterInputStream {
private boolean closed;
CloseTrackingInputStream(InputStream delegate) {
super(delegate);
}
@Override
public void close() throws IOException {
closed = true;
super.close();
}
boolean closed() {
return closed;
}
}
Test:
@Test
void processorDoesNotCloseCallerOwnedStream() throws IOException {
CloseTrackingInputStream in = new CloseTrackingInputStream(
new ByteArrayInputStream("abc".getBytes(StandardCharsets.UTF_8))
);
processor.process(in);
assertFalse(in.closed(), "caller-owned stream should remain open");
}
Or the opposite:
@Test
void processorClosesOwnedStreamSupplier() throws IOException {
CloseTrackingInputStream in = new CloseTrackingInputStream(new ByteArrayInputStream(new byte[0]));
processor.processOwned(() -> in);
assertTrue(in.closed());
}
Resource ownership should not be implicit.
15. Testing Files.lines() and Lazy Streams
Files.lines(path) returns a lazy stream backed by an open file resource. Test code should close it.
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
assertEquals(List.of("a", "b"), lines.toList());
}
For production methods returning streams, prefer not to expose a stream that hides file lifecycle unless the caller contract is very clear.
Test that the method does not leak by designing APIs around callbacks:
public <T> T withLines(Path path, Function<Stream<String>, T> fn) throws IOException {
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
return fn.apply(lines);
}
}
Test:
@Test
void withLinesClosesResource(@TempDir Path dir) throws IOException {
Path file = dir.resolve("a.txt");
Files.writeString(file, "x\ny\n", StandardCharsets.UTF_8);
List<String> result = reader.withLines(file, Stream::toList);
assertEquals(List.of("x", "y"), result);
}
On some platforms, leaked file handles prevent delete. A cleanup test on Windows can catch leaks that Linux silently tolerates.
16. Testing Archive Extraction
Archive extraction deserves hostile tests.
Test cases:
| Case | Expected behavior |
|---|---|
| normal nested entry | extracted inside root |
../escape.txt entry | rejected |
| absolute path entry | rejected |
| duplicate entry | deterministic policy |
| huge declared size | rejected or bounded |
| many small entries | rejected by count limit |
| compressed bomb pattern | rejected by decompressed byte limit |
| symlink-like entry | rejected or handled explicitly |
| directory entry missing before file | extractor creates parent safely |
Zip-slip test:
@Test
void rejectsZipSlipEntry(@TempDir Path dir) throws IOException {
Path zip = dir.resolve("evil.zip");
createZip(zip, Map.of("../escape.txt", "owned"));
Path target = dir.resolve("out");
Files.createDirectory(target);
assertThrows(IOException.class, () -> extractor.extract(zip, target));
assertFalse(Files.exists(dir.resolve("escape.txt")));
}
The assertion must check both rejection and absence of escaped artifact.
17. Testing Process IO
Process IO was covered in Part 029. Testing it belongs here from a fixture perspective.
Create tiny Java helper programs or scripts that:
- write large stdout
- write large stderr
- wait for stdin EOF
- sleep longer than timeout
- exit non-zero
- emit invalid UTF-8
- emit machine stdout and noisy stderr
Example helper idea:
public final class SpamStderr {
public static void main(String[] args) throws Exception {
byte[] block = new byte[8192];
Arrays.fill(block, (byte) 'x');
for (int i = 0; i < 10_000; i++) {
System.err.write(block);
}
}
}
Use it to test deadlock prevention.
Do not make CI tests depend on platform-specific tools unless those tests are explicitly marked integration tests.
18. Crash-Consistency Tests with Failpoints
You cannot easily force a real power loss in a unit test. But you can test your state machine with failpoints.
Example workflow:
write temp
fsync temp
move temp to target
fsync parent
Inject failure after each step:
enum FailPoint {
BEFORE_TEMP_WRITE,
AFTER_TEMP_WRITE,
AFTER_TEMP_FORCE,
AFTER_ATOMIC_MOVE,
AFTER_PARENT_FORCE
}
Test invariant:
after any injected failure, target is either old complete content or new complete content, never partial mixed content
Example:
for (FailPoint fp : FailPoint.values()) {
Path dir = Files.createTempDirectory("atomic-test-");
try {
Files.writeString(dir.resolve("target"), "old", StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> writer.replace(dir.resolve("target"), "new", fp));
String content = Files.readString(dir.resolve("target"), StandardCharsets.UTF_8);
assertTrue(content.equals("old") || content.equals("new"));
} finally {
deleteRecursively(dir);
}
}
This is not a proof of disk durability, but it is a strong test of your workflow invariants.
19. File Tree Assertion Helpers
For complex IO systems, build helpers that compare directory trees.
Example expected tree DSL:
.
├── committed/
│ └── file-a.txt = "hello"
├── staging/
└── manifest.json
Simple helper idea:
static Map<String, String> readTextTree(Path root) throws IOException {
try (Stream<Path> paths = Files.walk(root)) {
return paths
.filter(Files::isRegularFile)
.collect(Collectors.toMap(
p -> root.relativize(p).toString().replace(File.separatorChar, '/'),
p -> {
try {
return Files.readString(p, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
},
(a, b) -> { throw new IllegalStateException("duplicate"); },
TreeMap::new
));
}
}
Then:
assertEquals(Map.of(
"committed/file-a.txt", "hello",
"manifest.json", "{\"status\":\"COMMITTED\"}\n"
), readTextTree(root));
Benefits:
- deterministic assertion
- easy failure diff
- avoids many weak
exists()assertions
20. Avoiding Brittle IO Tests
Common brittleness sources:
| Source | Fix |
|---|---|
| default charset | use explicit charset |
| default locale | set locale-sensitive tool output or avoid it |
| current working directory | use explicit temp dir |
| line endings | normalize if not part of contract |
| timestamp precision | compare ranges or logical ordering |
| file listing order | sort paths before asserting |
| OS path separator | compare logical /-normalized relative paths |
| case sensitivity | use OS-specific test assumptions or Jimfs configurations |
| slow filesystem | avoid timing assertions unless testing performance |
| cleanup racing with background tasks | join/close before assert/delete |
Bad:
assertTrue(Files.list(dir).findFirst().isPresent());
Better:
try (Stream<Path> children = Files.list(dir)) {
List<String> names = children
.map(p -> p.getFileName().toString())
.sorted()
.toList();
assertEquals(List.of("manifest.json", "payload.bin"), names);
}
21. Testable IO API Design
APIs become easier to test when they separate concerns.
Hard to test:
void importFromDefaultLocation() throws IOException {
Path input = Path.of(System.getProperty("user.home"), "data.csv");
// parse, validate, write, log, move
}
Better:
ImportResult importFrom(Path input, Path workDir, Path outputDir) throws IOException
Even better for parser logic:
List<Record> parse(InputStream input) throws IOException
Then test in layers:
parse(InputStream) -> pure/fault-injection tests
importFrom(Path...) -> temp filesystem tests
scheduled import job -> integration tests
Design principle:
Keep boundary acquisition separate from boundary processing.
Acquisition:
open file
create temp dir
start process
locate resource
Processing:
parse bytes
validate frame
transform records
write encoded output
Processing is easier to test with in-memory and fault-injection boundaries.
22. Resource Leak Detection Tactics
Resource leaks are hard to assert portably, but you can improve coverage.
Tactics:
- Use close-tracking test doubles.
- On Windows CI, deletion failure can reveal open file handles.
- Count active test resources in custom wrappers.
- Avoid returning lazy streams without lifecycle tests.
- Use try-with-resources in test code too.
- Keep integration tests that repeatedly run an operation many times.
Example repeated test idea:
@RepeatedTest(100)
void noHandleLeakAcrossRepeatedImport(@TempDir Path dir) throws IOException {
Path input = dir.resolve("input.txt");
Files.writeString(input, "hello", StandardCharsets.UTF_8);
importer.importFile(input, dir.resolve("out.txt"));
Files.deleteIfExists(dir.resolve("out.txt"));
}
This is not a formal proof, but it catches many accidental unclosed streams.
23. Property-Style Tests for Framing
For frame encoders/decoders, property-style testing is powerful.
Invariant:
decode(encode(payload)) == payload
For many payloads:
- empty
- one byte
- random bytes
- maximum allowed size
- bytes with zeros
- bytes with high bit set
Example without a property-testing library:
@Test
void frameRoundTripForRandomPayloads() throws IOException {
Random random = new Random(1234);
for (int i = 0; i < 1000; i++) {
byte[] payload = new byte[random.nextInt(4096)];
random.nextBytes(payload);
byte[] encoded = frameCodec.encode(payload);
byte[] decoded = frameCodec.decode(new ByteArrayInputStream(encoded));
assertArrayEquals(payload, decoded);
}
}
Add negative tests too. Round-trip tests alone do not prove malformed input handling.
24. Testing Large Data Without Huge Fixtures
Do not commit giant binary files just to test large IO behavior.
Use generated streams:
final class RepeatingInputStream extends InputStream {
private long remaining;
private final byte value;
RepeatingInputStream(long size, byte value) {
this.remaining = size;
this.value = value;
}
@Override
public int read() {
if (remaining <= 0) {
return -1;
}
remaining--;
return value & 0xFF;
}
}
Use it to test:
- bounded memory behavior
- streaming copy
- size limits
- progress callbacks
- cancellation after N bytes
For content-sensitive tests, generate deterministic pseudo-random bytes from a fixed seed.
25. CI Strategy
A good IO test suite separates fast and slow tests.
unit tests:
parser
encoder
stream fault injection
path normalization
contract tests:
real filesystem operations
symlink/permission behavior when supported
atomic move behavior
integration tests:
external process tools
actual archive fixtures
container-mounted volumes
OS-specific behavior
Tag slow/platform tests:
@Tag("filesystem-contract")
@Tag("external-tool")
@Tag("windows-specific")
Use assumptions:
Assumptions.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
Do not silently skip critical production guarantees. If POSIX permissions are required in production, run those tests in an environment that supports them.
26. Deliberate Practice
Exercise 1 — Partial read hardening
Take a parser that currently uses in.read(buffer) once and assumes the buffer is full.
Build tests using OneByteAtATimeInputStream.
Acceptance criteria:
- parser succeeds with partial reads
- parser fails cleanly on EOF before full frame
- no unbounded allocation
Exercise 2 — Atomic write failure matrix
Implement writeAtomically(Path target, byte[] data) with staging.
Inject failure after:
- temp file creation
- partial write
- flush
- move
- cleanup
Acceptance criteria:
- target is never partial
- temp file policy is deterministic
- exception preserves cause
- test asserts directory tree state
Exercise 3 — Archive hostile corpus
Create a small hostile ZIP corpus:
normal.zip
zip-slip.zip
duplicate-entry.zip
many-entries.zip
huge-entry.zip
nested.zip
Acceptance criteria:
- each corpus file has a named test
- rejected archives leave no escaped files
- extraction is bounded
- error category is clear
Exercise 4 — Process runner fixture
Write helper child programs that emit large stdout/stderr and wait for stdin.
Acceptance criteria:
- runner does not deadlock
- timeout works
- stdout/stderr capture is bounded
- stdin is closed when unused
27. Review Checklist
Use this checklist when reviewing IO tests:
[ ] Test uses isolated temp directory or explicit fake filesystem.
[ ] Test does not depend on project working directory.
[ ] Charset is explicit.
[ ] File listing assertions are sorted/deterministic.
[ ] Text comparison handles line endings intentionally.
[ ] Parser tests include partial input and malformed input.
[ ] Writer tests include partial/failing output.
[ ] Large-data tests do not require huge committed fixtures.
[ ] Resource ownership is tested where API contract matters.
[ ] Cleanup or quarantine behavior is asserted.
[ ] Archive/path tests include traversal attempts.
[ ] Process tests drain stdout/stderr and enforce timeout.
[ ] Platform-specific semantics are tagged or guarded by assumptions.
[ ] Golden files are small, named, and documented.
[ ] Failure messages include enough diagnostic context.
28. Mental Model Summary
IO testing should validate behavior at the boundary, not just the happy path.
The best IO tests combine:
small deterministic fixtures
+ explicit temp workspaces
+ partial read/write injection
+ malformed input corpus
+ cleanup assertions
+ real provider contract tests where necessary
If you remember only one invariant:
Any IO code that has not been tested against partial reads, partial writes, malformed data, and cleanup failure has only been tested against the easiest version of reality.
References
- JUnit 5 User Guide —
@TempDir: https://docs.junit.org/current/user-guide/#writing-tests-built-in-extensions-TempDirectory - JUnit Jupiter API —
TempDir: https://docs.junit.org/current/api/org.junit.jupiter.api/org/junit/jupiter/api/io/TempDir.html - Oracle Java SE 25 API —
Files: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/file/Files.html - Oracle Java SE 25 API —
Path: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/file/Path.html - Oracle Java SE 25 API —
InputStream: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/InputStream.html - Oracle Java SE 25 API —
WritableByteChannel: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/channels/WritableByteChannel.html - Google Jimfs — in-memory Java filesystem: https://github.com/google/jimfs
You just completed lesson 30 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.