现在,ViewModel 和服务位于单独的类库中,因此可以轻松创建单元测试。 通过添加单元测试项目,可以验证 ViewModel 和服务是否按预期运行,而无需依赖 UI 层或手动测试。 可以在开发工作流中自动运行单元测试,确保代码保持可靠且可维护。
创建单元测试项目
- 右键单击 解决方案资源管理器中的解决方案。
- 选择“ 添加新>项目...”。
- 选择 WinUI 单元测试应用 模板,然后选择“ 下一步”。
- **
将项目命名为
WinUINotes.Tests并选择 创建。
添加项目引用
- 右键单击 WinUINotes.Tests 项目,然后选择 “添加>项目引用...”。
- 检查 WinUINotes.Bus 项目,然后选择“ 确定”。
创建用于测试的虚假实现
若要进行测试,请创建文件服务和存储类的虚假实现,这些实现实际上不会写入磁盘。 Fakes 是用于模拟实际依赖项行为的轻型实现,用于测试目的。
在 WinUINotes.Tests 项目中,创建名为 Fakes 的新文件夹。
在 Fakes 文件夹中添加类文件
FakeFileService.cs:using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Windows.Storage; using WinUINotes.Services; namespace WinUINotes.Tests.Fakes { internal class FakeFileService : IFileService { private Dictionary<string, string> fileStorage = []; public async Task CreateOrUpdateFileAsync(string filename, string contents) { if (fileStorage.ContainsKey(filename)) { fileStorage[filename] = contents; } else { fileStorage.Add(filename, contents); } await Task.Delay(10); // Simulate some async work } public async Task DeleteFileAsync(string filename) { if (fileStorage.ContainsKey(filename)) { fileStorage.Remove(filename); } await Task.Delay(10); // Simulate some async work } public bool FileExists(string filename) { if (string.IsNullOrEmpty(filename)) { throw new ArgumentException("Filename cannot be null or empty", nameof(filename)); } if (fileStorage.ContainsKey(filename)) { return true; } return false; } public IStorageFolder GetLocalFolder() { return new FakeStorageFolder(fileStorage); } public async Task<IReadOnlyList<IStorageItem>> GetStorageItemsAsync() { await Task.Delay(10); return GetStorageItemsInternal(); } public async Task<IReadOnlyList<IStorageItem>> GetStorageItemsAsync(IStorageFolder storageFolder) { await Task.Delay(10); return GetStorageItemsInternal(); } private IReadOnlyList<IStorageItem> GetStorageItemsInternal() { return fileStorage.Keys.Select(filename => CreateFakeStorageItem(filename)).ToList(); } private IStorageItem CreateFakeStorageItem(string filename) { return new FakeStorageFile(filename); } public async Task<string> GetTextFromFileAsync(IStorageFile file) { await Task.Delay(10); if (fileStorage.ContainsKey(file.Name)) { return fileStorage[file.Name]; } return string.Empty; } } }FakeFileService使用一个内存字典(fileStorage)来模拟文件操作,而不涉及实际的文件系统。 主要功能包括:-
异步模拟:使用
Task.Delay(10)模拟真实的异步文件操作 - 验证:为无效输入引发异常,就像实际实现一样
-
与假存储类集成:返回
FakeStorageFolder和FakeStorageFile实例协同工作以模拟 Windows 存储 API
-
异步模拟:使用
添加
FakeStorageFolder.cs:using System; using System.Collections.Generic; using System.Runtime.InteropServices.WindowsRuntime; using Windows.Foundation; using Windows.Storage; using Windows.Storage.FileProperties; using Windows.Storage.Search; namespace WinUINotes.Tests.Fakes { internal class FakeStorageFolder : IStorageFolder { private string name; private Dictionary<string, string> fileStorage = []; public FakeStorageFolder(Dictionary<string, string> files) { fileStorage = files; } public FileAttributes Attributes => throw new NotImplementedException(); public DateTimeOffset DateCreated => throw new NotImplementedException(); public string Name => name; public string Path => throw new NotImplementedException(); public IAsyncOperation<StorageFile> CreateFileAsync(string desiredName) { throw new NotImplementedException(); } public IAsyncOperation<StorageFile> CreateFileAsync(string desiredName, CreationCollisionOption options) { throw new NotImplementedException(); } public IAsyncOperation<StorageFolder> CreateFolderAsync(string desiredName) { throw new NotImplementedException(); } // Only partial implementation shown for brevity ... } }FakeStorageFolder在其构造函数中获取文件存储字典对象,使其能够与内存文件系统相同的FakeFileService配合工作。 大多数接口成员都会引发NotImplementedException,因为只需要实现测试实际使用的那些属性和方法。添加
FakeStorageFile.cs:using System; using System.IO; using System.Runtime.InteropServices.WindowsRuntime; using Windows.Foundation; using Windows.Storage; using Windows.Storage.FileProperties; using Windows.Storage.Streams; namespace WinUINotes.Tests.Fakes { public class FakeStorageFile : IStorageFile { private string name; public FakeStorageFile(string name) { this.name = name; } public string ContentType => throw new NotImplementedException(); public string FileType => throw new NotImplementedException(); public FileAttributes Attributes => throw new NotImplementedException(); public DateTimeOffset DateCreated => throw new NotImplementedException(); public string Name => name; public string Path => throw new NotImplementedException(); public IAsyncOperation<StorageFile> CopyAsync(IStorageFolder destinationFolder) { throw new NotImplementedException(); } public IAsyncOperation<StorageFile> CopyAsync(IStorageFolder destinationFolder, string desiredNewName) { throw new NotImplementedException(); } public IAsyncOperation<StorageFile> CopyAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) { throw new NotImplementedException(); } public IAsyncAction CopyAndReplaceAsync(IStorageFile fileToReplace) { throw new NotImplementedException(); } // Only partial implementation shown for brevity ... } }FakeStorageFile表示假存储系统中的单个文件。 它存储文件名,并提供测试所需的最小实现。 类似于FakeStorageFolder,它仅实现测试代码实际使用的成员。
在文档中了解详细信息:
编写简单的单元测试
UnitTest1.cs重命名NoteTests.cs并更新它:using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using WinUINotes.Tests.Fakes; namespace WinUINotes.Tests { [TestClass] public partial class NoteTests { [TestMethod] public void TestCreateUnsavedNote() { var noteVm = new ViewModels.NoteViewModel(new FakeFileService()); Assert.IsNotNull(noteVm); Assert.IsTrue(noteVm.Date > DateTime.Now.AddHours(-1)); Assert.IsTrue(noteVm.Filename.EndsWith(".txt")); Assert.IsTrue(noteVm.Filename.StartsWith("notes")); noteVm.Text = "Sample Note"; Assert.AreEqual("Sample Note", noteVm.Text); noteVm.SaveCommand.Execute(null); Assert.AreEqual("Sample Note", noteVm.Text); } } }此测试演示如何使用
FakeFileService进行NoteViewModel的单元测试。 该测试将创建一个新的NoteViewModel,检查其初始状态(日期是最近,文件名遵循预期的模式),设置笔记上的文本,运行保存命令,并确认文本仍然存在。 由于使用了假文件服务而不是实际实现,因此测试无需任何实际文件 I/O 即可快速运行,并且无需副作用即可重复运行。
在文档中了解详细信息:
运行测试
- 在 Visual Studio 中打开 “测试资源管理器” 窗口(测试>测试资源管理器)。
- 选择“ 运行所有测试 ”以执行单元测试。
- 验证测试是否通过。
现在,你有了一个可测试的体系结构,你可以在其中独立于 UI 测试 ViewModel 和服务!
概要
在本教程系列中,您将学习如何:
- 创建一个单独的类库项目(Bus 项目),用于存放 ViewModel 和服务,以便实现与 UI 层分离的单元测试。
- 使用 MVVM 工具包实现 MVVM 模式,利用
ObservableObject、[ObservableProperty]特性并减少[RelayCommand]样本代码。 - 使用源生成器自动创建属性更改通知和命令实现。
- 使用
[NotifyCanExecuteChangedFor]在属性值更改时自动更新命令可用性。 - 集成依赖项注入,以
Microsoft.Extensions.DependencyInjection管理 ViewModels 和服务生命周期。 - 创建
IFileService接口和实现,以便以可测试的方式处理文件操作。 - 在
App.xaml.cs中配置 DI 容器,并在你的页面中从服务容器检索 ViewModels。 - 实现在
WeakReferenceMessenger组件之间实现松散耦合,允许页面在不直接引用的情况下响应 ViewModel 事件。 - 创建继承自
ValueChangedMessage<T>以在组件之间传输数据的消息类。 - 创建用于测试的依赖项的虚假实现,而无需触摸实际的文件系统。
- 使用 MSTest 编写单元测试,以独立于 UI 层验证 ViewModel 行为。
此体系结构为构建可维护、可测试的 WinUI 应用程序提供了坚实的基础,可以明确分离 UI、业务逻辑和数据访问层之间的关注点。 可以从 GitHub 存储库下载或查看本教程的代码。
后续步骤
了解如何使用 MVVM 工具包和依赖项注入实现 MVVM 后,可以探索更高级的主题:
- 高级消息传送:探索其他消息传送模式,包括用于选择性消息处理的请求/响应消息和消息令牌。
- 验证:使用数据注释和 MVVM 工具包的验证功能向 ViewModel 添加输入验证。
-
异步命令:详细了解异步命令执行、取消支持和进度报告。
AsyncRelayCommand - 高级测试:探索更高级的测试方案,包括测试消息处理、异步命令执行和属性更改通知。
-
可观测集合:有效使用
ObservableCollection<T>并探索ObservableRangeCollection<T>进行批量操作。