Series MapLesson 30 / 32
Final StretchOrdered learning track

Learn Java Io Modern Io Resource Boundaries Part 030 Testing Io Systems

13 min read2460 words
PrevNext
Lesson 3032 lesson track2832 Final Stretch

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:

  1. Use temporary directories and files safely in tests.
  2. Decide when to test against the real filesystem and when to use a fake filesystem.
  3. Build golden-file tests without making them brittle.
  4. Inject partial read/write, slow stream, and failure-after-N-bytes behavior.
  5. Test parser and framing code against malformed input.
  6. Test crash-consistency logic with failpoints.
  7. Validate cleanup and resource ownership.
  8. Avoid test pollution from working directory, default charset, locale, and line endings.
  9. Design IO APIs that are easy to test.
  10. 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-skillProduction question
IsolationDoes each test get its own filesystem workspace?
Fixture designAre test inputs realistic but minimal?
Path coverageAre edge-case names and directory layouts tested?
Stream behaviorDoes code handle partial read/write?
Failure injectionCan failures happen after some bytes are processed?
CleanupAre temp files, streams, locks, and processes released?
EncodingAre charset and malformed input behavior explicit?
AtomicityAre staging and commit steps tested independently?
PortabilityDoes the test depend on OS-specific semantics?
DiagnosticsDo failures show enough information to debug?

3. The IO Test Pyramid

Not all IO tests need the same realism.

A practical distribution:

LayerPurposeSpeedExample
Pure parser/encoderValidate format logicvery fastbyte array to records
Fault injection stream/channelValidate partial/failing IOfaststream throws after 100 bytes
Fake filesystemValidate path logicfastJimfs Unix/Windows config
Real filesystemValidate actual OS/provider behaviormediumatomic move, permissions, symlink
Production-like integrationValidate tool/container/runtime assumptionsslowerexternal 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:

  1. Never write test artifacts into the project root.
  2. Never depend on the JVM working directory unless that is what you are testing.
  3. Do not reuse the same temp directory across tests unless testing shared-state behavior.
  4. Assert meaningful file content, not just existence.
  5. Use explicit charsets.
  6. 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:

BehaviorWhy fake may be insufficient
atomic moveProvider/OS support differs
file lockingPlatform semantics differ
symlink behaviorRequires OS permissions and provider support
POSIX permissionsNot available on all filesystems
case sensitivityOS/filesystem dependent
timestamp precisionProvider dependent
directory fsyncPlatform-dependent behavior
process redirectsNeed 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:

  1. Keep golden files small.
  2. Name them by scenario.
  3. Store input and expected output together.
  4. Provide a documented regeneration command if output intentionally changes.
  5. Normalize line endings when comparing text unless line ending is part of the contract.
  6. 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:

CaseExpected behavior
empty inputEOF/protocol error
partial headerEOF/protocol error
wrong magicreject
unsupported versionreject
negative lengthreject
huge lengthreject before allocation
partial payloadEOF/protocol error
wrong CRCreject
extra bytesreject 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:

CaseExpected behavior
normal nested entryextracted inside root
../escape.txt entryrejected
absolute path entryrejected
duplicate entrydeterministic policy
huge declared sizerejected or bounded
many small entriesrejected by count limit
compressed bomb patternrejected by decompressed byte limit
symlink-like entryrejected or handled explicitly
directory entry missing before fileextractor 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:

SourceFix
default charsetuse explicit charset
default localeset locale-sensitive tool output or avoid it
current working directoryuse explicit temp dir
line endingsnormalize if not part of contract
timestamp precisioncompare ranges or logical ordering
file listing ordersort paths before asserting
OS path separatorcompare logical /-normalized relative paths
case sensitivityuse OS-specific test assumptions or Jimfs configurations
slow filesystemavoid timing assertions unless testing performance
cleanup racing with background tasksjoin/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:

  1. Use close-tracking test doubles.
  2. On Windows CI, deletion failure can reveal open file handles.
  3. Count active test resources in custom wrappers.
  4. Avoid returning lazy streams without lifecycle tests.
  5. Use try-with-resources in test code too.
  6. 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:

  1. temp file creation
  2. partial write
  3. flush
  4. move
  5. 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

Lesson Recap

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.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.