DEV Community

Rez Moss
Rez Moss

Posted on

Testing File System Code: Mocking, Stubbing, and Test Patterns 9/9

Testing Philosophy with File System Interfaces

File system code presents unique testing challenges that many developers struggle with. The traditional approach of writing tests that directly interact with the real file system creates brittle, slow tests that depend on external state. A better approach treats the file system as a dependency that can be abstracted, controlled, and tested in isolation.

Dependency Injection Benefits

The key to testable file system code lies in dependency injection. Instead of hardcoding calls to os.Open() or ioutil.ReadFile(), inject the file system interface as a dependency. This seemingly simple change transforms your code from untestable to highly testable.

// Problematic: Direct file system dependency
func ProcessConfigFile(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    return parseConfig(data)
}

// Better: File system as injected dependency
func ProcessConfigFile(fsys fs.FS, path string) (*Config, error) {
    data, err := fs.ReadFile(fsys, path)
    if err != nil {
        return nil, err
    }
    return parseConfig(data)
}
Enter fullscreen mode Exit fullscreen mode

This pattern allows you to pass different file system implementations during testing while maintaining the same interface in production code. The production code receives os.DirFS(".") while tests receive mock implementations.

Testable File System Operations

When designing file system operations for testability, focus on three core principles: interface segregation, explicit dependencies, and predictable behavior.

Interface segregation means depending only on the file system capabilities you actually need. If your function only reads files, depend on fs.FS rather than a broader interface that includes write operations. This makes your intentions clear and simplifies testing.

// Only needs read access
func LoadTemplate(fsys fs.FS, name string) (*Template, error) {
    content, err := fs.ReadFile(fsys, name)
    if err != nil {
        return nil, fmt.Errorf("loading template %s: %w", name, err)
    }
    return parseTemplate(content)
}

// Needs write access - use different interface
func SaveReport(fsys WriteFS, path string, report *Report) error {
    data, err := report.Marshal()
    if err != nil {
        return err
    }
    return fsys.WriteFile(path, data, 0644)
}
Enter fullscreen mode Exit fullscreen mode

Explicit dependencies eliminate hidden file system interactions. Every file system operation should flow through the injected interface, making the code's dependencies obvious to both humans and tests.

Isolation from Real File System

Test isolation prevents tests from interfering with each other and with the host system. When tests manipulate real files, they can leave behind state that affects subsequent test runs or, worse, modify important system files.

Proper isolation means your tests never touch the real file system unless specifically testing integration points. Unit tests work entirely with mock file systems, while integration tests use temporary directories that are cleaned up automatically.

func TestProcessConfigFile(t *testing.T) {
    // Isolated test using mock file system
    testFS := fstest.MapFS{
        "config.json": &fstest.MapFile{
            Data: []byte(`{"setting": "value"}`),
        },
    }

    config, err := ProcessConfigFile(testFS, "config.json")
    assert.NoError(t, err)
    assert.Equal(t, "value", config.Setting)
}
Enter fullscreen mode Exit fullscreen mode

This approach gives you complete control over the file system state during tests. You define exactly what files exist, their contents, permissions, and modification times. Tests become deterministic and fast because they don't perform actual disk I/O.

The isolation also extends to error conditions. Mock file systems let you simulate permission errors, disk full conditions, and network failures that would be difficult or impossible to reproduce consistently with real file systems.

Testing file system code well requires thinking about the file system as just another dependency to be managed, not as an unchangeable part of the environment. This mindset shift enables the testing patterns and techniques that follow in the subsequent sections.

Using testing/fstest.TestFS

Go's testing/fstest package provides powerful tools for validating file system implementations and ensuring your code works correctly with any fs.FS implementation. The TestFS function acts as a comprehensive test suite that verifies file system behavior against the expected interface contracts.

Validating Custom FS Implementations

When you create custom file system implementations, fstest.TestFS becomes your verification tool. It runs a battery of tests that check edge cases, error conditions, and specification compliance that you might miss in manual testing.

func TestMyCustomFS(t *testing.T) {
    // Create your custom file system
    customFS := &MyCustomFS{
        files: map[string][]byte{
            "file1.txt":     []byte("content1"),
            "dir/file2.txt": []byte("content2"),
            "empty.txt":     []byte(""),
        },
    }

    // Validate it conforms to fs.FS interface expectations
    if err := fstest.TestFS(customFS, "file1.txt", "dir/file2.txt", "empty.txt"); err != nil {
        t.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The function tests various scenarios: opening files that exist and don't exist, reading directories, handling path separators correctly, and ensuring error messages follow Go's conventions. It catches subtle bugs that surface only in specific conditions.

For read-write file systems, create additional validation that goes beyond the basic fs.FS interface:

func TestWritableFS(t *testing.T) {
    fsys := NewMemoryFS()

    // First validate basic fs.FS compliance
    err := fstest.TestFS(fsys, "test1.txt", "dir/test2.txt")
    require.NoError(t, err)

    // Then test write operations
    testWriteOperations(t, fsys)
    testConcurrentAccess(t, fsys)
    testErrorConditions(t, fsys)
}
Enter fullscreen mode Exit fullscreen mode

Test Coverage for Edge Cases

fstest.TestFS excels at uncovering edge cases that developers typically overlook. It tests paths with different separators, attempts to open directories as files, checks behavior with empty files, and validates that file info structures contain correct data.

The tool systematically tests path handling, which is notoriously error-prone:

// These are all tested automatically by fstest.TestFS
testCases := []string{
    "normal-file.txt",
    "path/to/nested/file.txt", 
    "file-with-dots..txt",
    "file.with.multiple.dots",
    "UPPERCASE.TXT",
    "file with spaces.txt",
}
Enter fullscreen mode Exit fullscreen mode

It also validates that your file system handles the distinction between files and directories correctly, ensures that fs.FileInfo returns accurate information, and checks that error types match Go's standard patterns.

The edge case testing extends to boundary conditions. What happens when you try to read beyond a file's end? How does your implementation handle zero-length files? Does it correctly report file modification times? TestFS checks all of these scenarios.

Performance Testing Patterns

While fstest.TestFS focuses on correctness, performance testing requires additional patterns. Create benchmarks that measure your file system implementation under realistic workloads:

func BenchmarkFileSystemOperations(b *testing.B) {
    fsys := createLargeTestFS(1000) // 1000 files
    filenames := getRandomFilenames(fsys, 100)

    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            name := filenames[rand.Intn(len(filenames))]
            if file, err := fsys.Open(name); err == nil {
                io.Copy(io.Discard, file)
                file.Close()
            }
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Performance testing should cover different access patterns: sequential reads, random access, directory traversal, and concurrent operations. Each pattern stresses different aspects of your implementation.

Memory usage patterns matter as much as speed. Profile your file system implementation to ensure it doesn't leak memory or consume excessive resources:

func TestMemoryUsage(t *testing.T) {
    fsys := NewMemoryFS()

    // Load test data
    for i := 0; i < 1000; i++ {
        filename := fmt.Sprintf("file%d.txt", i)
        fsys.WriteFile(filename, make([]byte, 10240), 0644)
    }

    // Measure baseline memory
    var m1 runtime.MemStats
    runtime.GC()
    runtime.ReadMemStats(&m1)

    // Perform operations
    performRandomAccess(fsys, 10000)

    // Check for memory leaks
    var m2 runtime.MemStats
    runtime.GC()
    runtime.ReadMemStats(&m2)

    memGrowth := m2.Alloc - m1.Alloc
    if memGrowth > 1024*1024 { // 1MB threshold
        t.Errorf("Excessive memory growth: %d bytes", memGrowth)
    }
}
Enter fullscreen mode Exit fullscreen mode

The combination of fstest.TestFS for correctness validation and custom benchmarks for performance creates a comprehensive testing strategy. Your file system implementation gains confidence through systematic verification while maintaining performance characteristics under realistic workloads.

Mock File System Implementations

Mock file systems give you complete control over the testing environment, allowing you to simulate any condition your code might encounter in production. Unlike stubs that return fixed responses, mocks actively track interactions and can change behavior based on the sequence of operations.

In-Memory Test File Systems

In-memory file systems provide the fastest and most controllable testing environment. They eliminate disk I/O completely while maintaining the same interface as real file systems. The fstest.MapFS type serves as an excellent starting point:

func TestFileProcessor(t *testing.T) {
    testFS := fstest.MapFS{
        "input/data.csv": &fstest.MapFile{
            Data:    []byte("name,age\nAlice,30\nBob,25"),
            Mode:    0644,
            ModTime: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
        },
        "config/settings.json": &fstest.MapFile{
            Data: []byte(`{"delimiter": ",", "headers": true}`),
        },
    }

    processor := NewFileProcessor(testFS)
    result, err := processor.ProcessData("input/data.csv", "config/settings.json")

    assert.NoError(t, err)
    assert.Len(t, result.Records, 2)
    assert.Equal(t, "Alice", result.Records[0]["name"])
}
Enter fullscreen mode Exit fullscreen mode

For more complex scenarios, create custom in-memory implementations that support write operations and state tracking:

type MemoryFS struct {
    files   map[string]*MemoryFile
    dirs    map[string]bool
    mutex   sync.RWMutex
    calls   []string // Track method calls for verification
}

func (m *MemoryFS) Open(name string) (fs.File, error) {
    m.mutex.Lock()
    m.calls = append(m.calls, fmt.Sprintf("Open(%s)", name))
    m.mutex.Unlock()

    m.mutex.RLock()
    defer m.mutex.RUnlock()

    if file, exists := m.files[name]; exists {
        return &MemoryFile{data: file.data, info: file.info}, nil
    }
    return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}

func (m *MemoryFS) WriteFile(name string, data []byte, perm fs.FileMode) error {
    m.mutex.Lock()
    defer m.mutex.Unlock()

    m.calls = append(m.calls, fmt.Sprintf("WriteFile(%s, %d bytes)", name, len(data)))
    m.files[name] = &MemoryFile{
        data: append([]byte(nil), data...), // Copy to prevent external modification
        info: &MemoryFileInfo{name: name, size: int64(len(data)), mode: perm},
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This approach lets you verify not just the final state but also the sequence of operations your code performs.

Controlled Error Injection

Mock file systems excel at simulating error conditions that are difficult to reproduce with real file systems. You can trigger specific errors at precise moments to test error handling paths:

type ErrorInjectingFS struct {
    base      fs.FS
    failAfter int
    callCount int
    errorType error
}

func (e *ErrorInjectingFS) Open(name string) (fs.File, error) {
    e.callCount++
    if e.callCount > e.failAfter {
        return nil, e.errorType
    }
    return e.base.Open(name)
}

func TestErrorHandling(t *testing.T) {
    baseFS := fstest.MapFS{
        "file1.txt": &fstest.MapFile{Data: []byte("content1")},
        "file2.txt": &fstest.MapFile{Data: []byte("content2")},
        "file3.txt": &fstest.MapFile{Data: []byte("content3")},
    }

    // Simulate disk full error after 2 successful operations
    errorFS := &ErrorInjectingFS{
        base:      baseFS,
        failAfter: 2,
        errorType: errors.New("no space left on device"),
    }

    processor := NewBatchProcessor(errorFS)
    files := []string{"file1.txt", "file2.txt", "file3.txt"}

    results, err := processor.ProcessFiles(files)

    // Should process first 2 files successfully, then fail
    assert.Error(t, err)
    assert.Len(t, results, 2)
    assert.Contains(t, err.Error(), "no space left on device")
}
Enter fullscreen mode Exit fullscreen mode

Error injection patterns help you test partial failure scenarios, retry logic, and cleanup operations. You can simulate network timeouts, permission errors, corrupted files, and resource exhaustion.

State Verification Techniques

Mock file systems enable detailed verification of how your code interacts with the file system. Track not just what files were accessed, but when, in what order, and with what parameters:

type TrackedFS struct {
    base       fs.FS
    operations []Operation
    mutex      sync.Mutex
}

type Operation struct {
    Type      string
    Path      string
    Timestamp time.Time
    Thread    int
}

func (t *TrackedFS) Open(name string) (fs.File, error) {
    t.mutex.Lock()
    t.operations = append(t.operations, Operation{
        Type:      "Open",
        Path:      name,
        Timestamp: time.Now(),
        Thread:    getCurrentThreadID(),
    })
    t.mutex.Unlock()

    return t.base.Open(name)
}

func TestFileAccessPattern(t *testing.T) {
    tracked := &TrackedFS{base: createTestFS()}
    processor := NewConcurrentProcessor(tracked)

    processor.ProcessDirectory("test-dir")

    // Verify operations occurred in expected order
    ops := tracked.GetOperations()
    assert.Equal(t, "Open", ops[0].Type)
    assert.Equal(t, "test-dir", ops[0].Path)

    // Verify no concurrent access to same file
    fileAccess := groupOperationsByFile(ops)
    for file, accesses := range fileAccess {
        ensureNoOverlap(t, file, accesses)
    }
}
Enter fullscreen mode Exit fullscreen mode

State verification extends beyond simple call tracking. You can verify that files are closed properly, that locks are acquired and released correctly, and that temporary files are cleaned up. Mock file systems give you visibility into aspects of your code's behavior that would be invisible with real file systems.

The power of mock file systems lies in their predictability and observability. Every aspect of the file system's behavior is under your control, and every interaction is visible to your tests. This combination enables thorough testing of complex file system operations that would be brittle or impossible to test otherwise.

Test Data Management

Effective test data management determines whether your file system tests remain maintainable as your codebase grows. Poor test data practices lead to flaky tests, unclear failure messages, and maintenance nightmares. Well-organized test data makes tests self-documenting and reliable.

Embedding Test Fixtures

Go's embed directive provides an elegant solution for including test files directly in your binary. This approach ensures test data travels with your code and eliminates path-related issues across different environments:

//go:embed testdata/*
var testDataFS embed.FS

func TestConfigParser(t *testing.T) {
    tests := []struct {
        name     string
        filename string
        expected Config
        wantErr  bool
    }{
        {
            name:     "valid config",
            filename: "testdata/valid-config.json",
            expected: Config{Host: "localhost", Port: 8080},
        },
        {
            name:     "invalid json",
            filename: "testdata/invalid.json",
            wantErr:  true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            config, err := ParseConfig(testDataFS, tt.filename)
            if tt.wantErr {
                assert.Error(t, err)
                return
            }
            assert.NoError(t, err)
            assert.Equal(t, tt.expected, *config)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Organize embedded test data in a clear directory structure that mirrors your test cases:

testdata/
├── configs/
│   ├── valid-config.json
│   ├── invalid-syntax.json
│   └── missing-required-field.json
├── templates/
│   ├── simple.tmpl
│   └── complex-with-partials.tmpl
└── datasets/
    ├── small-sample.csv
    └── large-sample.csv
Enter fullscreen mode Exit fullscreen mode

This structure makes it obvious which test data belongs to which test scenario. File names should be descriptive enough that you understand their purpose without opening them.

For binary test data or files that change frequently, consider using a hybrid approach:

func loadTestData(t *testing.T, filename string) []byte {
    // Try embedded data first
    if data, err := testDataFS.ReadFile("testdata/" + filename); err == nil {
        return data
    }

    // Fall back to external file for development
    data, err := os.ReadFile(filepath.Join("testdata", filename))
    require.NoError(t, err, "failed to load test data: %s", filename)
    return data
}
Enter fullscreen mode Exit fullscreen mode

Dynamic Test File Generation

Some tests require generated data rather than static fixtures. Dynamic generation is particularly useful for testing edge cases, large datasets, or scenarios with specific characteristics:

func generateTestCSV(rows int, corruptLine int) []byte {
    var buf bytes.Buffer
    buf.WriteString("id,name,email,age\n")

    for i := 1; i <= rows; i++ {
        if i == corruptLine {
            // Intentionally corrupt this line for error testing
            buf.WriteString(fmt.Sprintf("%d,\"Unclosed Quote,user%d@example.com,25\n", i, i))
        } else {
            buf.WriteString(fmt.Sprintf("%d,User%d,user%d@example.com,%d\n", i, i, i, 20+i%50))
        }
    }
    return buf.Bytes()
}

func TestCSVParserWithLargeFile(t *testing.T) {
    testFS := fstest.MapFS{
        "large.csv": &fstest.MapFile{
            Data: generateTestCSV(10000, 5000), // Corrupt line 5000
        },
    }

    parser := NewCSVParser(testFS)
    records, err := parser.ParseFile("large.csv")

    assert.Error(t, err)
    assert.Contains(t, err.Error(), "line 5000")
    assert.Len(t, records, 4999) // Should have parsed up to the error
}
Enter fullscreen mode Exit fullscreen mode

Dynamic generation shines when testing boundary conditions. Generate files that are exactly at size limits, create directory structures with maximum depth, or produce data with specific statistical distributions:

func createDeepDirectoryStructure(depth int) fstest.MapFS {
    fs := make(fstest.MapFS)

    var path strings.Builder
    for i := 0; i < depth; i++ {
        if i > 0 {
            path.WriteString("/")
        }
        path.WriteString(fmt.Sprintf("dir%d", i))
    }

    // Add a file at the deepest level
    filePath := path.String() + "/deep-file.txt"
    fs[filePath] = &fstest.MapFile{
        Data: []byte(fmt.Sprintf("File at depth %d", depth)),
    }

    return fs
}

func TestDeepDirectoryHandling(t *testing.T) {
    maxDepth := 100
    testFS := createDeepDirectoryStructure(maxDepth)

    walker := NewDirectoryWalker(testFS)
    files, err := walker.FindAllFiles(".")

    assert.NoError(t, err)
    assert.Len(t, files, 1)
    assert.Contains(t, files[0], "deep-file.txt")
}
Enter fullscreen mode Exit fullscreen mode

Cleanup Strategies

Test cleanup becomes critical when tests create temporary files or modify shared state. Implement cleanup strategies that work reliably even when tests fail or panic:

func TestFileProcessor(t *testing.T) {
    // Create temporary directory
    tempDir := t.TempDir() // Automatically cleaned up by testing framework

    // For more control, use custom cleanup
    customTempDir, err := os.MkdirTemp("", "test-processor-*")
    require.NoError(t, err)
    t.Cleanup(func() {
        os.RemoveAll(customTempDir)
    })

    // Test operations...
    processor := NewFileProcessor(os.DirFS(tempDir))
    result, err := processor.ProcessFiles([]string{"input.txt"})

    assert.NoError(t, err)
    assert.NotNil(t, result)
}
Enter fullscreen mode Exit fullscreen mode

For tests that modify global state or shared resources, use cleanup functions that restore the original state:

func TestWithModifiedEnvironment(t *testing.T) {
    // Save original environment
    originalHome := os.Getenv("HOME")
    originalConfig := os.Getenv("CONFIG_PATH")

    t.Cleanup(func() {
        os.Setenv("HOME", originalHome)
        os.Setenv("CONFIG_PATH", originalConfig)
    })

    // Modify environment for test
    testHome := t.TempDir()
    os.Setenv("HOME", testHome)
    os.Setenv("CONFIG_PATH", filepath.Join(testHome, "config"))

    // Run test with modified environment
    config, err := LoadUserConfig()
    assert.NoError(t, err)
    assert.Equal(t, testHome, config.HomeDirectory)
}
Enter fullscreen mode Exit fullscreen mode

Well-managed test data eliminates a major source of test brittleness. When tests fail, you want to debug your application logic, not struggle with missing files or corrupted test data. The patterns above ensure your test data remains an asset rather than a liability as your test suite grows.

Integration Testing Approaches

Integration tests verify that your file system code works correctly with real file systems and external dependencies. While unit tests with mocks validate logic in isolation, integration tests catch issues that emerge from the interaction between your code and the actual environment it will run in.

Temporary Directory Usage

Integration tests need isolated environments that don't interfere with the host system or other tests. Go's testing package provides t.TempDir() which creates temporary directories that are automatically cleaned up:

func TestFileBackupIntegration(t *testing.T) {
    // Create isolated test environment
    sourceDir := t.TempDir()
    backupDir := t.TempDir()

    // Set up test files
    testFiles := map[string]string{
        "document.txt":     "Important document content",
        "config/app.json":  `{"version": "1.0", "debug": true}`,
        "data/records.csv": "id,name,value\n1,test,100\n2,prod,200",
    }

    for path, content := range testFiles {
        fullPath := filepath.Join(sourceDir, path)
        err := os.MkdirAll(filepath.Dir(fullPath), 0755)
        require.NoError(t, err)
        err = os.WriteFile(fullPath, []byte(content), 0644)
        require.NoError(t, err)
    }

    // Test the backup operation
    backup := NewFileBackup()
    err := backup.CreateBackup(sourceDir, backupDir)
    require.NoError(t, err)

    // Verify backup contents
    for path, expectedContent := range testFiles {
        backupPath := filepath.Join(backupDir, path)
        actualContent, err := os.ReadFile(backupPath)
        require.NoError(t, err)
        assert.Equal(t, expectedContent, string(actualContent))
    }

    // Verify file permissions and metadata
    sourceInfo, err := os.Stat(filepath.Join(sourceDir, "document.txt"))
    require.NoError(t, err)
    backupInfo, err := os.Stat(filepath.Join(backupDir, "document.txt"))
    require.NoError(t, err)

    assert.Equal(t, sourceInfo.Mode(), backupInfo.Mode())
    assert.Equal(t, sourceInfo.Size(), backupInfo.Size())
}
Enter fullscreen mode Exit fullscreen mode

Temporary directories provide true isolation while testing real file system behavior. This catches issues like path separator handling, permission problems, and filesystem-specific limitations that mocks cannot simulate.

Real File System Testing

Some functionality can only be properly tested against real file systems. File locking, atomic operations, and filesystem-specific features require integration testing:

func TestConcurrentFileAccess(t *testing.T) {
    if testing.Short() {
        t.Skip("Skipping integration test in short mode")
    }

    tempDir := t.TempDir()
    testFile := filepath.Join(tempDir, "shared.txt")

    // Create initial file
    err := os.WriteFile(testFile, []byte("initial content\n"), 0644)
    require.NoError(t, err)

    const numWriters = 5
    const writesPerWriter = 10

    var wg sync.WaitGroup
    errChan := make(chan error, numWriters)

    // Launch concurrent writers
    for i := 0; i < numWriters; i++ {
        wg.Add(1)
        go func(writerID int) {
            defer wg.Done()

            for j := 0; j < writesPerWriter; j++ {
                line := fmt.Sprintf("Writer %d, line %d\n", writerID, j)
                if err := appendToFile(testFile, line); err != nil {
                    errChan <- err
                    return
                }
                time.Sleep(time.Millisecond) // Small delay to increase contention
            }
        }(i)
    }

    wg.Wait()
    close(errChan)

    // Check for errors
    for err := range errChan {
        t.Errorf("Concurrent write error: %v", err)
    }

    // Verify file integrity
    content, err := os.ReadFile(testFile)
    require.NoError(t, err)

    lines := strings.Split(string(content), "\n")
    expectedLines := 1 + (numWriters * writesPerWriter) // Initial + all writes
    assert.Equal(t, expectedLines, len(lines))
}

func appendToFile(filename, content string) error {
    file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0644)
    if err != nil {
        return err
    }
    defer file.Close()

    _, err = file.WriteString(content)
    return err
}
Enter fullscreen mode Exit fullscreen mode

Real file system testing reveals race conditions, locking issues, and platform-specific behaviors that are impossible to catch with mocked implementations.

Cross-Platform Considerations

File system behavior varies significantly across operating systems. Integration tests should account for these differences or explicitly test platform-specific behavior:

func TestPathHandling(t *testing.T) {
    tempDir := t.TempDir()

    tests := []struct {
        name        string
        inputPath   string
        expectError bool
        skipOnOS    string
    }{
        {
            name:      "normal path",
            inputPath: "normal-file.txt",
        },
        {
            name:      "path with spaces",
            inputPath: "file with spaces.txt",
        },
        {
            name:      "long filename",
            inputPath: strings.Repeat("a", 255), // Max filename length on most filesystems
        },
        {
            name:        "invalid characters",
            inputPath:   "file<>|.txt",
            expectError: true,
            skipOnOS:    "linux", // Linux allows these characters
        },
        {
            name:      "case sensitivity test",
            inputPath: "CaseSensitive.TXT",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if tt.skipOnOS == runtime.GOOS {
                t.Skipf("Skipping test on %s", runtime.GOOS)
            }

            filePath := filepath.Join(tempDir, tt.inputPath)
            err := os.WriteFile(filePath, []byte("test content"), 0644)

            if tt.expectError {
                assert.Error(t, err)
            } else {
                assert.NoError(t, err)

                // Verify file can be read back
                content, readErr := os.ReadFile(filePath)
                assert.NoError(t, readErr)
                assert.Equal(t, "test content", string(content))
            }
        })
    }
}

func TestSymlinkHandling(t *testing.T) {
    if runtime.GOOS == "windows" {
        t.Skip("Symlink test requires Unix-like system")
    }

    tempDir := t.TempDir()

    // Create target file
    targetFile := filepath.Join(tempDir, "target.txt")
    err := os.WriteFile(targetFile, []byte("target content"), 0644)
    require.NoError(t, err)

    // Create symlink
    linkFile := filepath.Join(tempDir, "link.txt")
    err = os.Symlink(targetFile, linkFile)
    require.NoError(t, err)

    // Test reading through symlink
    walker := NewDirectoryWalker()
    files, err := walker.ListFiles(tempDir)
    require.NoError(t, err)

    // Should find both target and link
    assert.Len(t, files, 2)

    // Verify link behavior
    linkInfo, err := os.Lstat(linkFile)
    require.NoError(t, err)
    assert.True(t, linkInfo.Mode()&os.ModeSymlink != 0)
}
Enter fullscreen mode Exit fullscreen mode

Platform-specific tests help ensure your code works correctly across different environments. Use build tags or runtime checks to handle platform differences gracefully.

Integration tests bridge the gap between unit tests and production deployment. They catch environment-specific issues while maintaining enough control to be reliable and repeatable. The key is finding the right balance between real-world conditions and test stability.

Advanced Testing Patterns

Advanced testing patterns push beyond traditional unit and integration tests to discover edge cases and performance characteristics that conventional testing might miss. These techniques are particularly valuable for file system code, which often handles unpredictable inputs and operates under varying system conditions.

Property-Based Testing

Property-based testing generates random inputs to verify that your code maintains certain invariants regardless of the specific data it processes. For file system operations, this approach excels at finding edge cases in path handling, data processing, and error recovery:

func TestFilePathNormalization(t *testing.T) {
    property := func(pathComponents []string) bool {
        if len(pathComponents) == 0 {
            return true // Skip empty input
        }

        // Filter out empty components and those with null bytes
        validComponents := make([]string, 0, len(pathComponents))
        for _, component := range pathComponents {
            if component != "" && !strings.Contains(component, "\x00") {
                validComponents = append(validComponents, component)
            }
        }

        if len(validComponents) == 0 {
            return true
        }

        // Build path and normalize it
        rawPath := strings.Join(validComponents, "/")
        normalized := NormalizePath(rawPath)

        // Properties that should always hold:
        // 1. Normalized path should not contain double slashes
        if strings.Contains(normalized, "//") {
            t.Logf("Double slash found in normalized path: %s", normalized)
            return false
        }

        // 2. Should not end with slash unless it's root
        if len(normalized) > 1 && strings.HasSuffix(normalized, "/") {
            t.Logf("Unexpected trailing slash: %s", normalized)
            return false
        }

        // 3. Should be idempotent - normalizing twice gives same result
        if NormalizePath(normalized) != normalized {
            t.Logf("Not idempotent: %s != %s", normalized, NormalizePath(normalized))
            return false
        }

        return true
    }

    // Run property test with random inputs
    for i := 0; i < 1000; i++ {
        components := generateRandomPathComponents(rand.Intn(10) + 1)
        if !property(components) {
            t.Fatalf("Property violation with input: %v", components)
        }
    }
}

func generateRandomPathComponents(count int) []string {
    components := make([]string, count)
    chars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."

    for i := 0; i < count; i++ {
        length := rand.Intn(20) + 1
        component := make([]byte, length)
        for j := range component {
            component[j] = chars[rand.Intn(len(chars))]
        }
        components[i] = string(component)
    }
    return components
}
Enter fullscreen mode Exit fullscreen mode

Property-based testing for file operations can verify data integrity across various transformations:

func TestFileCompressionRoundTrip(t *testing.T) {
    compressor := NewFileCompressor()

    for i := 0; i < 100; i++ {
        // Generate random data with various characteristics
        originalData := generateRandomData(rand.Intn(10000) + 1)

        // Compress and decompress
        compressed, err := compressor.Compress(originalData)
        require.NoError(t, err)

        decompressed, err := compressor.Decompress(compressed)
        require.NoError(t, err)

        // Property: round-trip should preserve data exactly
        assert.Equal(t, originalData, decompressed, 
            "Round-trip failed for data of length %d", len(originalData))

        // Property: compressed size should be reasonable
        ratio := float64(len(compressed)) / float64(len(originalData))
        assert.Less(t, ratio, 2.0, 
            "Compression ratio too high: %f for data length %d", ratio, len(originalData))
    }
}

func generateRandomData(size int) []byte {
    data := make([]byte, size)

    // Mix different patterns to test various compression scenarios
    switch rand.Intn(4) {
    case 0: // Random data
        rand.Read(data)
    case 1: // Repeated patterns
        pattern := []byte("ABCDEFGH")
        for i := range data {
            data[i] = pattern[i%len(pattern)]
        }
    case 2: // Mostly zeros with some random
        for i := 0; i < size/10; i++ {
            data[rand.Intn(size)] = byte(rand.Intn(256))
        }
    case 3: // Text-like data
        chars := "abcdefghijklmnopqrstuvwxyz      \n\n"
        for i := range data {
            data[i] = chars[rand.Intn(len(chars))]
        }
    }

    return data
}
Enter fullscreen mode Exit fullscreen mode

Fuzzing File System Operations

Fuzzing automatically generates test inputs to find crashes, panics, and unexpected behaviors. Go's built-in fuzzing support makes it easy to fuzz file system operations:

func FuzzFilePathParser(f *testing.F) {
    // Seed corpus with known interesting cases
    f.Add("/path/to/file.txt")
    f.Add("../../../etc/passwd")
    f.Add("file with spaces.txt")
    f.Add("file\x00with\x00nulls")
    f.Add(strings.Repeat("a", 1000))

    f.Fuzz(func(t *testing.T, input string) {
        // The function should never panic, regardless of input
        defer func() {
            if r := recover(); r != nil {
                t.Errorf("ParseFilePath panicked with input %q: %v", input, r)
            }
        }()

        result, err := ParseFilePath(input)

        if err == nil {
            // If parsing succeeded, result should be valid
            if result.Dir == "" && result.Base == "" {
                t.Errorf("ParseFilePath returned empty result for valid input: %q", input)
            }

            // Reconstructed path should be equivalent to original
            reconstructed := filepath.Join(result.Dir, result.Base)
            if !pathsEquivalent(input, reconstructed) {
                t.Errorf("Path reconstruction mismatch: %q -> %q", input, reconstructed)
            }
        }
    })
}

func FuzzFileReader(f *testing.F) {
    f.Add([]byte("normal file content"))
    f.Add([]byte(""))
    f.Add(make([]byte, 10000)) // Large file
    f.Add([]byte("\x00\x01\x02\xff")) // Binary data

    f.Fuzz(func(t *testing.T, data []byte) {
        // Create temporary file with fuzz data
        tempFile := filepath.Join(t.TempDir(), "fuzz-test.dat")
        err := os.WriteFile(tempFile, data, 0644)
        require.NoError(t, err)

        // Test file reader with arbitrary data
        reader := NewFileReader()

        // Should handle any data without crashing
        content, err := reader.ReadFile(tempFile)
        if err != nil {
            // Error is acceptable, but should not panic
            return
        }

        // If read succeeded, content should match original
        assert.Equal(t, data, content)
    })
}
Enter fullscreen mode Exit fullscreen mode

Performance Benchmarking

Performance benchmarks measure how your file system code behaves under different load conditions. Effective benchmarks test realistic scenarios and measure relevant metrics:

func BenchmarkFileOperations(b *testing.B) {
    tempDir := b.TempDir()

    // Test different file sizes
    fileSizes := []int{1024, 10240, 102400, 1024000} // 1KB to 1MB

    for _, size := range fileSizes {
        b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
            data := make([]byte, size)
            rand.Read(data)

            b.ResetTimer()
            b.ReportAllocs()

            for i := 0; i < b.N; i++ {
                filename := filepath.Join(tempDir, fmt.Sprintf("bench_%d.dat", i))

                // Measure write performance
                err := os.WriteFile(filename, data, 0644)
                if err != nil {
                    b.Fatal(err)
                }

                // Measure read performance
                _, err = os.ReadFile(filename)
                if err != nil {
                    b.Fatal(err)
                }

                // Clean up
                os.Remove(filename)
            }

            // Report throughput
            bytesPerOp := int64(size * 2) // Read + write
            b.SetBytes(bytesPerOp)
        })
    }
}

func BenchmarkConcurrentFileAccess(b *testing.B) {
    tempDir := b.TempDir()
    numWorkers := runtime.NumCPU()

    b.ResetTimer()

    b.RunParallel(func(pb *testing.PB) {
        workerID := atomic.AddInt64(&workerCounter, 1)
        data := make([]byte, 1024)
        rand.Read(data)

        for pb.Next() {
            filename := filepath.Join(tempDir, fmt.Sprintf("worker_%d_%d.dat", workerID, time.Now().UnixNano()))

            // Write file
            err := os.WriteFile(filename, data, 0644)
            if err != nil {
                b.Fatal(err)
            }

            // Read it back
            _, err = os.ReadFile(filename)
            if err != nil {
                b.Fatal(err)
            }

            // Clean up
            os.Remove(filename)
        }
    })
}

var workerCounter int64

func BenchmarkDirectoryTraversal(b *testing.B) {
    // Create directory structure for testing
    tempDir := b.TempDir()
    createTestDirectoryStructure(tempDir, 5, 10) // 5 levels deep, 10 files per level

    walker := NewDirectoryWalker()

    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        files, err := walker.WalkDirectory(tempDir)
        if err != nil {
            b.Fatal(err)
        }

        // Ensure we found expected number of files
        if len(files) < 50 {
            b.Fatalf("Expected at least 50 files, found %d", len(files))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced testing patterns reveal problems that traditional testing approaches miss. Property-based testing finds edge cases in your logic, fuzzing discovers inputs that cause crashes, and performance benchmarks ensure your code scales appropriately. These techniques require more investment than basic unit tests, but they provide confidence that your file system code handles the full range of conditions it will encounter in production.

Top comments (0)