單元測試佈道二:在全新的 DDD 架構上進行單元測試
leoninew 原創,轉載請保留出處 www.cnblogs.com/leoninew
回顧
前期內容 單元測試佈道之一:定義、分類與策略 描述了測試相關的部分概念,介紹了 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 = "//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}"),
};
實戰小結
- 查詢邏輯也能夠進行有效的測試
- 單元測試減少了回歸工作量
- 單元測試提升了交付質量
leoninew 原創,轉載請保留出處 www.cnblogs.com/leoninew