zoukankan      html  css  js  c++  java
  • 单元测试布道之二:在全新的 DDD 架构上进行单元测试

    leoninew 原创,转载请保留出处 www.cnblogs.com/leoninew

    回顾:相关定义和相关知识

    前期内容 单元测试布道之一:定义、分类与策略 描述了测试相关的部分概念,介绍了 dotnet 单元测试策略,声明了可测试性的重要性

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

    现在略回顾下准备知识。

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

    1. NSubstitute

    该类库对自身的定位是 A friendly substitute for .NET mocking libraries,作为老牌 mock 库 moq 的替代实现。

    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);
    
    1. 使用 InternalsVisibleToAttribute 测试内部类

    为了避免暴露大量的实现细节、提高内聚性,开发人员应应减少 public 访问修饰符的使用。但是非公开的类和方法如何进行测试?这就是InternalsVisibleToAttribute 的作用,我们可以在被测项目的AssemblyInfo.cs 文件中添加定义,该特性接受 assembly 名称作为参数,对其暴露内部可见性。

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

    也可以在被测试目标的项目文件 .csproj 中使用,并支持使用项目的上下文变量作为参数名。

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

    通过以上两种方式,单元测试项目拥有了对被测试项目中 internal 类和方法的访问能力。

    1. 扩展方法的测试

    扩展方法不具备可测试性,如果注入的是接口或抽象类,那么对扩展方法本身可以 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;
    	}
    }
    

    直接 mock IRandom 的扩展方法 Next(int min, int max) 会失败,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 架构,预计的项目结构如下

    $ tree -L 2
    .
    ├── doc
    ├── HelloDevCloud.sln
    ├── README.md
    ├── src
    │   ├── HelloDevCloud.Domain                领域对象
    │   ├── HelloDevCloud.Domain.Shared
    │   ├── HelloDevCloud.DomainService         领域服务
    │   ├── HelloDevCloud.EntityFrameworkCore   基于 efcore 的仓储模式实现
    │   ├── HelloDevCloud.Infrastructure        基础设施
    │   ├── HelloDevCloud.Repositories          DbContext 与仓储
    │   └── HelloDevCloud.Web                   Web 接口
    └── test
        ├── HelloDevCloud.DomainService.Tests   领域服务测试用例
        ├── HelloDevCloud.RepositoriesTests     DbContext 与仓储测试用例
        └── HelloDevCloud.Web.Tests             Web 接口测试用例
    

    基于 DDD 分层架构不一而足,本示例用作单元测试演示

    目前已有如下领域划分

    1. 每个组织(Organization)都可以创建一个或多个项目(Project)
    2. 提供公共的 GitLab 用于托管代码,每个项目(Project)创建之时有 master 和 develop 分支被创建出来
    3. 项目(Project)目前支持公共 GitLab,但预备在将来支持私有 GitLab
    classDiagram class ProjectController { +Post() BranchDto } class IProjectService { <<interface>> CreateBranch() Branch } class IGitlabClient { <<interface>> } class Project { Gitlab: GitlabSettings } ProjectController ..> IProjectService ProjectController ..> IProjectRepository IProjectService ..> IGitlabClient Project --* GitlabSettings

    需求-迭代1:分支管理

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

    1. 每个项目(Project,聚合根)都能创建特定类别的分支(Branch,实体),目前支持特性分支(feature)和修复分支(hotfix),分别从 develop 分支和 master 分支签出
    2. GitLab 有自己的管理入口,分支创建时需要检查项目和分支是否存在
    3. 分支创建成功后将提交记录(Commit)写入分支

    前期:分析调用时序

    %% Example of sequence diagram sequenceDiagram User->>+Service: create branch with name and type Service->>+Database: get branch record Database->>-Service: branch entity or null alt if branch record exist Service->>User: assert fail end Service->>+Gitlab: check project and branch Gitlab->>-Service: response alt if remote project not exist or branch exist Service->>User: assert fail end Service->>+Gitlab: create remote branch Gitlab->>-Service: ok Service->>+Database: insert branch record Database->>-Service: branch entity Service->>-User: branch dto

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

    • IProjectService:领域服务,依赖IGitlabClient完成业务验证与调用
    • IProjectRepository:项目(Project,聚合根)仓储,更新聚合根
    • IBranchRepository:分支(Branch,实体)仓储,检查
    • IGitlabClient:基础设施
    classDiagram class ProjectController { +Post() BranchDto } class IProjectService { <<interface>> CreateBranch() Branch } class IGitlabClient { <<interface>> } class IBranchRepository { <<interface>> GetByName() Branch } class Project { Gitlab: GitlabSettings Branches: ICollection<Branch> } ProjectController ..> IProjectService ProjectController ..> IProjectRepository ProjectController ..> IBranchRepository IProjectService ..> IGitlabClient Project --* GitlabSettings Project --o Branch

    前期:列举单元测试用例

    • 项目领域服务
      1. 在 GitLab 项目不存在时断言失败:CreateBranch_WhenRemoteProjectNotExist_ShouldFailed()
      2. 在 GitLab 分支已经存在时断言失败:CreateBranch_WhenRemoteBranchPresented_ShouldFailed()
      3. 创建不支持的特性分支时断言失败:CreateBranch_UseTypeNotSupported_ShouldFailed()
      4. 正确创建的分支应包含提交记录(Commit):CreateBranch_WhenParamValid_ShouldQuoteCommit()
    • 项目应用服务
      1. 在项目(Project)不存在时断言失败:Post_WhenProjectNotExist_ShouldFail()
      2. 在项目(Project)不存在时断言失败:Post_WhenProjectNotExist_ShouldFail()
      3. 参数合法时返回预期的分支签出结果:Post_WhenParamValid_ShouldCreateBranch()

    中期:业务逻辑实现

    1. 项目(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;
    +         }
    
    1. 视图层逻辑并不复杂
    [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);
    }
    
    1. 我们总是需要在远程与本地项目、分支之前进行检查,它们由领域服务组织
    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}"),
        };
    }
    

    中期:单元测试实现

    实战小结

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

    需求-迭代2:支持外部 GitLab,支持分支搜索

    本迭代预期添加以下内容

    1. 支持使用外部 GitLab 上管理分支
    2. 并支持使用名称搜索组织下的分支列表

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

    classDiagram class ProjectController { +Post() BranchDto } class IProjectService { <<interface>> CreateBranch() Branch } class IBranchRepository { <<interface>> GetByName() Branch } class IGitlabClientFactory { <<interface>> GetGitlabClient() IGitlabClient } class IGitlabClient { <<interface>> } class Project { Gitlab: GitlabSettings Branches: ICollection<Branch> } ProjectController ..> IProjectService ProjectController ..> IProjectRepository ProjectController ..> IBranchRepository IProjectService ..> IGitlabClientFactory IGitlabClientFactory --> IGitlabClient Project --* GitlabSettings Project --o Branch

    前期:列举单元测试用例

    • 项目领域服务
      1. 使用外部 GitLab 仓库能签出分支:CreateBranch_UserExternalRepository_ShouldQuoteCommit()
    • 分支仓储
      1. 从配置了外部仓库的项目获取分支应返回符合预期的结果 GetAllByOrganization_ViaName_ReturnMatched

    中期:业务逻辑实现

    1. 使用新的工厂接口 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

    1. 使用组织 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();
        }
    

    中期:单元测试实现

    注意:仓储仍然是可测且应该进行测试的,mock 数据库查询的主要工作是 mock IQuerable<T>,但是 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:依赖具体实现

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

    classDiagram class ProjectController { +Post() BranchDto } class IProjectService { <<interface>> CreateBranch() Branch } class ProjectService { _gitlabOptions IOptions<GitlabOptions> CreateBranch() Branch } class IBranchRepository { <<interface>> GetByName() Branch } class Project { Gitlab: GitlabSettings Branches: ICollection<Branch> } ProjectController ..> IProjectService ProjectController ..> IProjectRepository ProjectController ..> IBranchRepository ProjectService --> GitlabClient Project --* GitlabSettings Project --o Branch

    对应的逻辑实现在分支 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? 
        }
    

    提问:如果需要取消 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. 单元测试减少了回归工作量,提升了交付质量

    后话与总结

    以迭代紧张为理由在提交业务代码时候忽略单元测试的编写,是项目管理及开发人员对单元测试认识有限的体现。前文描述了定义和必要性,本文则直接进行了实践,展示了单元测试如何影响我们的逻辑实现甚至是架构设计。

    1. 交付质量应在开发阶段就开始由单元测试保障,开发人员应认识和理解单元测试,掌握相关知识工具和技能。
    2. 测试先行体现了业务规则,要求逻辑自洽
    3. 可测试性要求会倒推架构合理性,避免反模式

    leoninew 原创,转载请保留出处 www.cnblogs.com/leoninew

  • 相关阅读:
    libgdx 2D 粒子效果
    libgdx robovm binding umeng
    libgdx 3D Bullet 碰撞检测三
    《学习CSS布局》学习笔记
    保研机试准备之常用机试代码
    保研面试准备之自然语言处理知识点梳理
    软件工程应用与实践复习笔记
    Git学习笔记
    C、C++、C#中struct的简单比较
    从PEP-8学习Python编码风格
  • 原文地址:https://www.cnblogs.com/leoninew/p/practice-for-unit-test-with-ddd.html
Copyright © 2011-2022 走看看