home / skills / kevintsengtw / dotnet-testing-agent-skills / dotnet-testing-filesystem-testing-abstractions

dotnet-testing-filesystem-testing-abstractions skill

/skills/dotnet-testing-filesystem-testing-abstractions

This skill helps you test .NET code with System.IO.Abstractions, enabling mock filesystem, isolated tests for File, Directory, and Path operations.

npx playbooks add skill kevintsengtw/dotnet-testing-agent-skills --skill dotnet-testing-filesystem-testing-abstractions

Review the files below or copy the command above to add this skill to your agents.

Files (4)
SKILL.md
11.5 KB
---
name: dotnet-testing-filesystem-testing-abstractions
description: |
  使用 System.IO.Abstractions 測試檔案系統操作的專門技能。當需要測試 File、Directory、Path 等操作、模擬檔案系統時使用。涵蓋 IFileSystem、MockFileSystem、檔案讀寫測試、目錄操作測試等。
  Keywords: file testing, filesystem, 檔案測試, 檔案系統測試, IFileSystem, MockFileSystem, System.IO.Abstractions, File.ReadAllText, File.WriteAllText, Directory.CreateDirectory, Path.Combine, mock file system, 檔案抽象化
license: MIT
metadata:
  author: Kevin Tseng
  version: "1.0.0"
  tags: ".NET, testing, IFileSystem, MockFileSystem, file testing"
  related_skills: "nsubstitute-mocking, unit-test-fundamentals, datetime-testing-timeprovider"
---

# 檔案系統測試:使用 System.IO.Abstractions 模擬檔案操作

## 適用情境

當被要求執行以下任務時,請使用此技能:

- 重構直接使用 `System.IO.File`、`System.IO.Directory` 等靜態類別的程式碼
- 為涉及檔案讀寫、目錄操作的程式碼撰寫單元測試
- 使用 MockFileSystem 模擬各種檔案系統狀態
- 測試檔案權限不足、檔案不存在等異常情境
- 設計可測試的檔案處理服務架構

## 核心原則

### 1. 檔案系統相依性的根本問題

傳統直接使用 `System.IO` 靜態類別的程式碼難以測試,原因包括:

- **速度問題**:實際磁碟 IO 比記憶體操作慢 10-100 倍
- **環境相依**:測試結果受檔案系統狀態、權限、路徑影響
- **副作用**:測試會在磁碟上留下痕跡,影響其他測試
- **並行問題**:多個測試同時操作同一檔案會產生競爭條件
- **錯誤模擬困難**:難以模擬權限不足、磁碟空間不足等異常

### 2. System.IO.Abstractions 解決方案

這是一個將 System.IO 靜態類別包裝成介面的套件,支援依賴注入和測試替身。

**核心介面架構**:

```csharp
public interface IFileSystem
{
    IFile File { get; }
    IDirectory Directory { get; }
    IFileInfo FileInfo { get; }
    IDirectoryInfo DirectoryInfo { get; }
    IPath Path { get; }
    IDriveInfo DriveInfo { get; }
}
```

**必要 NuGet 套件**:

```xml
<!-- 正式環境 -->
<PackageReference Include="System.IO.Abstractions" Version="21.*" />

<!-- 測試專案 -->
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.*" />
```

### 3. 重構步驟

**步驟一**:將直接使用靜態類別的程式碼改為依賴 `IFileSystem`

```csharp
// ❌ 重構前(不可測試)
public class ConfigService
{
    public string LoadConfig(string path)
    {
        return File.ReadAllText(path);
    }
}

// ✅ 重構後(可測試)
public class ConfigService
{
    private readonly IFileSystem _fileSystem;
    
    public ConfigService(IFileSystem fileSystem)
    {
        _fileSystem = fileSystem;
    }
    
    public string LoadConfig(string path)
    {
        return _fileSystem.File.ReadAllText(path);
    }
}
```

**步驟二**:在 DI 容器中註冊真實實作

```csharp
// Program.cs
services.AddSingleton<IFileSystem, FileSystem>();
services.AddScoped<ConfigService>();
```

**步驟三**:在測試中使用 MockFileSystem

```csharp
var mockFs = new MockFileSystem(new Dictionary<string, MockFileData>
{
    ["config.json"] = new MockFileData("{ \"key\": \"value\" }")
});
var service = new ConfigService(mockFs);
```

## MockFileSystem 測試模式

### 模式一:預設檔案狀態

```csharp
[Fact]
public async Task LoadConfig_檔案存在_應回傳內容()
{
    // Arrange - 建立預設的檔案系統狀態
    var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
    {
        ["config.json"] = new MockFileData("{ \"key\": \"value\" }"),
        [@"C:\data\users.csv"] = new MockFileData("Name,Age\nJohn,25"),
        [@"C:\logs\"] = new MockDirectoryData()  // 空目錄
    });
    
    var service = new ConfigService(mockFileSystem);
    
    // Act
    var result = await service.LoadConfigAsync("config.json");
    
    // Assert
    result.Should().Contain("key");
}
```

### 模式二:驗證寫入結果

```csharp
[Fact]
public async Task SaveConfig_指定內容_應正確寫入()
{
    // Arrange
    var mockFileSystem = new MockFileSystem();
    var service = new ConfigService(mockFileSystem);
    
    // Act
    await service.SaveConfigAsync("output.json", "{ \"saved\": true }");
    
    // Assert - 驗證檔案系統的最終狀態
    mockFileSystem.File.Exists("output.json").Should().BeTrue();
    var content = await mockFileSystem.File.ReadAllTextAsync("output.json");
    content.Should().Contain("saved");
}
```

### 模式三:測試目錄操作

```csharp
[Fact]
public void CopyFile_目標目錄不存在_應自動建立()
{
    // Arrange
    var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
    {
        [@"C:\source\file.txt"] = new MockFileData("content")
    });
    var service = new FileManagerService(mockFileSystem);
    
    // Act
    service.CopyFileToDirectory(@"C:\source\file.txt", @"C:\target\subfolder");
    
    // Assert
    mockFileSystem.Directory.Exists(@"C:\target\subfolder").Should().BeTrue();
    mockFileSystem.File.Exists(@"C:\target\subfolder\file.txt").Should().BeTrue();
}
```

### 模式四:使用 NSubstitute 模擬錯誤

當需要模擬特定異常時,MockFileSystem 支援有限,可使用 NSubstitute:

```csharp
[Fact]
public void TryReadFile_權限不足_應回傳False()
{
    // Arrange
    var mockFileSystem = Substitute.For<IFileSystem>();
    var mockFile = Substitute.For<IFile>();
    
    mockFileSystem.File.Returns(mockFile);
    mockFile.Exists("protected.txt").Returns(true);
    mockFile.ReadAllText("protected.txt")
            .Throws(new UnauthorizedAccessException("存取被拒"));
    
    var service = new FilePermissionService(mockFileSystem);
    
    // Act
    var result = service.TryReadFile("protected.txt", out var content);
    
    // Assert
    result.Should().BeFalse();
    content.Should().BeNull();
}
```

## 進階測試技巧

### 串流操作測試

```csharp
[Fact]
public async Task CountLines_多行檔案_應回傳正確行數()
{
    // Arrange
    var content = "Line 1\nLine 2\nLine 3\nLine 4";
    var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
    {
        ["data.txt"] = new MockFileData(content)
    });
    
    var processor = new StreamProcessorService(mockFileSystem);
    
    // Act
    var result = await processor.CountLinesAsync("data.txt");
    
    // Assert
    result.Should().Be(4);
}
```

### 檔案資訊測試

```csharp
[Fact]
public void GetFileInfo_檔案存在_應回傳正確資訊()
{
    // Arrange
    var content = "Hello, World!";
    var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
    {
        [@"C:\test.txt"] = new MockFileData(content)
    });
    
    var service = new FileManagerService(mockFileSystem);
    
    // Act
    var info = service.GetFileInfo(@"C:\test.txt");
    
    // Assert
    info.Should().NotBeNull();
    info!.Name.Should().Be("test.txt");
    info.Size.Should().Be(content.Length);
}
```

### 備份檔案測試

```csharp
[Fact]
public void BackupFile_檔案存在_應建立時間戳記備份()
{
    // Arrange
    var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
    {
        [@"C:\data\important.txt"] = new MockFileData("重要資料")
    });
    
    var service = new FileManagerService(mockFileSystem);
    
    // Act
    var backupPath = service.BackupFile(@"C:\data\important.txt");
    
    // Assert
    backupPath.Should().StartWith(@"C:\data\important_");
    backupPath.Should().EndWith(".txt");
    mockFileSystem.File.Exists(backupPath).Should().BeTrue();
}
```

## 最佳實踐

### ✅ 應該這樣做

1. **使用 Path.Combine 處理路徑**:

   ```csharp
   var path = _fileSystem.Path.Combine("configs", "app.json");
   ```

2. **防禦性檢查檔案存在性**:

   ```csharp
   if (!_fileSystem.File.Exists(filePath))
   {
       return defaultValue;
   }
   ```

3. **自動建立必要目錄**:

   ```csharp
   var dir = _fileSystem.Path.GetDirectoryName(filePath);
   if (!string.IsNullOrEmpty(dir) && !_fileSystem.Directory.Exists(dir))
   {
       _fileSystem.Directory.CreateDirectory(dir);
   }
   ```

4. **妥善處理各種 IO 異常**:

   ```csharp
   try
   {
       return await _fileSystem.File.ReadAllTextAsync(path);
   }
   catch (UnauthorizedAccessException) { /* 權限不足 */ }
   catch (IOException) { /* 檔案被鎖定 */ }
   catch (DirectoryNotFoundException) { /* 目錄不存在 */ }
   ```

5. **每個測試使用獨立的 MockFileSystem**:

   ```csharp
   public class ServiceTests
   {
       [Fact]
       public void Test1()
       {
           var mockFs = new MockFileSystem(); // 獨立實例
       }
       
       [Fact]
       public void Test2()
       {
           var mockFs = new MockFileSystem(); // 獨立實例
       }
   }
   ```

### ❌ 應該避免

1. **硬編碼路徑分隔符號**:

   ```csharp
   // ❌ 不要這樣做
   var path = "configs\\app.json";  // Windows only
   var path = "configs/app.json";   // Unix only
   
   // ✅ 應該這樣做
   var path = _fileSystem.Path.Combine("configs", "app.json");
   ```

2. **在單元測試中使用真實檔案系統**:

   ```csharp
   // ❌ 這不是單元測試
   var realFs = new FileSystem();
   
   // ✅ 單元測試應使用 MockFileSystem
   var mockFs = new MockFileSystem();
   ```

3. **忽略例外處理**:

   ```csharp
   // ❌ 不要假設檔案一定存在
   var content = _fileSystem.File.ReadAllText(path);
   
   // ✅ 加入存在性檢查和例外處理
   if (_fileSystem.File.Exists(path))
   {
       try { return _fileSystem.File.ReadAllText(path); }
       catch (IOException) { return defaultValue; }
   }
   ```

## 效能考量

### MockFileSystem 優勢

- **速度**:比真實檔案操作快 10-100 倍
- **可靠性**:不受磁碟狀態影響
- **隔離性**:測試之間完全隔離
- **錯誤模擬**:可精確模擬各種異常情境

### 記憶體使用建議

- 只建立測試必需的檔案
- 避免在測試中模擬超大檔案
- 對於大檔案處理邏輯,使用適度大小的測試資料:

```csharp
// ✅ 適度大小的測試資料
var testContent = string.Join("\n", 
    Enumerable.Range(1, 1000).Select(i => $"Line {i}"));
mockFileSystem.AddFile("test.txt", new MockFileData(testContent));
```

## 實務整合範例

### 設定檔管理服務

請參考 `templates/configmanager-service.cs` 中的完整實作,包含:

- 設定檔載入與儲存
- JSON 序列化與反序列化
- 自動建立目錄
- 設定檔備份功能

### 檔案管理服務

請參考 `templates/filemanager-service.cs` 中的實作,包含:

- 檔案複製與備份
- 目錄操作
- 檔案資訊查詢
- 錯誤處理模式

## 參考資源

### 原始文章

本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章:

- **Day 17 - 檔案與 IO 測試:使用 System.IO.Abstractions 模擬檔案系統**
  - 鐵人賽文章:https://ithelp.ithome.com.tw/articles/10375981
  - 範例程式碼:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day17

### 官方文件

- [System.IO.Abstractions GitHub](https://github.com/TestableIO/System.IO.Abstractions)
- [System.IO.Abstractions NuGet](https://www.nuget.org/packages/System.IO.Abstractions/)
- [TestingHelpers NuGet](https://www.nuget.org/packages/System.IO.Abstractions.TestingHelpers/)

### 相關技能

- `nsubstitute-mocking` - 測試替身與模擬
- `unit-test-fundamentals` - 單元測試基礎

Overview

This skill provides a focused workflow for testing file-system operations in .NET using System.IO.Abstractions and its MockFileSystem. It shows how to refactor code to depend on IFileSystem, set up DI with the real FileSystem, and write fast, isolated unit tests with mocked file state. The goal is reliable tests for File, Directory, Path, and file-info scenarios without touching the real disk.

How this skill works

I convert direct uses of System.IO static APIs into an IFileSystem dependency so tests can inject MockFileSystem instances. Tests create virtual files and directories, verify reads/writes, assert directory behavior, and simulate exceptions. When MockFileSystem is insufficient, the skill recommends using a substitute (e.g., NSubstitute) to throw targeted IO exceptions for error-path testing.

When to use it

  • Refactoring code that calls System.IO.File/Directory directly into testable services
  • Unit testing services that read/write files, enumerate directories, or produce backups
  • Simulating different filesystem states (missing files, empty dirs, existing content) without disk IO
  • Testing error handling for permission, IO, and not-found scenarios
  • Validating path and file-info logic (Path.Combine, file name/size)

Best practices

  • Inject IFileSystem into services and register FileSystem in DI for production
  • Use MockFileSystem per test to ensure isolation and speed
  • Always use _fileSystem.Path.Combine instead of hardcoded separators
  • Defensively check File.Exists and create directories with Directory.CreateDirectory before writes
  • Catch and handle common IO exceptions (UnauthorizedAccessException, IOException, DirectoryNotFoundException)

Example use cases

  • LoadConfig service: unit-test reading JSON from a virtual config.json via MockFileSystem
  • SaveConfig test: verify File.WriteAllText created the expected virtual file and contents
  • CopyFile behavior: assert target directory is auto-created and file exists in MockFileSystem
  • Stream processing: count lines in a virtual large-but-reasonable file stored in MockFileSystem
  • Permission error: use a substituted IFileSystem to throw UnauthorizedAccessException and verify graceful failure

FAQ

Do I always need MockFileSystem for unit tests?

Prefer MockFileSystem for unit tests to keep them fast, deterministic, and isolated. Use the real FileSystem only in integration tests that explicitly exercise disk behavior.

How do I simulate permission or disk errors?

MockFileSystem has limits for throwing specific exceptions. Substitute IFileSystem (NSubstitute or similar) and configure methods to throw the desired exception to test error handling.