单元测试布道之二:在全新的 DDD 架构上进行单元测试

Posted 测试baby

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了单元测试布道之二:在全新的 DDD 架构上进行单元测试相关的知识,希望对你有一定的参考价值。

在这里插入图片描述

回顾

前期内容 单元测试布道之一:定义、分类与策略 描述了测试相关的部分概念,介绍了 dotnet 单元测试策略,声明了可测试性的重要性,并展示了现有项目的特定场景添测试用例的具体步骤。

  1. 单元测试的定义:对软件中的最小可测试单元进行检查和验证,用于检验被测代码的一个很小的、很明确的功能是否正确
  2. 单元测试的必要:单元测试能在开发阶段发现 BUG,及早暴露,收益高,是交付质量的保证
  3. 单元测试的策略:自底向上或孤立的测试策略

现在略回顾下准备知识就进入实战。

dotnet 单元测试相关的工具和知识

1. NSubstitute

自称是 A friendly substitute for .NET mocking libraries,目前已经是 Mock 等的替代实现。

mock 离不开动态代理,NSubstitute 依赖 Castle Core,其原理另起篇幅描述。

// Arrange(准备):Prepare
var calculator = Substitute.For<ICalculator>();

// Act(执行):Set a return value
calculator.Add(1, 2).Returns(3);
Assert.AreEqual(3, calculator.Add(1, 2));

// Assert(断言 ):Check received calls
calculator.Received().Add(1, Arg.Any<int>());
calculator.DidNotReceive().Add(2, 2);

2.使用 InternalsVisibleToAttribute 测试内部类

为了避免暴露大量的实现细节、提高内聚性,我们应减少 public 访问修饰符的使用。但是没有 public 访问修饰符的方法如何进行测试?这就是InternalsVisibleToAttribute 的作用,我们可以在被测项目的 AssemblyInfo.cs 中使用

[assembly: InternalsVisibleTo("XXX.Tests")]

也可以在被测试项目的文件 .csproj 中使用

  <ItemGroup>
    <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
      <_Parameter1>$(MSBuildProjectName).Tests</_Parameter1>
    </AssemblyAttribute>
  </ItemGroup>

注意示例中的命名约定。通过以上两种方式, 作为项目名称后缀的单元测试项目拥有了对被测试项目中 internal 成员的访问能力。

3.扩展方法的测试

扩展方法不具体可测试性,但如果注入的是接口或抽象类,那么对接口的直接调用可以 mock,但依赖接口的调用会直接调用扩展方法,mock 失败。

public interface IRandom {
	Double Next();
}

public class Random : IRandom {
	private static readonly System.Random r = new System.Random();

	public double Next() {
		return r.NextDouble();
	}
}

// 扩展方法
public static class RandomExtensions {
	public static Double Next(this IRandom random, int min, int max) {
		return max - random.Next() * min;
	}
}

public class CalulateService {
	private readonly IRandom _random;

	public CalulateService(IRandom random) {
		_random = random;
	}

	public void DoStuff() {
		_random.Next(0, 100);
	}
}

直接对 IRandom 的扩展方法进行 mock 会失败,NSubstitute 的 Returns 方法抛出异常。

[Fact]
public void Next_ExtensionMethodMock_ShouldFailed() {
	var random = Substitute.For<IRandom>();
	random.Next(Arg.Any<int>(), Arg.Any<int>())
		.Returns(call => (call.ArgAt<int>(0) + call.ArgAt<int>(1)) / 2);

    // "Argument matchers (Arg.Is, Arg.Any) should only be used in place of member arguments. Do not use in a Returns() statement or anywhere else outside of a member call."
	random.Next(0, 100);
}

实际上我们可以从 IRandom 继续定义接口,并包含一个签名与扩展方法相同的成员方法,mock 是行得通的。

public interface IRandomWrapper : IRandom {
    Double Next(int min, int max);
}

[Fact]
public void Next_WrapprMethod_ShouldWorks() {
    var random = Substitute.For<IRandomWrapper>();
    random.Next(Arg.Any<int>(), Arg.Any<int>())
        .Returns(call => (call.ArgAt<int>(0) + call.ArgAt<int>(1)) / 2);
    Assert.Equal(random.Next(0, 100), 50);
    
    var service = new CalulateService(random);
    // 会调用扩展方法还是 mock 方法?
    service.DoStuff();
}

然而到目前为止,CalulateService.DoStuff() 仍然会调用扩展方法,我们需要更多工作来达到测试目的,另起篇幅描述。

efcore 有形如 ToListAsync() 等大量扩展方法,测试步骤略繁复。

可测试性

可测试性的回顾仍然十分有必要,大概上可以归于以下三类。

不确定性/未决行为

// BAD
public class PowerTimer
{
	public String GetMeridiem()
	{
		var time = DateTime.Now;
		if (time.Hour >= 0 && time.Hour < 12)
		{
			return "AM";
		}
		return "PM";
	}
}

依赖于实现:不可 mock

// BAD: 依赖于实现
public class DepartmentService
{
	private CacheManager _cacheManager = new CacheManager();

	public List<Department> GetDepartmentList()
	{
		List<Department> result;
		if (_cacheManager.TryGet("department-list", out result))
		{
			return result;
		}
        // ... do stuff 
	}
}

// BAD: 静态方法
public static bool CheckNodejsInstalled()
{
    return Environment.GetEnvironmentVariable("PATH").Contains("nodejs", StringComparison.OrdinalIgnoreCase);
}

复杂继承/高耦合代码:测试困难

随着步骤/分支增加,场景组合和 mock 工作量成倍堆积,直到不可测试。

实战:在全新的 DDD 架构上进行单元测试

HelloDevCloud 是一个假想的早期 devOps 产品,提供了组织(Organization)和项目(Project)管理,遵从极简的 DDD 架构,包含以下特性

  1. 每个组织(Organization)都可以创建一个或多个项目(Project)
  2. 提供公共的 GitLab 用于托管代码,每个项目(Project)创建之时有 master 和 develop 分支被创建出来
  3. 项目(Project)目前支持公共 GitLab,但预备在将来支持私有 GitLab

在这里插入图片描述

需求-迭代1:分支管理

本迭代预计引入分支管理功能

每个项目(Project,聚合根)都能创建特定类别的分支(Branch,实体),目前支持特性分支(feature)和修复分支(hotfix),分别从 develop 分支和 master 分支签出

GitLab 有自己的管理入口,分支创建时需要检查项目和分支是否存在

分支创建成功后将提交记录(Commit)写入分支

前期:分析调用时序

在这里插入图片描述

前期:设计模块与依赖关系

  • IProjectService:领域服务,依赖IGitlabClient完成业务验证与调用
  • IProjectRepository:项目(Project,聚合根)仓储,更新聚合根
  • IBranchRepository:分支(Branch,实体)仓储,检查
  • IGitlabClient:基础设施

在这里插入图片描述

前期:列举单元测试用例

项目领域服务

  • 在 GitLab
    项目不存在时断言失败:CreateBranch_WhenRemoteProjectNotExist_ShouldFailed()
  • 在 GitLab
    分支已经存在时断言失败:CreateBranch_WhenRemoteBranchPresented_ShouldFailed()
  • 创建不支持的特性分支时断言失败:CreateBranch_UseTypeNotSupported_ShouldFailed()
  • 正确创建的分支应包含提交记录(Commit):CreateBranch_WhenParamValid_ShouldQuoteCommit()

项目应用服务

  • 在项目(Project)不存在时断言失败:Post_WhenProjectNotExist_ShouldFail()
  • 在项目(Project)不存在时断言失败:Post_WhenProjectNotExist_ShouldFail()
  • 参数合法时返回预期的分支签出结果:Post_WhenParamValid_ShouldCreateBranch()

中期:业务逻辑实现

项目(Project )作为聚合根添加分支(Branch)作为组成

      public class Project
      {
+         public Project()
+         {
+             Branches = new HashSet<Branch>();
+         }
+ 
          public int Id { get; set; }
          public string Name { get; set; }
          public string Description { get; set; }
          public int OrganizationId { get; set; }
+         public virtual ICollection<Branch> Branches { get; set; }
+ 
          public GitlabSettings Gitlab { get; set; }
+ 
+         public Branch CheckoutBranch(string name, string commit, BranchType type)
+         {
+             var branch = Branch.Create(name, commit, type);
+             Branches.Add(branch);
+             return branch;
+         }

视图层逻辑并不复杂

[HttpPost]
[Route("{id}/branch")]
public async Task<BranchOutput> Post(int id, [FromBody] BranchCreateInput input)
{
    var branch = _branchRepository.GetByName(id, input.Name);
    // 断言本地分支不存在
    if (branch != null)
    {
        throw new InvalidOperationException("branch already existed");
    }

    var project = _projectRepository.Retrieve(id);
    // 断言项目存在
    if (project == null)
    {
        throw new ArgumentOutOfRangeException(nameof(id));
    }
    // 创建分支
    branch = await _projectService.CreateBranch(project, input.Name, input.Type);
    _projectRepository.Update(project);
    return _mapper.Map<BranchOutput>(branch);
}

中期:领域服务实现

public async Task<Branch> CreateBranch(Project project, string branchName, BranchType branchType)
{
    var gitProject = await _gitlabClient.Projects.GetAsync(project.Gitlab.Id);
    // 断言远程项目存在
    if (gitProject == null)
    {
        throw new NotImplementedException("project should existed");
    }

    // 断言远程分支不何存在
    var gitBranch = await _gitlabClient.Branches.GetAsync(project.Gitlab.Id, branchName);
    if (gitBranch != null)
    {
        throw new ArgumentOutOfRangeException(nameof(branchName), "remote branch already existed");
    }

    // 获取签出分支
    var reference = GetBranchReferenceForCreate(branchType);
    var request = new CreateBranchRequest(branchName, reference);
    // 创建分支
    gitBranch = await _gitlabClient.Branches.CreateAsync(project.Gitlab.Id, request);

    return project.CheckoutBranch(gitBranch.Name, gitBranch.Commit.Id, branchType);
}

private String GetBranchReferenceForCreate(BranchType branchType)
{
    return branchType switch
    {
            BranchType.Feature => Branch.Develop,
            BranchType.Hotfix => Branch.Master,
            _ => throw new ArgumentOutOfRangeException(nameof(branchType), $"Not supported branchType {branchType}"),
    };
}

中期:单元测试实现

  • 领域服务:测试用例见于项目源码 test/HelloDevCloud.DomainService.Tests/Projects/ProjectServiceTest.cs
  • 应用服务:测试用例见于项目源码
    test/HelloDevCloud.Web.Tests/Controllers/ProjectControllerTest.cs

实战小结

  • 单元测试用例体现了业务规则
  • 单元测试同架构一样是分层的

需求-迭代2:支持外部 GitLab

前期:设计模块与依赖关系

在这里插入图片描述

前期:列举单元测试用例

  • 项目领域服务

    使用外部 GitLab 仓库能签出分支:CreateBranch_UserExternalRepository_ShouldQuoteCommit()

中期:业务逻辑实现

使用新的工厂接口 IGitlabClientFactory 替换 IGitlabClient

class GitlabClientFactory : IGitlabClientFactory
{
    private readonly IOptions<GitlabOptions> _gitlabOptions;

    public GitlabClientFactory(IOptions<GitlabOptions> gitlabOptions)
    {
        _gitlabOptions = gitlabOptions;
    }

    // 从全局设置创建客户端
    public IGitLabClient GetGitlabClient()
    {
        return GetGitlabClient(_gitlabOptions.Value);
    }

    // 从项目设置创建客户端
    public IGitLabClient GetGitlabClient(GitlabOptions gitlabOptions)
    {
        return new GitLabClient(gitlabOptions.HostUrl, gitlabOptions.AuthenticationToken);
    }
}

详细内容见于项目提交记录 8a106d44eb5f72f7bccc536354a8b7071aad9fca

中期:单元测试实现

领域服务:测试用例见于项目源码 test/HelloDevCloud.DomainService.Tests/Projects/ProjectServiceTest.cs

ANTI-PATTERN:依赖具体实现

支持外部 GitLab 仓库需要动态生成 IGitlabClient 实例,故在业务逻辑中根据项目(Project)设置实例化 GitlabClinet是很“自然”的事情,但代码不再具有可测试性。
在这里插入图片描述

对应的逻辑实现在分支 support-external-gitlab-anti-pattern上,提交记录为 3afc62a21ccf207c35d6cb61a2a2bf2e5fe5ca3c

//BAD
-        private readonly IGitLabClient _gitlabClient;
+        private readonly IOptions<GitlabOptions> _gitlabOptions;

-        public ProjectService(IGitLabClient gitlabClient)
+        public ProjectService(IOptions<GitlabOptions> gitlabOptions)
         {
-            _gitlabClient = gitlabClient;
+            _gitlabOptions = gitlabOptions;
         }
         
         public async Task<Branch> CreateBranch(Project project, string branchName, BranchType branchType)
         {
-            var gitProject = await _gitlabClient.Projects.GetAsync(project.Gitlab.Id);
+            var gitlabClient = GetGitliabClient(project.Gitlab);
+            var gitProject = await gitlabClient.Projects.GetAsync(project.Gitlab.Id);

+        private IGitLabClient GetGitliabClient(GitlabSettings repository)
+        {
+            if (repository?.HostUrl == null)
+            {
+                return GetGitlabClient(_gitlabOptions.Value);
+            }
+
+            // 如果携带了 gitlab 设置, 则作为外部仓库
+            var gitlabOptions = new GitlabOptions()
+            {
+                HostUrl = repository.HostUrl,
+                AuthenticationToken = repository.AuthenticationToken
+            };
+            return GetGitlabClient(gitlabOptions);
+        }
+
+        private IGitLabClient GetGitlabClient(GitlabOptions gitlabOptions)
+        {
+            return new GitLabClient(gitlabOptions.HostUrl, gitlabOptions.AuthenticationToken);
+        }
+    }

对于以上实现,调用 ProjectService 会真实地调用 GitlabClient,注意这引入了依赖具体实现的反模式,代码失去了可测试性。

[Fact(Skip = "not implemented")]
public async Task CreateBranch_UserExternalRepository_ShouldQuoteCommit()
{
    var project = new Project
    {
        Gitlab = new GitlabSettings
        {
            Id = 1024,
            HostUrl = "https://gitee.com",
            AuthenticationToken = "token"
        }
    };

    // HOW? 
}

实战小结

  1. 良好的设计具有很好的可测试性
  2. 可测试性要求反过来会影响架构设计与领域实现

需求-迭代3:跨应用搜索

前期:列举单元测试用例

分支仓储

从配置了外部仓库的项目获取分支应返回符合预期的结果 GetAllByOrganization_ViaName_ReturnMatched

中期:业务逻辑实现

使用组织 Id 查询分支列表

public IList<Branch> GetAllByOrganization(int organizationId, string search)
{
    var projects = EfUnitOfWork.DbSet<Project>();
    var branchs = EfUnitOfWork.DbSet<Branch>();
    var query = from b in branchs
                join p in projects
                    on b.ProjectId equals p.Id
                where p.OrganizationId == organizationId && (b.Type == BranchType.Feature || b.Type == BranchType.Hotfix)
                select b;

    if (string.IsNullOrWhiteSpace(search) == false)
    {
        query.Where(x => x.Name.Contains(search));
    }

    return query.ToArray();
}

详细内容见于项目提交记录 d93bd48c7903101e8bac7601f76b093a035fc360

提问:仓储实现在 DDD 架构为归于什么位置?

中期:单元测试实现

仓储实现:见于项目源码 [https://gitee.com/leoninew/HelloDevCloud/blob/feature/support-external-gitlab/src/HelloDevCloud.Repositories/Implements/BranchRepository.cshttps://gitee.com/leoninew/HelloDevCloud/blob/feature/support-external-gitlab/src/HelloDevCloud.Repositories/Implements/BranchRepository.cs)

注意:仓储仍然是可测且应该进行测试的,mock 数据库查询的主要工作是 mock IQuerable,但是 mock 数据库读写并不容易。好在 efcore 提供了 UseInMemoryDatabase() 模式,无须我们再提供 FackRepository 一类实现。

[Fact]
public void GetAllByOrganization_ViaName_ReturnMatched()
{
    var options = new DbContextOptionsBuilder<DevCloudContext>()
        .UseInMemoryDatabase("DevCloudContext")
        .Options;
    using var devCloudContext = new DevCloudContext(options);
    devCloudContext.Set<Project>().AddRange(new[] {
        new Project
        {
            Id = 11,
            Name = "成本系统",
            OrganizationId = 1
        },
        new Project
        {
            Id = 12,
            Name = "成本系统合同执行应用",
            OrganizationId = 1
        },
        new Project
        {
            Id = 13,
            Name = "售楼系统",
            OrganizationId = 2
        },
    });

    devCloudContext.Set<Branch>().AddRange(new[] {
        new Branch
        {
            Id = 101,
            Name = "3.0.20.4_core分支",
            ProjectId = 11,
            Type = BranchType.Feature
        },
        new Branch
        {
            Id = 102,
            Name = "3.0.20.1_core发版修复分支15",
            ProjectId = 12,
            Type = BranchType.Hotfix
        },
        new Branch
        {
            Id = 103,
            Name = "730Core自动化验证",
            ProjectId = 13,
            Type = BranchType.Feature
        }
    });
    devCloudContext.SaveChanges();

    var unitOfWork = new EntityFrameworkUnitOfWork(devCloudContext);
    var branchRepo = new BranchRepository(unitOfWork);

    var branches = branchRepo.GetAllByOrganization(1, "core");
    Assert.Equal(2, branches.Count);
    Assert.Equal(101, branches[0].Id);
    Assert.Equal(102, branches[1].Id);
}

ANTI-PATTERN:业务变更将引起单元测试失败

提问:如果需要取消 develop 分支的特殊性,在方法 GetBranchReferenceForCreate() 上注释掉分支判断是否完成了需求?

         private String GetBranchReferenceForCreate(BranchType branchType)
         {
             return branchType switch
             {
                 BranchType.Feature => Branch.Develop,
-                // BranchType.Feature => Branch.Develop,
                 BranchType.Hotfix => Branch.Master,
                 _ => throw new ArgumentOutOfRangeException(nameof(branchType), $"Not supported branchType {branchType}"),
             };

实战小结

  1. 查询逻辑也能够进行有效的测试
  2. 单元测试减少了回归工作量
  3. 单元测试提升了交付质量

下面是我在做自动化对于技术一些归纳和总结,希望能帮助到有心在技术这条道路上一路走到黑的朋友!附带教程学习资料~

在这里插入图片描述

这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴我走过了最艰难的路程,希望也能帮助到你

关注我的微信公众号【伤心的辣条】免费获取~

如果我的博客对你有帮助、如果你喜欢我的博客内容,请 “点赞” “评论” “收藏” 一键三连哦!

好文推荐:

阿里小黑叹息:越来越多的年轻人从职场撤退了?

Python简单?先来40道基础面试题测试下

App公共测试用例梳理

从一名开发人员转做测试的一些感悟

以上是关于单元测试布道之二:在全新的 DDD 架构上进行单元测试的主要内容,如果未能解决你的问题,请参考以下文章

PowerMock单元测试

在 NodeJS + Typescript AWS Lambda 上进行单元测试

在 Android 上进行单元测试期间的语言环境

单元测试布道之一:定义分类与策略

在越狱的全新 iOS 设备上进行测试 - 使用 Xcode?

DDD(领域驱动设计)系列之二-应用架构