学习笔记(Maui 05 单元测试)

学习笔记(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
}

该文件将默认的并行测试改为线性测试。文件属性:复制到输出目录设为“如果较新则复制”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sleevefisher

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值