添加单元测试

现在,ViewModel 和服务位于单独的类库中,因此可以轻松创建单元测试。 通过添加单元测试项目,可以验证 ViewModel 和服务是否按预期运行,而无需依赖 UI 层或手动测试。 可以在开发工作流中自动运行单元测试,确保代码保持可靠且可维护。

创建单元测试项目

  1. 右键单击 解决方案资源管理器中的解决方案。
  2. 选择“ 添加新>项目...”
  3. 选择 WinUI 单元测试应用 模板,然后选择“ 下一步”。
  4. ** 将项目命名为 WinUINotes.Tests 并选择 创建

添加项目引用

  1. 右键单击 WinUINotes.Tests 项目,然后选择 “添加>项目引用...”
  2. 检查 WinUINotes.Bus 项目,然后选择“ 确定”。

创建用于测试的虚假实现

若要进行测试,请创建文件服务和存储类的虚假实现,这些实现实际上不会写入磁盘。 Fakes 是用于模拟实际依赖项行为的轻型实现,用于测试目的。

  1. WinUINotes.Tests 项目中,创建名为 Fakes 的新文件夹。

  2. 在 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)模拟真实的异步文件操作
    • 验证:为无效输入引发异常,就像实际实现一样
    • 与假存储类集成:返回 FakeStorageFolderFakeStorageFile 实例协同工作以模拟 Windows 存储 API
  3. 添加 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,因为只需要实现测试实际使用的那些属性和方法。

    可以在本教程的 FakeStorageFolder中查看完整实现

  4. 添加 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,它仅实现测试代码实际使用的成员。

    可以在本教程的 FakeStorageFolder中查看完整实现

在文档中了解详细信息:

编写简单的单元测试

  1. 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 即可快速运行,并且无需副作用即可重复运行。

在文档中了解详细信息:

运行测试

  1. 在 Visual Studio 中打开 “测试资源管理器” 窗口(测试>测试资源管理器)。
  2. 选择“ 运行所有测试 ”以执行单元测试。
  3. 验证测试是否通过。

现在,你有了一个可测试的体系结构,你可以在其中独立于 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> 进行批量操作。