学习笔记(Maui 05 项目测试)
DailyPoetryM 项目(对应P10P11和P12部分)
本节继续介绍 Maui 项目测试
1 嵌入式资源部署
.Net Maui 的资源
- 嵌入式资源:二进制资源(例如数据库文件 poetrydb.sqlite3)
- ApplicationResources:类的实例(例如ServiceLocator)
将文件 poetrydb.sqlite3 作为嵌入式资源使用步骤:(1)复制文件,在 VS 中选择中间库项目 DailyPoetryM.Library 右键粘贴。(2)在中间库项目 DailyPoetryM.Library 的文件 poetrydb.sqlite3 上右键 -> 属性 -> 生成操作:嵌入式资源。保证部署程序时将嵌入式资源写入正确位置。
应该是为了测试才将文件放入中间库项目?
2 嵌入式数据库的初始化
类 StoragePoetry 中的函数 InitializeAsync() 完成数据库初始化。数据库初始化包括四个基本步骤
- 打开数据库文件
- 打开嵌入式资源
- 将嵌入式资源拷贝到数据库文件中
- 维护版本号
先在中间库项目 DailyPoetryM.Library 类 StoragePoetry 中确定数据库文件名和数据库文件存储位置。
public const string DbName = "poetrydb.sqlite3";
public static readonly string DbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), DbName);
按照数据库初始化的四个步骤,在中间库项目 DailyPoetryM.Library 类 StoragePoetry 中的 InitialAsync 函数实现
public async Task InitializeAsync()
{
await using var dbFileStream = new FileStream(DbPath, FileMode.OpenOrCreate);
await using var dbAssetStream = typeof(StoragePoetry).Assembly.GetManifestResourceStream(DbName);。
await dbAssetStream.CopyToAsync(dbFileStream);
_StoragePreference.Set(StoragePoetryConstant.VersionKey, StoragePoetryConstant.Version);
}
using 是为了系统能够在合适的时机自动关闭文件流和资源流,为以下写法的简写。
using (var dbFileStream = new FileStream(DbPath, FileMode.OpenOrCreate))
{
...
}
typeof(StoragePoetry).Assembly.GetManifestResourceStream 语句是获取资源流的标准写法,表示从项目编译出的组件(Assembly)获得资源流。
3 函数 InitializedAsync 测试
3.1 测试程序
在测试项目 DailyPoetry.TestProject 类 StoragePoetry 中添加测试函数 InitializedAsync_Default()
[Fact]
public async Task InitializedAsync_Default()
{
var mockStoragePreference = new Mock<IStoragePreference>();
var fakeStoragePreference = mockStoragePreference.Object;
var storagePoetry = new StoragePoetry(fakeStoragePreference);
Assert.False(File.Exists(StoragePoetry.DbPath));
await storagePoetry.InitializeAsync();
Assert.True(File.Exists(StoragePoetry.DbPath));
}
第一个 Assert 语句保证拷贝之前文件不存在,第二个 Assert 保证拷贝之后文件存在。
测试出现两个问题。
3.2 空引用问题
测试首先发生空引用问题,是由于 dbAssetStream 没有获得引用引起的,根本原因在于函数GetManifestResourceStream 的参数是资源名,而文件名 DbName 并不是资源名。实际资源名是 DailyPoetryM.DbName。这个问题有两个解决办法。
第一个办法:修正读取资源流时的资源名称,使其与实际资源名称相同。
await using var dbAssetStream = typeof(StoragePoetry).Assembly.GetManifestResourceStream("DailyPoetryM." + DbName);
第二个办法:添加嵌入式名称
在项目文档内,找到标签
<ItemGroup>
<EmbeddedResource Include="poetrydb.sqlite3"/>
</ItemGroup>
添加内容
<ItemGroup>
<EmbeddedResource Include="poetrydb.sqlite3">
<LogicalName>poetrydb.sqlite3</LogicalName>
</EmbeddedResource>
</ItemGroup>
此后,可以直接使用 DbName 作为资源名字。
3.3 防御性编程
如何避免空指针问题是编程时必须考虑的。大多数情况下,程序员不能意识到某一个操作会产生空指针。
await using var dbAssetStream = typeof(StoragePoetry).Assembly.GetManifestResourceStream(DbName);
为了避免这种不可预见的情况,可以采用所谓的防御性编程。即追加判断。如果出现空指针可以及时发现。
await using var dbAssetStream = typeof(StoragePoetry).Assembly.GetManifestResourceStream(DbName);
if (dbAssetStream is null) // 防御性编程
{
throw new Exception($"找不到名为{DbName}的资源!");
}
简化语法后的表现形式。GetManifestResourceStream 返回 null 则抛异常,否则不发生任何事。
await using var dbAssetStream = typeof(StoragePoetry).Assembly.GetManifestResourceStream(DbName) ?? throw new Exception($"找不到名为{DbName}的资源!");
3.4 测试垃圾问题
第二个测试问题是测试垃圾引起的。
Assert.False(File.Exists(StoragePoetry.DbPath));
从第二次测试开始,上一次测试留下的数据库文件就作为测试垃圾影响测试,即以上测试语句无法通过,需要删除测试垃圾解决。比较直接的版本。保证测试之前没有文件,测试之后删除文件。
[Fact]
public async Task InitializedAsync_Default()
{
var mockStoragePreference = new Mock<IStoragePreference>();
var fakeStoragePreference = mockStoragePreference.Object;
var storagePoetry = new StoragePoetry(fakeStoragePreference);
File.Delete(StoragePoetry.DbPath);
Assert.False(File.Exists(StoragePoetry.DbPath));
await storagePoetry.InitializeAsync();
Assert.True(File.Exists(StoragePoetry.DbPath));
// File.Delete(StoragePoetry.DbPath);
}
考虑到删除测试垃圾是众多测试函数需要进行的操作。统一在测试函数开始之前进行删除操作,保证文件不存在的做法是使用构造函数。在项目 DailyPoetryM.TestProject 类 StoragePoetry 中增加构造函数。
public StoragePoetryTest()
{
File.Delete(StoragePoetry.DbPath);
}
统一在测试函数之后进行删除操作,保证文件不存在的做法是使用 Dispose() 函数。给项目 DailyPoetryM.TestProject 类 StoragePoetry 增加继承接口 IDisposable,增加函数 Dispose(),其作用类似析构函数。
public void Dispose()
{
File.Delete(StoragePoetry.DbPath);
}
单元测试函数每次运行,都会 new 一个新的对象。
3.5 执行次数判断
验证 mock 调用是否正常
mockStoragePreference.Verify(p => p.Set(StoragePoetryConstant.VersionKey, StoragePoetryConstant.Version), Times.Once);
Verify 验证被 Mock 的对象中的函数是否正常调用。本例中验证是否调用 Set 函数一次,且参数一致。
4 函数 GetPoetryAsync 测试
4.1 函数 GetPoetryAsync 实现
在中间库项目 DailyPoetry.Library 类 StoragePoetry 中实现函数 GetPoetryAsync()
public async Task<Poetry> GetPoetryAsync(int id)
{
return await Connection.Table<Poetry>().FirstOrDefaultAsync(p => p.Id == id);
}
4.2 函数 GetPoetryAsync 测试
在测试项目 DailyPoetry.TestProject 类 StoragePoetry 中添加测试函数 GetPoetryAsync_Default()
[Fact]
public async Task GetPoetryAsync_Default()
{
var mockStoragePreference = new Mock<IStoragePreference>();
var fakeStoragePreference = mockStoragePreference.Object;
var storagePoetry = new StoragePoetry(fakeStoragePreference);
await storagePoetry.InitializeAsync();
var poetry = await storagePoetry.GetPoetryAsync(10001);
Assert.Equal("临江仙 · 夜归临皋", poetry.Name);
}
Mock 语句在不同测试函数中多次出现,将其提炼到一个函数 GetInitializedStoragePoetry() 中。
public static async Task<StoragePoetry> GetInitializedStoragePoetry()
{
var mockStoragePreference = new Mock<IStoragePreference>();
var fakeStoragePreference = mockStoragePreference.Object;
var storagePoetry = new StoragePoetry(fakeStoragePreference);
await storagePoetry.InitializeAsync();
return storagePoetry;
}
原测试函数可以形式上简化
[Fact]
public async Task GetPoetryAsync_Default()
{
var storagePoetry = GetInitializedStoragePoetry();
var poetry = await storagePoetry.GetPoetryAsync(10001);
Assert.Equal("临江仙 · 夜归临皋", poetry.Name);
}
测试函数执行结果是无法通过测试。分析原因是 Dispose() 函数无法删除文件,而无法删除文件的原因是数据库打开未关闭。项目本身的业务逻辑不要求每次打开数据库都关闭,只要程序结束时关闭就可以。但是单元测试要求每次打开数据库都关闭,否则单元测试无法通过。对于这种非业务要求,不需要体现在被测试项目 DailyPoetryM 的接口里,也就不需要体现在中间库项目 DailyPoetry.TestProject 的接口里。作为单元测试的要求(实现层面的需求),直接放在实现类 StoragePoetry 中就可以。在中间库项目 DailyPoetry.TestProject 的类 StoragePoetry 中添加关闭数据库的函数 CloseAsync()
public async Task CloseAsync() => await Connection.CloseAsync();
在测试项目 DailyPoetry.TestProject 类 StoragePoetry 测试函数 GetPoetriesAsync_Default 中添加关闭数据库函数 CloseAsync() 的调用。
[Fact]
public async Task GetPoetryAsync_Default()
{
var storagePoetry = GetInitializedStoragePoetry();
var poetry = await storagePoetry.GetPoetryAsync(10001);
Assert.Equal("临江仙 · 夜归临皋", poetry.Name);
await storagePoetry.CloseAsync();
}
加上关闭数据库的函数以后,测试可以通过了。
5 函数 GetPoetriesAsync 测试
5.1 函数 GetPoetriesAsync 实现
public async Task<IEnumerable<Poetry>> GetPoetriesAsync(Expression<Func<Poetry, bool>> where, int skip, int take)
{
return await Connection.Table<Poetry>().Where(where).Skip(skip).Take(take).ToListAsync();
}
5.2 函数 GetPoetriesAsync 测试
在测试项目 DailyPoetry.TestProject 类 StoragePoetry 中添加测试函数 GetPoetriesAsync_Default()
[Fact]
public async Task GetPoetriesAsync_Default()
{
StoragePoetry storagePoetry = await GetInitializedStoragePoetry();
// 参数含义:(没有任何条件,永远为 true;略过 0 条;获取所有)
var poetries = await storagePoetry.GetPoetriesAsync(Expression.Lambda<Func<Poetry, bool>>(Expression.Constant(true), Expression.Parameter(typeof(Poetry), "p")), 0, int.MaxValue);
Assert.Equal(30, poetries.Count());
await storagePoetry.CloseAsync();
}
5.3 xunit 测试的并行问题
同时存在几个测试函数时,单独测试可以通过不意味着一起测试可以通过,一起测试存在出错的概率。xUnit 为了提高运行效率,所有单元测试是并行运行的,有时会出现资源占用问题。避免测试并行运行导致问题的方法是在单元测试项目 DailyPoetry.TestProject 新建文件 xunit.runner.json
{
"parallelizeAssembly": false,
"parallelizeTestCollections": false
}
该文件将默认的并行测试改为线性测试。文件属性:复制到输出目录设为“如果较新则复制”。