iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0
Software Development

重啟挑戰:老派軟體工程師的測試修練系列 第 17

Day 17 – 檔案與 IO 測試:使用 System.IO.Abstractions 模擬檔案系統 - 實現可測試的檔案操作

  • 分享至 

  • xImage
  •  

前言

前一天學會了如何處理時間相依性的測試問題,現在要面對另一個常見的測試挑戰:檔案系統相依性

實際開發中,經常需要處理檔案操作:

  • 讀取設定檔
  • 處理上傳的檔案
  • 產生報表並儲存
  • 處理日誌檔案
  • 資料匯入匯出

傳統上,會直接使用 System.IO.FileSystem.IO.Directory 等靜態類別來處理這些操作。但是,當要為這些程式碼寫單元測試時,就會遇到許多問題。

今天將學習如何使用 System.IO.Abstractions 來解決檔案系統測試的根本問題,建立快速、可靠、不依賴真實檔案系統的測試。


檔案系統測試的根本挑戰

問題一:實際 IO 操作的速度與可靠性問題

先看一個典型的檔案處理程式碼:

public class ConfigurationService
{
    public string LoadConfig(string configPath)
    {
        return File.ReadAllText(configPath);
    }
    
    public void SaveConfig(string configPath, string content)
    {
        File.WriteAllText(configPath, content);
    }
    
    public bool ConfigExists(string configPath)
    {
        return File.Exists(configPath);
    }
}

這段程式碼看起來很正常,但當要寫測試時會遇到:

  1. 速度問題:實際的檔案 IO 操作比記憶體操作慢很多
  2. 可靠性問題:依賴磁碟狀態、檔案權限、路徑是否存在
  3. 測試隔離問題:多個測試可能會互相影響

問題二:環境相依性

[Fact]
public void LoadConfig_檔案存在_應回傳內容()
{
    // Arrange
    var configPath = "config.json";
    var expectedContent = "{ \"key\": \"value\" }";
    
    // 這裡需要先建立實際檔案
    File.WriteAllText(configPath, expectedContent);
    
    var service = new ConfigurationService();
    
    // Act
    var result = service.LoadConfig(configPath);
    
    // Assert
    result.Should().Be(expectedContent);
    
    // 測試後需要清理檔案
    File.Delete(configPath);
}

這個測試有以下問題:

  • 環境相依:依賴檔案系統狀態
  • 副作用:在檔案系統中留下痕跡
  • 權限問題:可能因為檔案權限而失敗
  • 路徑問題:在不同作業系統上可能有不同的行為
  • 清理困難:測試失敗時可能無法正確清理檔案

問題三:並行測試的檔案競爭

// 這些測試如果並行執行,可能會互相干擾
[Fact]
public void Test1() => File.WriteAllText("temp.txt", "content1");

[Fact] 
public void Test2() => File.WriteAllText("temp.txt", "content2");

問題四:難以模擬錯誤情境

要如何測試以下情況:

  • 檔案不存在
  • 權限不足
  • 磁碟空間不足
  • 檔案被其他程序鎖定
  • 網路磁碟機連線中斷

System.IO.Abstractions 解決方案

什麼是 System.IO.Abstractions?

System.IO.Abstractions 是一個 .NET 套件,它將 System.IO 的靜態類別包裝成介面,支援在測試中使用依賴注入和模擬。

核心特色

  1. 抽象化檔案系統操作:將 File、Directory、FileInfo、DirectoryInfo 等包裝成介面
  2. 支援依賴注入:可以直接將檔案系統操作注入到服務中
  3. 測試友善設計:提供 MockFileSystem 進行記憶體檔案系統模擬
  4. 完全相容:API 與 System.IO 完全相同,重構成本低

核心介面架構

// 主要介面
public interface IFileSystem
{
    IFile File { get; }
    IDirectory Directory { get; }
    IFileInfo FileInfo { get; }
    IDirectoryInfo DirectoryInfo { get; }
    IPath Path { get; }
    IDriveInfo DriveInfo { get; }
}

// 檔案操作介面
public interface IFile
{
    string ReadAllText(string path);
    void WriteAllText(string path, string content);
    bool Exists(string path);
    void Delete(string path);
    void Copy(string sourceFileName, string destFileName);
    // ... 更多方法
}

重構檔案相依程式碼

將剛才的 ConfigurationService 重構為可測試的版本:

重構前(不可測試)

public class ConfigurationService
{
    public string LoadConfig(string configPath)
    {
        return File.ReadAllText(configPath);
    }
    
    public void SaveConfig(string configPath, string content)
    {
        File.WriteAllText(configPath, content);
    }
    
    public bool ConfigExists(string configPath)
    {
        return File.Exists(configPath);
    }
}

重構後(可測試)

using System.IO.Abstractions;

public class ConfigurationService
{
    private readonly IFileSystem _fileSystem;
    
    public ConfigurationService(IFileSystem fileSystem)
    {
        _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
    }
    
    /// <summary>
    /// 載入配置值,如果檔案不存在則回傳預設值
    /// </summary>
    /// <param name="filePath">配置檔案路徑</param>
    /// <param name="defaultValue">預設值</param>
    /// <returns>配置值</returns>
    public async Task<string> LoadConfigurationAsync(string filePath, string defaultValue = "")
    {
        if (!_fileSystem.File.Exists(filePath))
        {
            return defaultValue;
        }

        try
        {
            return await _fileSystem.File.ReadAllTextAsync(filePath);
        }
        catch (Exception)
        {
            return defaultValue;
        }
    }
    
    /// <summary>
    /// 保存配置到檔案
    /// </summary>
    /// <param name="filePath">配置檔案路徑</param>
    /// <param name="value">要保存的值</param>
    public async Task SaveConfigurationAsync(string filePath, string value)
    {
        var directory = _fileSystem.Path.GetDirectoryName(filePath);
        if (!string.IsNullOrEmpty(directory) && !_fileSystem.Directory.Exists(directory))
        {
            _fileSystem.Directory.CreateDirectory(directory);
        }

        await _fileSystem.File.WriteAllTextAsync(filePath, value);
    }
    
    /// <summary>
    /// 載入 JSON 配置
    /// </summary>
    /// <typeparam name="T">配置類型</typeparam>
    /// <param name="filePath">配置檔案路徑</param>
    /// <returns>配置物件,如果檔案不存在或解析失敗則回傳預設值</returns>
    public async Task<T?> LoadJsonConfigurationAsync<T>(string filePath) where T : class
    {
        if (!_fileSystem.File.Exists(filePath))
        {
            return default;
        }

        try
        {
            var jsonContent = await _fileSystem.File.ReadAllTextAsync(filePath);
            return System.Text.Json.JsonSerializer.Deserialize<T>(jsonContent);
        }
        catch (Exception)
        {
            return default;
        }
    }
}

在實際應用中註冊 IFileSystem

// Program.cs 或 Startup.cs
services.AddSingleton<IFileSystem, FileSystem>();
services.AddScoped<ConfigurationService>();

MockFileSystem 測試實戰

基礎檔案操作測試

在重構了 ConfigurationService 之後,現在可以使用 MockFileSystem 來建立可控制的測試環境。這個章節將說明如何測試最基本的檔案操作:讀取、寫入和檢查檔案是否存在。

MockFileSystem 的核心優勢

  1. 記憶體模擬:所有檔案操作都在記憶體中進行,速度極快
  2. 狀態控制:可以直接定義檔案系統的初始狀態
  3. 驗證容易:測試後可以直接檢查檔案系統的最終狀態

看看如何測試 ConfigurationService 的各個方法:

using System.IO.Abstractions.TestingHelpers;
using AwesomeAssertions;

public class ConfigurationServiceTests
{
    [Fact]
    public async Task LoadConfigurationAsync_檔案存在_應回傳內容()
    {
        // Arrange
        var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
        {
            ["config.json"] = new MockFileData("{ \"key\": \"value\" }")
        });
        
        var service = new ConfigurationService(mockFileSystem);
        
        // Act
        var result = await service.LoadConfigurationAsync("config.json");
        
        // Assert
        result.Should().Be("{ \"key\": \"value\" }");
    }
    
    [Fact]
    public async Task SaveConfigurationAsync_指定內容_應正確寫入檔案()
    {
        // Arrange
        var mockFileSystem = new MockFileSystem();
        var service = new ConfigurationService(mockFileSystem);
        var configPath = "config.json";
        var content = "{ \"setting\": true }";
        
        // Act
        await service.SaveConfigurationAsync(configPath, content);
        
        // Assert
        mockFileSystem.File.Exists(configPath).Should().BeTrue();
        var savedContent = await mockFileSystem.File.ReadAllTextAsync(configPath);
        savedContent.Should().Be(content);
    }
    
    [Fact]
    public async Task LoadConfigurationAsync_檔案不存在_應回傳預設值()
    {
        // Arrange
        var mockFileSystem = new MockFileSystem();
        var service = new ConfigurationService(mockFileSystem);
        var defaultValue = "default_config";
        
        // Act
        var result = await service.LoadConfigurationAsync("nonexistent.json", defaultValue);
        
        // Assert
        result.Should().Be(defaultValue);
    }
    
    [Fact]
    public async Task LoadJsonConfigurationAsync_有效JSON_應正確反序列化()
    {
        // Arrange
        var testSettings = new { Name = "Test", Value = 123 };
        var json = System.Text.Json.JsonSerializer.Serialize(testSettings);
        var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
        {
            ["settings.json"] = new MockFileData(json)
        });
        
        var service = new ConfigurationService(mockFileSystem);
        
        // Act
        var result = await service.LoadJsonConfigurationAsync<dynamic>("settings.json");
        
        // Assert
        result.Should().NotBeNull();
    }
}

目錄操作與路徑處理測試

實際專案中的檔案管理需求

基本的檔案讀寫只是開始,實際專案中經常要處理更複雜的情況。比如管理整個目錄結構、取得檔案資訊、建立備份檔案等。

來看看一個更實用的檔案管理服務,它涵蓋了常見的檔案操作需求:

  • 目錄管理:列出目錄中的檔案、建立目錄結構
  • 檔案資訊查詢:取得檔案大小、修改日期等資訊
  • 檔案備份:建立檔案副本,通常加上時間戳記
  • 路徑處理:跨平台的路徑組合和解析

這個 FileManagerService 說明了如何處理這些常見任務,重點是要做好防禦性程式設計和錯誤處理。

/// <summary>
/// 檔案管理服務,提供檔案和目錄的基本操作功能
/// </summary>
public class FileManagerService
{
    private readonly IFileSystem _fileSystem;
    
    /// <summary>
    /// 建構檔案管理服務
    /// </summary>
    /// <param name="fileSystem">檔案系統介面</param>
    public FileManagerService(IFileSystem fileSystem)
    {
        _fileSystem = fileSystem;
    }
    
    /// <summary>
    /// 複製檔案到指定目錄
    /// </summary>
    /// <param name="sourceFilePath">來源檔案路徑</param>
    /// <param name="targetDirectory">目標目錄</param>
    /// <returns>目標檔案路徑</returns>
    public string CopyFileToDirectory(string sourceFilePath, string targetDirectory)
    {
        if (!_fileSystem.File.Exists(sourceFilePath))
        {
            throw new FileNotFoundException($"來源檔案不存在: {sourceFilePath}");
        }

        if (!_fileSystem.Directory.Exists(targetDirectory))
        {
            _fileSystem.Directory.CreateDirectory(targetDirectory);
        }

        var fileName = _fileSystem.Path.GetFileName(sourceFilePath);
        var targetFilePath = _fileSystem.Path.Combine(targetDirectory, fileName);

        _fileSystem.File.Copy(sourceFilePath, targetFilePath, overwrite: true);
        return targetFilePath;
    }
    
    /// <summary>
    /// 備份檔案(加上時間戳記)
    /// </summary>
    /// <param name="filePath">要備份的檔案路徑</param>
    /// <returns>備份檔案路徑</returns>
    public string BackupFile(string filePath)
    {
        if (!_fileSystem.File.Exists(filePath))
        {
            throw new FileNotFoundException($"檔案不存在: {filePath}");
        }

        var directory = _fileSystem.Path.GetDirectoryName(filePath);
        var fileNameWithoutExtension = _fileSystem.Path.GetFileNameWithoutExtension(filePath);
        var extension = _fileSystem.Path.GetExtension(filePath);
        var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");

        var backupFileName = $"{fileNameWithoutExtension}_{timestamp}{extension}";
        var backupFilePath = _fileSystem.Path.Combine(directory ?? "", backupFileName);

        _fileSystem.File.Copy(filePath, backupFilePath);
        return backupFilePath;
    }
    
    /// <summary>
    /// 取得檔案資訊
    /// </summary>
    /// <param name="filePath">檔案路徑</param>
    /// <returns>檔案資訊</returns>
    public FileInfoData? GetFileInfo(string filePath)
    {
        if (!_fileSystem.File.Exists(filePath))
        {
            return null;
        }

        var fileInfo = _fileSystem.FileInfo.New(filePath);
        return new FileInfoData
        {
            Name = fileInfo.Name,
            FullPath = fileInfo.FullName,
            Size = fileInfo.Length,
            CreationTime = fileInfo.CreationTime,
            LastWriteTime = fileInfo.LastWriteTime,
            IsReadOnly = fileInfo.IsReadOnly
        };
    }

    /// <summary>
    /// 檔案資訊資料類別
    /// </summary>
    public class FileInfoData
    {
        public string Name { get; set; } = string.Empty;
        public string FullPath { get; set; } = string.Empty;
        public long Size { get; set; }
        public DateTime CreationTime { get; set; }
        public DateTime LastWriteTime { get; set; }
        public bool IsReadOnly { get; set; }
    }
}

目錄操作測試

除了單純的檔案讀寫,目錄操作也是常見需求。要列出目錄中的檔案、建立目錄結構、取得檔案大小等。

傳統測試中要建立複雜的目錄結構很麻煩,但用 MockFileSystem 可以快速模擬任何目錄結構,還能驗證程式是否正確處理各種邊界情況。

測試重點包括:目錄存在性檢查、檔案清單功能、目錄建立邏輯、檔案資訊取得等。

public class FileManagerServiceTests
{
    [Fact]
    public void CopyFileToDirectory_檔案存在_應成功複製()
    {
        // Arrange
        var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
        {
            [@"C:\source\test.txt"] = new MockFileData("test content")
        });
        
        var service = new FileManagerService(mockFileSystem);
        
        // Act
        var result = service.CopyFileToDirectory(@"C:\source\test.txt", @"C:\target");
        
        // Assert
        result.Should().Be(@"C:\target\test.txt");
        mockFileSystem.File.Exists(@"C:\target\test.txt").Should().BeTrue();
        mockFileSystem.File.ReadAllText(@"C:\target\test.txt").Should().Be("test content");
    }
    
    [Fact]
    public void CopyFileToDirectory_來源檔案不存在_應拋出FileNotFoundException()
    {
        // Arrange
        var mockFileSystem = new MockFileSystem();
        var service = new FileManagerService(mockFileSystem);
        
        // Act & Assert
        var action = () => service.CopyFileToDirectory(@"C:\nonexistent.txt", @"C:\target");
        action.Should().Throw<FileNotFoundException>()
              .WithMessage("來源檔案不存在: C:\\nonexistent.txt");
    }
    
    [Fact]
    public void BackupFile_檔案存在_應建立備份檔案()
    {
        // Arrange
        var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
        {
            [@"C:\data\important.txt"] = new MockFileData("important data")
        });
        
        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();
        mockFileSystem.File.ReadAllText(backupPath).Should().Be("important data");
    }
    
    [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 result = service.GetFileInfo(@"C:\test.txt");
        
        // Assert
        result.Should().NotBeNull();
        result!.Name.Should().Be("test.txt");
        result.Size.Should().Be(content.Length);
    }
    
    [Fact]
    public void GetFileInfo_檔案不存在_應回傳Null()
    {
        // Arrange
        var mockFileSystem = new MockFileSystem();
        var service = new FileManagerService(mockFileSystem);
        
        // Act
        var result = service.GetFileInfo(@"C:\nonexistent.txt");
        
        // Assert
        result.Should().BeNull();
    }
}

進階檔案操作測試技巧

測試檔案權限與錯誤情境

正式環境中,檔案操作常常會碰到各種異常狀況:權限不足、檔案被鎖定、路徑無效、磁碟空間不足等等。

傳統測試很難模擬這些錯誤情況,因為要在檔案系統層級製造這些問題並不容易。但透過抽象化的檔案系統介面,可以直接測試異常處理邏輯。

下面的 FilePermissionService 說明了如何處理檔案操作中的各種錯誤情況,使用 try-catch 模式來安全地處理異常。

public class FilePermissionService
{
    private readonly IFileSystem _fileSystem;
    
    public FilePermissionService(IFileSystem fileSystem)
    {
        _fileSystem = fileSystem;
    }
    
    public bool TryReadFile(string filePath, out string content)
    {
        content = null;
        
        try
        {
            if (!_fileSystem.File.Exists(filePath))
            {
                return false;
            }
                
            content = _fileSystem.File.ReadAllText(filePath);
            return true;
        }
        catch (UnauthorizedAccessException)
        {
            return false;
        }
        catch (IOException)
        {
            return false;
        }
    }
}

使用 NSubstitute 模擬錯誤情境

MockFileSystem 主要設計用於正常流程的測試,對於異常情況的模擬支援有限。需要模擬特定錯誤(像是權限不足、檔案被鎖定等)時,可以配合 NSubstitute 來建立更靈活的錯誤情境測試。

這種方法可以精確控制何時、如何拋出異常,確保錯誤處理邏輯真正有效。

public class FilePermissionServiceTests
{
    [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 string content);
        
        // Assert
        result.Should().BeFalse();
        content.Should().BeNull();
    }
    
    [Fact]
    public void TryReadFile_IO錯誤_應回傳False()
    {
        // Arrange
        var mockFileSystem = Substitute.For<IFileSystem>();
        var mockFile = Substitute.For<IFile>();
        
        mockFileSystem.File.Returns(mockFile);
        mockFile.Exists("locked.txt").Returns(true);
        mockFile.ReadAllText("locked.txt").Throws(new IOException("檔案被其他程序使用中"));
        
        var service = new FilePermissionService(mockFileSystem);
        
        // Act
        var result = service.TryReadFile("locked.txt", out string content);
        
        // Assert
        result.Should().BeFalse();
        content.Should().BeNull();
    }
}

大檔案與串流操作測試

實際應用中,經常要處理大型檔案,例如日誌檔分析、資料處理、串流轉換等。直接把大檔案載入記憶體不僅效率低,還可能造成記憶體不足,所以需要使用串流來逐步處理。

串流處理服務

串流處理的關鍵是要正確管理資源、處理非同步操作、做好錯誤處理,還要確保記憶體效率。MockFileSystem 支援串流操作,可以在測試中模擬各種大小的檔案,而不需要實際建立大檔案。

public class StreamProcessorService
{
    private readonly IFileSystem _fileSystem;
    
    public StreamProcessorService(IFileSystem fileSystem)
    {
        _fileSystem = fileSystem;
    }
    
    public async Task<int> CountLinesAsync(string filePath)
    {
        using var stream = _fileSystem.File.OpenRead(filePath);
        using var reader = new StreamReader(stream);
        
        int lineCount = 0;
        while (await reader.ReadLineAsync() != null)
        {
            lineCount++;
        }
        
        return lineCount;
    }
    
    public async Task ProcessLargeFileAsync(string inputPath, string outputPath, Func<string, string> processor)
    {
        using var inputStream = _fileSystem.File.OpenRead(inputPath);
        using var outputStream = _fileSystem.File.Create(outputPath);
        using var reader = new StreamReader(inputStream);
        using var writer = new StreamWriter(outputStream);
        
        string line;
        while ((line = await reader.ReadLineAsync()) != null)
        {
            var processedLine = processor(line);
            await writer.WriteLineAsync(processedLine);
        }
    }
}

串流操作測試

測試串流操作時,要驗證處理結果是否正確、確保大檔案處理不會造成記憶體問題、驗證 Stream 物件被正確釋放、還要測試處理過程中的異常情況。

使用 MockFileSystem 的好處是可以建立任意大小和內容的模擬檔案,完全不用擔心磁碟空間或處理速度問題。

public class StreamProcessorServiceTests
{
    [Fact]
    public async Task CountLinesAsync_多行檔案_應回傳正確行數()
    {
        // Arrange
        var content = "Line 1\nLine 2\nLine 3\nLine 4";
        var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
        {
            ["test.txt"] = new MockFileData(content)
        });
        
        var service = new StreamProcessorService(mockFileSystem);
        
        // Act
        var result = await service.CountLinesAsync("test.txt");
        
        // Assert
        result.Should().Be(4);
    }
    
    [Fact]
    public async Task ProcessLargeFileAsync_處理每一行_應正確轉換並寫入()
    {
        // Arrange
        var inputContent = "hello\nworld\ntest";
        var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
        {
            ["input.txt"] = new MockFileData(inputContent)
        });
        
        var service = new StreamProcessorService(mockFileSystem);
        
        // Act
        await service.ProcessLargeFileAsync("input.txt", "output.txt", line => line.ToUpper());
        
        // Assert
        var outputContent = mockFileSystem.File.ReadAllText("output.txt");
        outputContent.Should().Be("HELLO\r\nWORLD\r\nTEST\r\n");
    }
}

實務整合範例:設定檔管理器

建立一個完整的範例,說明 System.IO.Abstractions 在實際專案中的應用。這個設定檔管理服務包含了前面學到的各種技巧。

完整的設定檔管理服務

/// <summary>
/// 整合配置管理器,示範實際應用場景
/// </summary>
public class ConfigManagerService
{
    private readonly IFileSystem _fileSystem;
    private readonly string _configDirectory;
    
    /// <summary>
    /// 建構配置管理服務
    /// </summary>
    /// <param name="fileSystem">檔案系統介面</param>
    /// <param name="configDirectory">配置檔目錄路徑</param>
    public ConfigManagerService(IFileSystem fileSystem, string configDirectory = "config")
    {
        _fileSystem = fileSystem;
        _configDirectory = configDirectory;
    }
    
    /// <summary>
    /// 初始化配置目錄
    /// </summary>
    public void InitializeConfigDirectory()
    {
        if (!string.IsNullOrWhiteSpace(_configDirectory) && !_fileSystem.Directory.Exists(_configDirectory))
        {
            _fileSystem.Directory.CreateDirectory(_configDirectory);
        }
    }
    
    /// <summary>
    /// 載入應用程式設定
    /// </summary>
    /// <returns>應用程式設定</returns>
    public async Task<AppSettings> LoadAppSettingsAsync()
    {
        var configPath = _fileSystem.Path.Combine(_configDirectory, "appsettings.json");

        if (!_fileSystem.File.Exists(configPath))
        {
            var defaultSettings = new AppSettings();
            await SaveAppSettingsAsync(defaultSettings);
            return defaultSettings;
        }

        try
        {
            var jsonContent = await _fileSystem.File.ReadAllTextAsync(configPath);
            var settings = System.Text.Json.JsonSerializer.Deserialize<AppSettings>(jsonContent);
            return settings ?? new AppSettings();
        }
        catch (Exception)
        {
            return new AppSettings();
        }
    }
    
    /// <summary>
    /// 保存應用程式設定
    /// </summary>
    /// <param name="settings">應用程式設定</param>
    public async Task SaveAppSettingsAsync(AppSettings settings)
    {
        InitializeConfigDirectory();

        var configPath = _fileSystem.Path.Combine(_configDirectory, "appsettings.json");
        var jsonContent = System.Text.Json.JsonSerializer.Serialize(settings, new System.Text.Json.JsonSerializerOptions
        {
            WriteIndented = true
        });

        await _fileSystem.File.WriteAllTextAsync(configPath, jsonContent);
    }
    
    /// <summary>
    /// 備份現有配置
    /// </summary>
    /// <returns>備份檔案路徑</returns>
    public string BackupConfiguration()
    {
        var configPath = _fileSystem.Path.Combine(_configDirectory, "appsettings.json");

        if (!_fileSystem.File.Exists(configPath))
        {
            throw new FileNotFoundException("找不到要備份的配置檔案");
        }

        var backupDirectory = _fileSystem.Path.Combine(_configDirectory, "backup");
        if (!_fileSystem.Directory.Exists(backupDirectory))
        {
            _fileSystem.Directory.CreateDirectory(backupDirectory);
        }

        var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
        var backupFileName = $"appsettings_{timestamp}.json";
        var backupPath = _fileSystem.Path.Combine(backupDirectory, backupFileName);

        _fileSystem.File.Copy(configPath, backupPath);
        return backupPath;
    }

    /// <summary>
    /// 應用程式設定
    /// </summary>
    public class AppSettings
    {
        public string ApplicationName { get; set; } = "Day17 FileSystem Testing Demo";
        public string Version { get; set; } = "1.0.0";
        public DatabaseSettings Database { get; set; } = new();
        public LoggingSettings Logging { get; set; } = new();
    }

    /// <summary>
    /// 資料庫設定
    /// </summary>
    public class DatabaseSettings
    {
        public string ConnectionString { get; set; } = "Server=localhost;Database=TestDb;";
        public int TimeoutSeconds { get; set; } = 30;
    }

    /// <summary>
    /// 日誌設定
    /// </summary>
    public class LoggingSettings
    {
        public string Level { get; set; } = "Information";
        public bool EnableFileLogging { get; set; } = true;
        public string LogDirectory { get; set; } = "logs";
    }
}

完整測試範例

這個章節說明如何為完整的設定檔管理服務建立測試套件。ConfigManagerService 整合了前面學到的所有概念:設定檔載入(支援泛型類別載入,具備錯誤處理)、設定檔儲存(格式化 JSON 輸出)、設定檔管理(列出、刪除設定檔)、備份功能(批次備份所有設定檔)。

測試要涵蓋正常流程、邊界條件(檔案不存在、格式錯誤等)、錯誤處理(JSON 解析失敗)、還有多個功能組合使用的整合情況。

這個範例說明了在真實專案中如何應用 System.IO.Abstractions 來建立完整、可靠的檔案操作測試。

public class ConfigManagerServiceTests
{
    private readonly MockFileSystem _mockFileSystem;
    private readonly ConfigManagerService _service;
    
    public ConfigManagerServiceTests()
    {
        _mockFileSystem = new MockFileSystem();
        _service = new ConfigManagerService(_mockFileSystem, "test-config");
    }
    
    [Fact]
    public async Task LoadAppSettingsAsync_設定檔不存在_應回傳並建立預設設定()
    {
        // Act
        var result = await _service.LoadAppSettingsAsync();
        
        // Assert
        result.Should().NotBeNull();
        result.ApplicationName.Should().Be("Day17 FileSystem Testing Demo");
        result.Version.Should().Be("1.0.0");
        result.Database.Should().NotBeNull();
        result.Logging.Should().NotBeNull();
        
        // 應該建立預設設定檔
        var configPath = @"test-config\appsettings.json";
        _mockFileSystem.File.Exists(configPath).Should().BeTrue();
    }
    
    [Fact]
    public async Task SaveAppSettingsAsync_儲存設定_應正確寫入檔案()
    {
        // Arrange
        var settings = new ConfigManagerService.AppSettings
        {
            ApplicationName = "Test App",
            Version = "2.0.0",
            Database = new ConfigManagerService.DatabaseSettings
            {
                ConnectionString = "Server=test;Database=TestDb;",
                TimeoutSeconds = 60
            }
        };
        
        // Act
        await _service.SaveAppSettingsAsync(settings);
        
        // Assert
        var configPath = @"test-config\appsettings.json";
        _mockFileSystem.File.Exists(configPath).Should().BeTrue();
        
        var savedContent = await _mockFileSystem.File.ReadAllTextAsync(configPath);
        var savedSettings = System.Text.Json.JsonSerializer.Deserialize<ConfigManagerService.AppSettings>(savedContent);
        
        savedSettings!.ApplicationName.Should().Be("Test App");
        savedSettings.Version.Should().Be("2.0.0");
        savedSettings.Database.ConnectionString.Should().Be("Server=test;Database=TestDb;");
        savedSettings.Database.TimeoutSeconds.Should().Be(60);
    }
    
    [Fact]
    public async Task LoadAppSettingsAsync_設定檔存在_應回傳正確設定()
    {
        // Arrange
        var settings = new ConfigManagerService.AppSettings
        {
            ApplicationName = "Existing App",
            Version = "3.0.0"
        };
        
        var json = System.Text.Json.JsonSerializer.Serialize(settings, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
        var configPath = @"test-config\appsettings.json";
        _mockFileSystem.AddFile(configPath, new MockFileData(json));
        
        // Act
        var result = await _service.LoadAppSettingsAsync();
        
        // Assert
        result.ApplicationName.Should().Be("Existing App");
        result.Version.Should().Be("3.0.0");
    }
    
    [Fact]
    public void BackupConfiguration_設定檔存在_應建立備份檔案()
    {
        // Arrange
        var settings = new ConfigManagerService.AppSettings();
        var json = System.Text.Json.JsonSerializer.Serialize(settings);
        var configPath = @"test-config\appsettings.json";
        _mockFileSystem.AddFile(configPath, new MockFileData(json));
        
        // Act
        var backupPath = _service.BackupConfiguration();
        
        // Assert
        _mockFileSystem.File.Exists(backupPath).Should().BeTrue();
        backupPath.Should().StartWith(@"test-config\backup\appsettings_");
        backupPath.Should().EndWith(".json");
        
        var backupContent = _mockFileSystem.File.ReadAllText(backupPath);
        backupContent.Should().Be(json);
    }
    
    [Fact]
    public void BackupConfiguration_設定檔不存在_應拋出FileNotFoundException()
    {
        // Act & Assert
        var action = () => _service.BackupConfiguration();
        action.Should().Throw<FileNotFoundException>()
              .WithMessage("找不到要備份的配置檔案");
    }
}

最佳實踐與注意事項

1. 選擇適當的測試策略

// O 好的做法:使用 MockFileSystem 進行單元測試
[Fact]
public void ProcessFile_檔案存在_應處理成功()
{
    var mockFileSystem = new MockFileSystem();
    // ...
}

// X 避免:在單元測試中使用真實檔案系統
[Fact]
public void ProcessFile_真實檔案_應處理成功() // ← 這不是單元測試
{
    var realFileSystem = new FileSystem();
    // ...
}

2. 路徑處理的跨平台考量

// O 好的做法:使用 Path.Combine
var configPath = _fileSystem.Path.Combine("configs", "app.json");

// X 避免:硬編碼路徑分隔符號
var configPath = "configs\\app.json"; // Windows only
var configPath = "configs/app.json";  // Unix only

3. 適當的錯誤處理

public async Task<bool> TrySaveFileAsync(string path, string content)
{
    try
    {
        await _fileSystem.File.WriteAllTextAsync(path, content);
        return true;
    }
    catch (DirectoryNotFoundException)
    {
        // 嘗試建立目錄後重試
        var directory = _fileSystem.Path.GetDirectoryName(path);
        _fileSystem.Directory.CreateDirectory(directory);
        
        try
        {
            await _fileSystem.File.WriteAllTextAsync(path, content);
            return true;
        }
        catch
        {
            return false;
        }
    }
    catch (UnauthorizedAccessException)
    {
        return false;
    }
    catch (IOException)
    {
        return false;
    }
}

4. 測試資料的組織

public class FileTestDataHelper
{
    public static Dictionary<string, MockFileData> CreateTestFileStructure()
    {
        return new Dictionary<string, MockFileData>
        {
            [@"C:\app\configs\app.json"] = new MockFileData("""
                {
                  "apiUrl": "https://api.test.com",
                  "timeout": 30
                }
                """),
            [@"C:\app\logs\app.log"] = new MockFileData("2024-01-01 10:00:00 INFO Application started"),
            [@"C:\app\data\users.csv"] = new MockFileData("Name,Age\nJohn,25\nJane,30"),
            [@"C:\temp\"] = new MockDirectoryData()
        };
    }
}

// 在測試中使用
var mockFileSystem = new MockFileSystem(FileTestDataHelper.CreateTestFileStructure());

效能考量與限制

MockFileSystem vs 真實檔案系統效能比較

雖然 MockFileSystem 在功能上完全相容於真實檔案系統,但在效能特性上有顯著差異。了解這些差異有助於我們:

  1. 測試策略選擇:何時使用 MockFileSystem,何時使用整合測試
  2. 效能預期:理解測試執行速度差異的原因
  3. 測試設計:設計更有效率的測試

效能差異的原因

  • 記憶體 vs 磁碟:MockFileSystem 在記憶體中操作,真實檔案系統需要磁碟 IO
  • 系統呼叫:MockFileSystem 不需要作業系統層級的檔案操作
  • 檔案系統開銷:沒有檔案系統權限檢查、快取管理等開銷

這個範例提供具體的效能比較,幫助你量化使用 MockFileSystem 帶來的速度提升。

[Fact]
public void 效能比較_MockFileSystem_vs_真實檔案系統()
{
    var stopwatch = Stopwatch.StartNew();
    
    // MockFileSystem 效能測試
    var mockFileSystem = new MockFileSystem();
    for (int i = 0; i < 1000; i++)
    {
        mockFileSystem.File.WriteAllText($"test_{i}.txt", $"Content {i}");
    }
    stopwatch.Stop();
    var mockTime = stopwatch.ElapsedMilliseconds;
    
    stopwatch.Restart();
    
    // 真實檔案系統效能測試(僅作比較,實際測試不應使用)
    var realFileSystem = new FileSystem();
    var tempDir = Path.GetTempPath();
    for (int i = 0; i < 1000; i++)
    {
        var path = Path.Combine(tempDir, $"test_{i}.txt");
        realFileSystem.File.WriteAllText(path, $"Content {i}");
    }
    stopwatch.Stop();
    var realTime = stopwatch.ElapsedMilliseconds;
    
    // MockFileSystem 通常快 10-100 倍
    Console.WriteLine($"MockFileSystem: {mockTime}ms, Real FileSystem: {realTime}ms");
    
    // 清理真實檔案
    for (int i = 0; i < 1000; i++)
    {
        var path = Path.Combine(tempDir, $"test_{i}.txt");
        if (File.Exists(path)) 
        {
            File.Delete(path);
        }
    }
}

記憶體使用注意事項

雖然 MockFileSystem 帶來了速度優勢,但我們也需要注意記憶體使用的問題:

記憶體考量重點

  1. 模擬檔案大小:MockFileData 會將檔案內容儲存在記憶體中
  2. 檔案數量:大量檔案可能會消耗較多記憶體
  3. 測試隔離:每個測試都應該使用新的 MockFileSystem 實例
  4. 資源釋放:確保測試結束後釋放記憶體

最佳實踐

  • 適度模擬:只建立測試必需的檔案
  • 記憶體監控:在大量檔案的測試中監控記憶體使用
  • 測試分割:將大型測試分解為較小的測試單元

這個範例展示如何監控和驗證測試的記憶體使用情況,確保測試的效能不會因為記憶體問題而受影響。

[Fact]
public void 大量檔案處理_記憶體使用_應保持穩定()
{
    // Arrange
    var mockFileSystem = new MockFileSystem();
    
    // 建立大量小檔案
    for (int i = 0; i < 10000; i++)
    {
        mockFileSystem.AddFile($"file_{i}.txt", new MockFileData($"Content {i}"));
    }
    
    var initialMemory = GC.GetTotalMemory(false);
    
    // Act - 處理檔案
    var processor = new BatchFileProcessor(mockFileSystem);
    processor.ProcessAllFiles();
    
    // Assert
    GC.Collect();
    GC.WaitForPendingFinalizers();
    
    var finalMemory = GC.GetTotalMemory(false);
    var memoryIncrease = finalMemory - initialMemory;
    
    // 記憶體增長應該在合理範圍內
    (memoryIncrease / 1024.0 / 1024.0).Should().BeLessThan(50); // 少於 50MB
}

與其他測試技術的整合

與 AutoFixture 整合

System.IO.Abstractions 可以與我們之前學過的測試技術整合。結合 AutoFixture 可以讓檔案系統測試更強大:

整合優勢

  1. 自動化測試資料:AutoFixture 自動產生檔案名稱和內容
  2. 邊界值測試:自動測試各種不同的檔案路徑和內容組合
  3. 重複測試:同一個測試邏輯可以用不同資料重複執行
  4. 減少測試程式碼:無需手動建立測試資料

使用場景

  • 測試檔案處理邏輯是否能處理各種輸入
  • 驗證檔案名稱處理的邊界條件
  • 確保檔案內容處理的正確性

這種結合可以用最少的程式碼覆蓋最多的測試情境。

public class FileServiceWithAutoFixtureTests
{
    [Theory, AutoData]
    public void ProcessFile_自動產生內容_應正確處理(string fileName, string content)
    {
        // Arrange
        var mockFileSystem = new MockFileSystem();
        mockFileSystem.AddFile(fileName, new MockFileData(content));
        
        var service = new FileProcessorService(mockFileSystem);
        
        // Act
        var result = service.ProcessFile(fileName);
        
        // Assert
        result.Should().NotBeNull();
        result.OriginalContent.Should().Be(content);
    }
}

與 NSubstitute 的組合使用

在某些複雜的場景中,我們可能需要同時使用 NSubstitute 和 System.IO.Abstractions:

適用場景

  1. 複合服務測試:服務同時依賴檔案系統和其他外部服務
  2. 行為驗證:需要驗證檔案操作的同時記錄日誌
  3. 錯誤處理整合:檔案錯誤和業務邏輯錯誤的整合測試
  4. 複雜模擬:需要精確控制檔案系統行為的測試

技術重點

  • 多重模擬:同時模擬檔案系統和其他依賴
  • 行為驗證:確認方法被正確呼叫
  • 狀態與行為:結合狀態檢查和行為驗證

這種組合可以建立精確的測試,覆蓋複雜的業務場景。

[Fact]
public void ComplexFileOperation_結合NSubstitute_應正確模擬()
{
    // Arrange
    var mockFileSystem = Substitute.For<IFileSystem>();
    var mockFile = Substitute.For<IFile>();
    var mockLogger = Substitute.For<ILogger>();
    
    mockFileSystem.File.Returns(mockFile);
    mockFile.Exists("test.txt").Returns(true);
    mockFile.ReadAllText("test.txt").Returns("test content");
    
    var service = new ComplexFileService(mockFileSystem, mockLogger);
    
    // Act
    service.ProcessFileWithLogging("test.txt");
    
    // Assert
    mockFile.Received(1).ReadAllText("test.txt");
    mockLogger.Received(1).LogInformation(Arg.Is<string>(s => s.Contains("Processing file: test.txt")));
}

今日小結

核心概念

  • System.IO.Abstractions:將檔案操作邏輯抽象為可注入的服務
  • 測試可控性:透過 MockFileSystem 完全控制測試中的檔案系統
  • 環境隔離:測試不再依賴實際的檔案系統狀態

實戰技能

  • 基礎重構:將檔案相依程式碼改為使用 IFileSystem
  • 測試設計:使用 MockFileSystem 進行檔案系統模擬測試
  • 錯誤情境模擬:透過 NSubstitute 模擬各種檔案系統錯誤

關鍵收穫

  1. 速度提升:MockFileSystem 比真實檔案操作快 10-100 倍
  2. 可靠性增強:測試不再受檔案系統狀態影響
  3. 完整覆蓋:能夠測試所有檔案相關的邊界條件和異常情況

透過 System.IO.Abstractions,已經解決了檔案系統測試的根本問題。不再需要擔心測試會因為檔案權限而失敗,也不用為了測試而在磁碟上建立和清理大量檔案。

延伸閱讀

明天會探討驗證測試的技術,學習如何使用 FluentValidation Test Extensions 來建立完整的輸入資料驗證測試策略,確保業務規則的正確性。

範例程式碼:


這是「重啟挑戰:老派軟體工程師的測試修練」的第十七天。明天會介紹 Day 18 – 驗證測試:FluentValidation Test Extensions。


上一篇
Day 16 – 測試日期與時間:Microsoft.Bcl.TimeProvider 取代 DateTime
下一篇
Day 18 – 驗證測試:FluentValidation Test Extensions
系列文
重啟挑戰:老派軟體工程師的測試修練23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言