项目

单元测试

对于单元测试,你不需要太多的基础设施。你通常实例化你的类,并提供一些预先配置的模拟对象,来准备要测试的对象。

无依赖项的类

这是最简单的情况,你要测试的类没有依赖项。在这种情况下,你可以直接实例化你的类,调用其方法并进行断言。

示例:测试一个实体

假设你有一个如下所示的 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;
        }
    }
}

注意,IsClosedCloseDate 属性具有私有 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 实体,并确保开始时 CloseDatenull
  • 执行 部分执行我们要为此情况测试的方法。
  • 断言 部分检查 Issue 属性是否与我们期望的一致。

[Fact] 属性由 xUnit 库定义,并将一个方法标记为测试方法。Should... 扩展方法由 Shouldly 库提供。你可以直接使用 xUnit 的 Assert 类,但 Shouldly 使其更加舒适和直接。

执行测试时,你将看到它成功通过:

issue-first-test

让我们再添加两个测试方法:

[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 检查执行的代码是否抛出了匹配的异常。

有关这些库的更多信息,请参阅 xUnitShoudly 文档。

有依赖项的类

如果你的服务有依赖项,并且你想对这个服务进行单元测试,你需要模拟这些依赖项。

示例:测试领域服务

假设你有一个如下定义的 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);
        }
    }
}

保持测试代码整洁,以创建可维护的测试套件。


在本文档中