单元测试布道之二:在全新的 DDD 架构上进行单元测试
单元测试布道之二:在全新的 DDD 架构上进行单元测试
回顾
前期内容 单元测试布道之一:定义、分类与策略 描述了测试相关的部分概念,介绍了 dotnet 单元测试策略,声明了可测试性的重要性,并展示了现有项目的特定场景添测试用例的具体步骤。
单元测试的定义:对软件中的最小可测试单元进行检查和验证,用于检验被测代码的一个很小的、很明确的功能是否正确
单元测试的必要:单元测试能在开发阶段发现 BUG,及早暴露,收益高,是交付质量的保证
单元测试的策略:自底向上或孤立的测试策略
现在略回顾下准备知识就进入实战。
dotnet 单元测试相关的工具和知识
NSubstitute
自称是 A friendly substitute for .NET mocking libraries,目前已经是 Mock
等的替代实现。
mock 离不开动态代理,NSubstitute 依赖 Castle Core,其原理另起篇幅描述。
// Arrange(准备):Preparevar calculator = Substitute.For<ICalculator>();// Act(执行):Set a return valuecalculator.Add(1, 2).Returns(3); Assert.AreEqual(3, calculator.Add(1, 2));// Assert(断言 ):Check received callscalculator.Received().Add(1, Arg.Any<int>()); calculator.DidNotReceive().Add(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
成员的访问能力。
扩展方法的测试
扩展方法不具体可测试性,但如果注入的是接口或抽象类,那么对接口的直接调用可以 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()
等大量扩展方法,测试步骤略繁复。
可测试性
可测试性的回顾仍然十分有必要,大概上可以归于以下三类。
不确定性/未决行为
// BADpublic 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 架构,包含以下特性
每个组织(Organization)都可以创建一个或多个项目(Project)
提供公共的 GitLab 用于托管代码,每个项目(Project)创建之时有 master 和 develop 分支被创建出来
项目(Project)目前支持公共 GitLab,但预备在将来支持私有 GitLab
ProjectController+Post() : BranchDto«interface»IProjectServiceCreateBranch() : Branch«interface»IGitlabClientProjectGitlab: GitlabSettingsIProjectRepositoryGitlabSettings
需求-迭代1:分支管理
本迭代预计引入分支管理功能
每个项目(Project,聚合根)都能创建特定类别的分支(Branch,实体),目前支持特性分支(feature)和修复分支(hotfix),分别从 develop 分支和 master 分支签出
GitLab 有自己的管理入口,分支创建时需要检查项目和分支是否存在
分支创建成功后将提交记录(Commit)写入分支
前期:分析调用时序
UserServiceDatabaseGitlabcreate branch with name and typeget branch recordbranch entity or nullassert failalt[if branch record exist]check project and branchresponseassert failalt[if remote project not exist orbranch exist]create remote branchokinsert branch recordbranch entitybranch dtoUserServiceDatabaseGitlab
前期:设计模块与依赖关系
IProjectService
:领域服务,依赖IGitlabClient
完成业务验证与调用IProjectRepository
:项目(Project,聚合根)仓储,更新聚合根IBranchRepository
:分支(Branch,实体)仓储,检查IGitlabClient
:基础设施
ProjectController+Post() : BranchDto«interface»IProjectServiceCreateBranch() : Branch«interface»IGitlabClient«interface»IBranchRepositoryGetByName() : BranchProjectGitlab: GitlabSettingsBranches: ICollection<Branch>IProjectRepositoryGitlabSettingsBranch
前期:列举单元测试用例
项目领域服务
在 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
前期:设计模块与依赖关系
ProjectController+Post() : BranchDto«interface»IProjectServiceCreateBranch() : Branch«interface»IBranchRepositoryGetByName() : Branch«interface»IGitlabClientFactoryGetGitlabClient() : IGitlabClient«interface»IGitlabClientProjectGitlab: GitlabSettingsBranches: ICollection<Branch>IProjectRepositoryGitlabSettingsBranch
前期:列举单元测试用例
项目领域服务
使用外部 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
是很“自然”的事情,但代码不再具有可测试性。
ProjectController+Post() : BranchDto«interface»IProjectServiceCreateBranch() : BranchProjectService_gitlabOptions IOptionsCreateBranch() : Branch«interface»IBranchRepositoryGetByName() : BranchProjectGitlab: GitlabSettingsBranches: ICollection<Branch>IProjectRepositoryGitlabClientGitlabSettingsBranch
对应的逻辑实现在分支 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? }
实战小结
良好的设计具有很好的可测试性
可测试性要求反过来会影响架构设计与领域实现
需求-迭代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<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:业务变更将引起单元测试失败
提问:如果需要取消 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}"), };
实战小结
查询逻辑也能够进行有效的测试
单元测试减少了回归工作量
单元测试提升了交付质量
leoninew 原创,转载请保留出处 www.cnblogs.com/leoninew