单元测试布道二:在全新的 DDD 架构上进行单元测试
回顾
前期内容 单元测试布道之一:定义、分类与策略 描述了测试相关的部分概念,介绍了 dotnet 单元测试策略,声明了可测试性的重要性,并展示了现有项目的特定场景添测试用例的具体步骤。
- 单元测试的定义:对软件中的最小可测试单元进行检查和验证,用于检验被测代码的一个很小的、很明确的功能是否正确
- 单元测试的必要:单元测试能在开发阶段发现 BUG,及早暴露,收益高,是交付质量的保证
- 单元测试的策略:自底向上或孤立的测试策略
现在略回顾下准备知识就进入实战。
dotnet 单元测试相关的工具和知识
- 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);
- 使用
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()
等大量扩展方法,测试步骤略繁复。
可测试性
可测试性的回顾仍然十分有必要,大概上可以归于以下三类。
不确定性/未决行为
// 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)管理,包含以下特性
- 每个组织(Organization)都可以创建一个或多个项目(Project)
- 提供公共的 GitLab 用于托管代码,每个项目(Project)创建之时有 master 和 develop 分支被创建出来
- 项目(Project)目前支持公共 GitLab,但预备在将来支持私有 GitLab
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:分支管理
本迭代预计引入分支管理功能
- 每个项目(Project,聚合根)都能创建特定类别的分支(Branch,实体),目前支持特性分支(feature)和修复分支(hotfix),分别从 develop 分支和 master 分支签出
- GitLab 有自己的管理入口,分支创建时需要检查项目和分支是否存在
- 分支创建成功后将提交记录(Commit)写入分支
前期:分析调用时序
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
:基础设施
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
前期:列举单元测试用例
- 项目领域服务
- 在 GitLab 项目不存在时断言失败:
CreateBranch_WhenRemoteProjectNotExist_ShouldFailed()
- 在 GitLab 分支已经存在时断言失败:
CreateBranch_WhenRemoteBranchPresented_ShouldFailed()
- 创建不支持的特性分支时断言失败:
CreateBranch_UseTypeNotSupported_ShouldFailed()
- 正确创建的分支应包含提交记录(Commit):
CreateBranch_WhenParamValid_ShouldQuoteCommit()
- 在 GitLab 项目不存在时断言失败:
- 项目应用服务
5. 在项目(Project)不存在时断言失败:Post_WhenProjectNotExist_ShouldFail()
6. 在项目(Project)不存在时断言失败:Post_WhenProjectNotExist_ShouldFail()
7. 参数合法时返回预期的分支签出结果: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
前期:设计模块与依赖关系
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
前期:列举单元测试用例
- 项目领域服务
- 使用外部 GitLab 仓库能签出分支:
CreateBranch_UserExternalRepository_ShouldQuoteCommit()
- 使用外部 GitLab 仓库能签出分支:
中期:业务逻辑实现
使用新的工厂接口 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
中期:单元测试实现
ANTI-PATTERN:依赖具体实现
支持外部 GitLab 仓库需要动态生成 IGitlabClient
实例,故在业务逻辑中根据项目(Project)设置实例化 GitlabClinet
是很“自然”的事情,但代码不再具有可测试性。
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?
}
实战小结
- 良好的设计具有很好的可测试性
- 可测试性要求反过来会影响架构设计与领域实现
需求-迭代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 架构为归于什么位置?
中期:单元测试实现
注意:仓储仍然是可测且应该进行测试的,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}"),
};
实战小结
- 查询逻辑也能够进行有效的测试
- 单元测试减少了回归工作量
- 单元测试提升了交付质量
单元测试布道二:在全新的 DDD 架构上进行单元测试的更多相关文章
- C#进阶系列——DDD领域驱动设计初探(二):仓储Repository(上)
前言:上篇介绍了DDD设计Demo里面的聚合划分以及实体和聚合根的设计,这章继续来说说DDD里面最具争议的话题之一的仓储Repository,为什么Repository会有这么大的争议,博主认为主要原 ...
- ddd 架构设计——abp
一.为什么要分层 分层架构是所有架构的鼻祖,分层的作用就是隔离,不过,我们有时候有个误解,就是把层和程序集对应起来,就比如简单三层架构中,在你的解决方案中,一般会有三个程序集项目:XXUI.dll.X ...
- DDD领域驱动设计初探(二):仓储Repository(上)
前言:上篇介绍了DDD设计Demo里面的聚合划分以及实体和聚合根的设计,这章继续来说说DDD里面最具争议的话题之一的仓储Repository,为什么Repository会有这么大的争议,博主认为主要原 ...
- 分布式抽奖秒杀系统,DDD架构设计和实现分享
作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.用大项目,贯穿知识体系 写CRUD.堆API.改屎山⛰,熬多少个996也只是成为重复的螺丝 ...
- 怎么说服领导,能让我用DDD架构肝项目?
作者:小傅哥 博客:https://bugstack.cn 原文:https://mp.weixin.qq.com/s/ezd-6xkRiNfPH1lGwhLd8Q 沉淀.分享.成长,让自己和他人都能 ...
- Python的单元测试(二)
title: Python的单元测试(二) date: 2015-03-04 19:08:20 categories: Python tags: [Python,单元测试] --- 在Python的单 ...
- ASP.NET MVC5 网站开发实践(二) Member区域 - 咨询管理的架构
咨询.留言.投诉等功能是网站应具备的基本功能,可以加强管理员与用户的交流,在上次完成文章部分后,这次开始做Member区域的咨询功能(留言.投诉都是咨询).咨询跟文章非常相似,而且内容更少.更简单. ...
- JUnit + Mockito 单元测试(二)
摘自: http://blog.csdn.net/zhangxin09/article/details/42422643 版权声明:本文为博主原创文章,未经博主允许不得转载. 目录(?)[-] 入门 ...
- 百度全新的ARM架构服务器,一个2U机箱装6台,每台4个3T硬盘,每个机箱共72TB
1月11日,中国科学院原秘书长.国家科技重大专项国务院咨询评估组专家侯自强,来到百度南京数据中心,和他一起的还有中国工程院院士倪光南以及工业和信息化部电信研究院传输研究所副所长石友康等人.他们看到的是 ...
随机推荐
- for 循环语句 (enumerate枚举,据说直接写出索引值)
for i in ***: 今天上课看到alex用了 for index,i in enumerate(list): print(index,i) (enumerate好像可以设置开头序号enumer ...
- 技能Get·Windows10将任何格式文件固定到开始屏幕
阅文时长 | 0.6分钟 字数统计 | 960.8字符 主要内容 | 1.引言&背景 2.将文件加入到应用列表中 3.修改注册表法 4.声明与参考资料 『技能Get·Windows10将任何格 ...
- Sentinel导航
简介 最近都在弄微服务的东西,现在来记录下收获.我从一知半解到现在能从0搭建使用最大的感触有两点 1.微服务各大组件的版本很多,网上很多博客内容不一定适合你的版本,很多时候苦苦琢磨都是无用功 2.网上 ...
- golang:net/http理解总结
Go语言标准库内建提供了net/http包,涵盖了HTTP客户端和服务端的具体实现.使用net/http包,我们可以很方便地编写HTTP客户端或服务端的程序. http服务端的创建流程 在使用http ...
- 攻防世界(二)Training-WWW-Robots
攻防世界系列:Training-WWW-Robots 1.查看robots.txt的要求 补充: 什么是robots.txt协议? Robots.txt是放在网站根目录下的一个文件,也是搜索引擎在网 ...
- 使用Wok管理kvm虚拟机
[Centos7.4] !!!测试环境我们首关闭防火墙和selinux [root@localhost ~]# systemctl stop firewalld [root@localhost ~]# ...
- 039.Python使用TCP实现多用户并发
使用TCP实现多用户并发 在前面的实验中,TCP建立连接时,只能允许一个用户连接,当第二个用户建立连接时,发送的信息,服务端是没有办法接受,只有当第一个用户退出时,才能接受到第二个用户的请求,并实现通 ...
- stm32之ADC应用实例(单通道、多通道、基于DMA)-转载精华帖,最后一部分的代码是精华
硬件:STM32F103VCT6 开发工具:Keil uVision4 下载调试工具:ARM仿真器网上资料很多,这里做一个详细的整合.(也不是很详细,但很通俗).所用的芯片内嵌3个12位的 ...
- addrinfo结构体原型-(转自 cxz2009)
addrinfo结构体原型 typedef struct addrinfo { int ai_flags; //AI_PASSIVE,AI_CANONNAME,AI_NUMERIC ...
- Docker学习(12) Dockerfile构建过程
Dockerfile的构建过程 以上为构建缓存