前一天學會了如何處理時間相依性的測試問題,現在要面對另一個常見的測試挑戰:檔案系統相依性。
實際開發中,經常需要處理檔案操作:
傳統上,會直接使用 System.IO.File
、System.IO.Directory
等靜態類別來處理這些操作。但是,當要為這些程式碼寫單元測試時,就會遇到許多問題。
今天將學習如何使用 System.IO.Abstractions 來解決檔案系統測試的根本問題,建立快速、可靠、不依賴真實檔案系統的測試。
先看一個典型的檔案處理程式碼:
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);
}
}
這段程式碼看起來很正常,但當要寫測試時會遇到:
[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 是一個 .NET 套件,它將 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;
}
}
}
// Program.cs 或 Startup.cs
services.AddSingleton<IFileSystem, FileSystem>();
services.AddScoped<ConfigurationService>();
在重構了 ConfigurationService 之後,現在可以使用 MockFileSystem 來建立可控制的測試環境。這個章節將說明如何測試最基本的檔案操作:讀取、寫入和檢查檔案是否存在。
MockFileSystem 的核心優勢:
看看如何測試 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;
}
}
}
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("找不到要備份的配置檔案");
}
}
// O 好的做法:使用 MockFileSystem 進行單元測試
[Fact]
public void ProcessFile_檔案存在_應處理成功()
{
var mockFileSystem = new MockFileSystem();
// ...
}
// X 避免:在單元測試中使用真實檔案系統
[Fact]
public void ProcessFile_真實檔案_應處理成功() // ← 這不是單元測試
{
var realFileSystem = new FileSystem();
// ...
}
// 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
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;
}
}
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 在功能上完全相容於真實檔案系統,但在效能特性上有顯著差異。了解這些差異有助於我們:
效能差異的原因:
這個範例提供具體的效能比較,幫助你量化使用 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 帶來了速度優勢,但我們也需要注意記憶體使用的問題:
記憶體考量重點:
最佳實踐:
這個範例展示如何監控和驗證測試的記憶體使用情況,確保測試的效能不會因為記憶體問題而受影響。
[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
}
System.IO.Abstractions 可以與我們之前學過的測試技術整合。結合 AutoFixture 可以讓檔案系統測試更強大:
整合優勢:
使用場景:
這種結合可以用最少的程式碼覆蓋最多的測試情境。
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 和 System.IO.Abstractions:
適用場景:
技術重點:
這種組合可以建立精確的測試,覆蓋複雜的業務場景。
[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,已經解決了檔案系統測試的根本問題。不再需要擔心測試會因為檔案權限而失敗,也不用為了測試而在磁碟上建立和清理大量檔案。
明天會探討驗證測試的技術,學習如何使用 FluentValidation Test Extensions 來建立完整的輸入資料驗證測試策略,確保業務規則的正確性。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第十七天。明天會介紹 Day 18 – 驗證測試:FluentValidation Test Extensions。