单元测试
对于单元测试,你不需要太多的基础设施。你通常实例化你的类,并提供一些预先配置的模拟对象,来准备要测试的对象。
无依赖项的类
这是最简单的情况,你要测试的类没有依赖项。在这种情况下,你可以直接实例化你的类,调用其方法并进行断言。
示例:测试一个实体
假设你有一个如下所示的 Issue 实体:
using System;
using Volo.Abp.Domain.Entities;
namespace MyProject.Issues
{
public class Issue : AggregateRoot<Guid>
{
public string Title { get; set; }
public string Description { get; set; }
public bool IsLocked { get; set; }
public bool IsClosed { get; private set; }
public DateTime? CloseDate { get; private set; }
public void Close()
{
IsClosed = true;
CloseDate = DateTime.UtcNow;
}
public void Open()
{
if (!IsClosed)
{
return;
}
if (IsLocked)
{
throw new IssueStateException("You can not open a locked issue!");
}
IsClosed = false;
CloseDate = null;
}
}
}
注意,IsClosed 和 CloseDate 属性具有私有 setter,以通过使用 Open() 和 Close() 方法来强制执行一些业务规则:
- 每当关闭一个 Issue 时,
CloseDate应设置为当前时间。 - 如果 Issue 被锁定,则无法重新打开。如果重新打开,
CloseDate应设置为null。
由于 Issue 实体是领域层的一部分,我们应该在 Domain.Tests 项目中测试它。在 Domain.Tests 项目中创建一个 Issue_Tests 类:
using Shouldly;
using Xunit;
namespace MyProject.Issues
{
public class Issue_Tests
{
[Fact]
public void Should_Set_The_CloseDate_Whenever_Close_An_Issue()
{
// 准备
var issue = new Issue();
issue.CloseDate.ShouldBeNull(); // 初始为 null
// 执行
issue.Close();
// 断言
issue.IsClosed.ShouldBeTrue();
issue.CloseDate.ShouldNotBeNull();
}
}
}
此测试遵循 AAA(准备-执行-断言)模式;
- 准备 部分创建一个
Issue实体,并确保开始时CloseDate为null。 - 执行 部分执行我们要为此情况测试的方法。
- 断言 部分检查
Issue属性是否与我们期望的一致。
[Fact] 属性由 xUnit 库定义,并将一个方法标记为测试方法。Should... 扩展方法由 Shouldly 库提供。你可以直接使用 xUnit 的 Assert 类,但 Shouldly 使其更加舒适和直接。
执行测试时,你将看到它成功通过:
让我们再添加两个测试方法:
[Fact]
public void Should_Allow_To_ReOpen_An_Issue()
{
// 准备
var issue = new Issue();
issue.Close();
// 执行
issue.Open();
// 断言
issue.IsClosed.ShouldBeFalse();
issue.CloseDate.ShouldBeNull();
}
[Fact]
public void Should_Not_Allow_To_ReOpen_A_Locked_Issue()
{
// 准备
var issue = new Issue();
issue.Close();
issue.IsLocked = true;
// 执行与断言
Assert.Throws<IssueStateException>(() =>
{
issue.Open();
});
}
Assert.Throws 检查执行的代码是否抛出了匹配的异常。
有依赖项的类
如果你的服务有依赖项,并且你想对这个服务进行单元测试,你需要模拟这些依赖项。
示例:测试领域服务
假设你有一个如下定义的 IssueManager 领域服务:
using System;
using System.Threading.Tasks;
using Volo.Abp;
using Volo.Abp.Domain.Services;
namespace MyProject.Issues
{
public class IssueManager : DomainService
{
public const int MaxAllowedOpenIssueCountForAUser = 3;
private readonly IIssueRepository _issueRepository;
public IssueManager(IIssueRepository issueRepository)
{
_issueRepository = issueRepository;
}
public async Task AssignToUserAsync(Issue issue, Guid userId)
{
var issueCount = await _issueRepository.GetIssueCountOfUserAsync(userId);
if (issueCount >= MaxAllowedOpenIssueCountForAUser)
{
throw new BusinessException(
code: "IM:00392",
message: $"You can not assign more" +
$"than {MaxAllowedOpenIssueCountForAUser} issues to a user!"
);
}
issue.AssignedUserId = userId;
}
}
}
IssueManager 依赖于 IssueRepository 服务,本例中将模拟该服务。
业务规则:示例中的 AssignToUserAsync 不允许给一个用户分配超过 3 个(MaxAllowedOpenIssueCountForAUser 常量)Issue。如果你想在这种情况下分配一个 Issue,首先需要取消分配一个现有的 Issue。
下面的测试用例尝试进行一个有效的分配:
using System;
using System.Threading.Tasks;
using NSubstitute;
using Shouldly;
using Volo.Abp;
using Xunit;
namespace MyProject.Issues
{
public class IssueManager_Tests
{
[Fact]
public async Task Should_Assign_An_Issue_To_A_User()
{
// 准备
var userId = Guid.NewGuid();
var fakeRepo = Substitute.For<IIssueRepository>();
fakeRepo.GetIssueCountOfUserAsync(userId).Returns(1);
var issueManager = new IssueManager(fakeRepo);
var issue = new Issue();
// 执行
await issueManager.AssignToUserAsync(issue, userId);
// 断言
issue.AssignedUserId.ShouldBe(userId);
await fakeRepo.Received(1).GetIssueCountOfUserAsync(userId);
}
}
}
Substitute.For<IIssueRepository>创建一个传递给IssueManager构造函数的模拟(伪造)对象。fakeRepo.GetIssueCountOfUserAsync(userId).Returns(1)确保存储库的GetIssueCountOfUserAsync方法返回1。issueManager.AssignToUserAsync不会抛出任何异常,因为存储库为当前已分配的 Issue 计数返回1。issue.AssignedUserId.ShouldBe(userId);行检查AssignedUserId是否具有正确的值。await fakeRepo.Received(1).GetIssueCountOfUserAsync(userId);检查IssueManager是否恰好调用了一次GetIssueCountOfUserAsync方法。
让我们添加第二个测试,看看它是否阻止将超过允许数量的 Issue 分配给用户:
[Fact]
public async Task Should_Not_Allow_To_Assign_Issues_Over_The_Limit()
{
// 准备
var userId = Guid.NewGuid();
var fakeRepo = Substitute.For<IIssueRepository>();
fakeRepo
.GetIssueCountOfUserAsync(userId)
.Returns(IssueManager.MaxAllowedOpenIssueCountForAUser);
var issueManager = new IssueManager(fakeRepo);
// 执行与断言
var issue = new Issue();
await Assert.ThrowsAsync<BusinessException>(async () =>
{
await issueManager.AssignToUserAsync(issue, userId);
});
issue.AssignedUserId.ShouldBeNull();
await fakeRepo.Received(1).GetIssueCountOfUserAsync(userId);
}
有关模拟的更多信息,请参阅 NSubstitute 文档。
模拟单个依赖项相对容易。但是,当你的依赖项增多时,设置测试对象和模拟所有依赖项会变得更加困难。请参阅集成测试部分,它不需要模拟依赖项。
提示:共享测试类构造函数
xUnit 为每个测试方法创建一个新的测试类实例(本例中为 IssueManager_Tests)。因此,你可以将一些准备代码移到构造函数中,以减少代码重复。构造函数会为每个测试用例执行,并且不会相互影响,即使它们并行运行。
示例:重构 IssueManager_Tests 以减少代码重复
using System;
using System.Threading.Tasks;
using NSubstitute;
using Shouldly;
using Volo.Abp;
using Xunit;
namespace MyProject.Issues
{
public class IssueManager_Tests
{
private readonly Guid _userId;
private readonly IIssueRepository _fakeRepo;
private readonly IssueManager _issueManager;
private readonly Issue _issue;
public IssueManager_Tests()
{
_userId = Guid.NewGuid();
_fakeRepo = Substitute.For<IIssueRepository>();
_issueManager = new IssueManager(_fakeRepo);
_issue = new Issue();
}
[Fact]
public async Task Should_Assign_An_Issue_To_A_User()
{
// 准备
_fakeRepo.GetIssueCountOfUserAsync(_userId).Returns(1);
// 执行
await _issueManager.AssignToUserAsync(_issue, _userId);
// 断言
_issue.AssignedUserId.ShouldBe(_userId);
await _fakeRepo.Received(1).GetIssueCountOfUserAsync(_userId);
}
[Fact]
public async Task Should_Not_Allow_To_Assign_Issues_Over_The_Limit()
{
// 准备
_fakeRepo
.GetIssueCountOfUserAsync(_userId)
.Returns(IssueManager.MaxAllowedOpenIssueCountForAUser);
// 执行与断言
await Assert.ThrowsAsync<BusinessException>(async () =>
{
await _issueManager.AssignToUserAsync(_issue, _userId);
});
_issue.AssignedUserId.ShouldBeNull();
await _fakeRepo.Received(1).GetIssueCountOfUserAsync(_userId);
}
}
}
保持测试代码整洁,以创建可维护的测试套件。
抠丁客



