技术分享|单元测试推广与实战-在全新的DDD架构上进行单元测试
源寶導(dǎo)讀:單元測試是伴隨軟件工程出現(xiàn)和發(fā)展的,怎么做大家可能各有見解。本文介紹了單元測試中的反模式,強(qiáng)調(diào)了可測試性的重要性,并以 DDD 架構(gòu)項(xiàng)目的迭代進(jìn)程作為示例,演示了單元測試的組織過程,展示了單元測試如何影響架構(gòu)設(shè)計(jì),進(jìn)而提高交付質(zhì)量。
一、背景
? ? 為了滿足日益增長的業(yè)務(wù)需求,天際-DevOps平臺(tái)于近期開始了重構(gòu)工作。由于重構(gòu)過程對(duì)各業(yè)務(wù)場景進(jìn)行了重新定義,開發(fā)過程推行基于 DDD 的編程架構(gòu),所以是推廣和落地單元測試的很好時(shí)機(jī)。糧草未動(dòng),兵馬先行,這里先介紹單元測試的定義、必要性,接著是引入 dotnet 單元測試相關(guān)的知識(shí),然后以反模式示例演示可測試性概念,最后在全新項(xiàng)目上演示整個(gè)迭代周期中單元測試的策略與實(shí)現(xiàn)細(xì)節(jié),并進(jìn)行小結(jié)。
二、單元測試相關(guān)的概念、工具和技巧
2.1、單元測試的定義
? ? 單元測試是指對(duì)軟件中的最小可測試單元進(jìn)行檢查和驗(yàn)證,是最低級(jí)別的測試活動(dòng)。開發(fā)者編寫的一小段代碼,用于檢驗(yàn)被測代碼的一個(gè)很小的、很明確的功能是否正確。通常而言,一個(gè)單元測試是用于判斷某個(gè)特定條件(或者場景)下某個(gè)特定函數(shù)的行為。
驗(yàn)證代碼與設(shè)計(jì)相符合;
跟蹤需求與設(shè)計(jì)的實(shí)現(xiàn);
發(fā)現(xiàn)設(shè)計(jì)和需求中存在的缺陷;
發(fā)現(xiàn)在編碼過程中引入的錯(cuò)誤。
2.2、單元測試的必要性
單元測試能在開發(fā)階段發(fā)現(xiàn) BUG,及早暴露,收益高,是交付質(zhì)量的保證。
來自微軟的統(tǒng)計(jì)數(shù)據(jù)顯示,bug在單元測試階段被發(fā)現(xiàn),平均耗時(shí)3.25小時(shí),如果漏到系統(tǒng)測試階段,要花費(fèi)11.5小時(shí)。
85% 的缺陷都在代碼設(shè)計(jì)階段產(chǎn)生,而發(fā)現(xiàn) bug 的階段越靠后,耗費(fèi)成本就越高,指數(shù)級(jí)別的增高。
2.3、單元測試相關(guān)的模式、知識(shí)點(diǎn)和工具
Arrange-Act-Assert (AAA) 模式
? ? AAA(準(zhǔn)備、執(zhí)行、斷言)模式是編寫待測試方法的單元測試的常用方法。
? ? 一個(gè)典型的單元測試用例如下:
[Fact] public void Add_EmptyString_ReturnsZero() {// Arrangevar stringCalculator = new StringCalculator();// Actvar actual = stringCalculator.Add("");// AssertAssert.Equal(0, actual); }NSubstitute
? ? 該類庫對(duì)自身的定位是?A friendly substitute for .NET mocking libraries,作為老牌 mock 庫 moq 的替代實(shí)現(xiàn)。(mock 離不開動(dòng)態(tài)代理,NSubstitute 依賴 Castle Core,其原理另起篇幅描述。)
// Arrange(準(zhǔn)備):Prepare var calculator = Substitute.For<ICalculator>();// Act(執(zhí)行):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);使用InternalsVisible ToAttribute測試內(nèi)部類
? ? 為了避免暴露大量的實(shí)現(xiàn)細(xì)節(jié)、提高內(nèi)聚性,開發(fā)人員應(yīng)應(yīng)減少?public?訪問修飾符的使用。但是非公開的類和方法如何進(jìn)行測試?這就是?InternalsVisible ToAttribute 的作用,我們可以在被測項(xiàng)目的AssemblyInfo.cs?文件中添加定義,該特性接受 assembly 名稱作為參數(shù),對(duì)其暴露內(nèi)部可見性。
[assembly: InternalsVisibleTo("XXX.Tests")]? ? 也可以在被測試目標(biāo)的項(xiàng)目文件?.csproj?中使用,并支持使用項(xiàng)目的上下文變量作為參數(shù)名。
<ItemGroup><AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"><_Parameter1>$(MSBuildProjectName).Tests</_Parameter1></AssemblyAttribute></ItemGroup>? ? 通過以上兩種方式,單元測試項(xiàng)目擁有了對(duì)被測試項(xiàng)目中?internal?類和方法的訪問能力。
擴(kuò)展方法的測試
? ? 大多數(shù)場景下擴(kuò)展方法不具備可測試性,efcore 中以?Async?結(jié)尾的擴(kuò)展方法,測試它們需要實(shí)現(xiàn)?IDbAsync QueryProvider?接口,步驟繁瑣,業(yè)務(wù)實(shí)現(xiàn)中應(yīng)注意擴(kuò)展方法的可測試性。
2.4、可測試性
? ? 可測試性的回顧仍然十分有必要,大概上可以歸于以下三類。
不確定性/未決行為
// BAD public class PowerTimer {public String GetMeridiem(){var time = DateTime.Now;if (time.Hour >= 0 && time.Hour < 12){return "AM";}return "PM";} }依賴于實(shí)現(xiàn):不可 mock
// BAD: 依賴于實(shí)現(xiàn) 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: 靜態(tài)方法 public static bool CheckNodejsInstalled() {return Environment.GetEnvironmentVariable("PATH").Contains("nodejs", StringComparison.OrdinalIgnoreCase); }復(fù)雜繼承/高耦合代碼:測試?yán)щy
? ? 隨著步驟與流程判斷增加,場景組合和 mock 工作量成倍堆積,直到不可測試。
三、實(shí)戰(zhàn):在全新的 DDD 架構(gòu)上進(jìn)行單元測試
? ? HelloDevCloud?是一個(gè)假想的早期 devOps 產(chǎn)品,提供了組織(Organization)和項(xiàng)目(Project)管理,遵從極簡的 DDD 架構(gòu),預(yù)計(jì)的項(xiàng)目結(jié)構(gòu)如下:
$ tree -L 2 . ├── doc ├── HelloDevCloud.sln ├── README.md ├── src │ ├── HelloDevCloud.Domain 領(lǐng)域?qū)ο?│ ├── HelloDevCloud.Domain.Shared │ ├── HelloDevCloud.DomainService 領(lǐng)域服務(wù) │ ├── HelloDevCloud.EntityFrameworkCore 基于 efcore 的倉儲(chǔ)模式實(shí)現(xiàn) │ ├── HelloDevCloud.Infrastructure 基礎(chǔ)設(shè)施 │ ├── HelloDevCloud.Repositories DbContext 與倉儲(chǔ) │ └── HelloDevCloud.Web Web 接口 └── test├── HelloDevCloud.DomainService.Tests 領(lǐng)域服務(wù)測試用例├── HelloDevCloud.RepositoriesTests DbContext 與倉儲(chǔ)測試用例└── HelloDevCloud.Web.Tests Web 接口測試用例? ??基于 DDD 分層架構(gòu)不一而足,本示例用作單元測試演示。
? ? 目前已有如下領(lǐng)域劃分:
每個(gè)組織(Organization)都可以創(chuàng)建一個(gè)或多個(gè)項(xiàng)目(Project);
提供公共的 GitLab 用于托管代碼,每個(gè)項(xiàng)目(Project)創(chuàng)建之時(shí)有 master 和 develop 分支被創(chuàng)建出來;
項(xiàng)目(Project)目前支持公共 GitLab,但預(yù)備在將來支持私有 GitLab。
3.1、需求-迭代1:分支管理
? ? 本迭代預(yù)計(jì)引入分支管理功能:
每個(gè)項(xiàng)目(Project,聚合根)都能創(chuàng)建特定類別的分支(Branch,實(shí)體),目前支持特性分支(feature)和修復(fù)分支(hotfix),分別從 develop 分支和 master 分支簽出;
GitLab 有自己的管理入口,分支創(chuàng)建時(shí)需要檢查項(xiàng)目和分支是否存在;
分支創(chuàng)建成功后將提交記錄(Commit)寫入分支。
前期:分析調(diào)用時(shí)序
前期:設(shè)計(jì)模塊與依賴關(guān)系
IProjectService:領(lǐng)域服務(wù),依賴IGitlabClient完成業(yè)務(wù)驗(yàn)證與調(diào)用;
IProjectRepository:項(xiàng)目(Project,聚合根)倉儲(chǔ),更新聚合根;
IBranchRepository:分支(Branch,實(shí)體)倉儲(chǔ),檢查;
IGitlabClient:基礎(chǔ)設(shè)施。
前期:列舉單元測試用例
項(xiàng)目領(lǐng)域服務(wù):
在 GitLab 項(xiàng)目不存在時(shí)斷言失敗:CreateBranch_WhenRemoteProjectNotExist_ShouldFailed()
在 GitLab 分支已經(jīng)存在時(shí)斷言失敗:CreateBranch_WhenRemoteBranchPresented_ShouldFailed()
創(chuàng)建不支持的特性分支時(shí)斷言失敗:CreateBranch_UseTypeNotSupported_ShouldFailed()
正確創(chuàng)建的分支應(yīng)包含提交記錄(Commit):CreateBranch_WhenParamValid_ShouldQuoteCommit()
項(xiàng)目應(yīng)用服務(wù):
在項(xiàng)目(Project)不存在時(shí)斷言失敗:Post_WhenProjectNotExist_ShouldFail()
在項(xiàng)目(Project)不存在時(shí)斷言失敗:Post_WhenProjectNotExist_ShouldFail()
參數(shù)合法時(shí)返回預(yù)期的分支的簽出結(jié)果:Post_WhenParamValid_ShouldCreateBranch()
中期:業(yè)務(wù)邏輯實(shí)現(xiàn)
項(xiàng)目(Project )作為聚合根添加分支(Branch)作為組成。
我們總是需要在遠(yuǎn)程與本地項(xiàng)目、分支之前進(jìn)行檢查,它們由領(lǐng)域服務(wù)組織。
中期:單元測試實(shí)現(xiàn)
領(lǐng)域服務(wù):測試用例見于項(xiàng)目源碼?test/HelloDevCloud.DomainService.Tests /Projects/ProjectServiceTest.cs。
應(yīng)用服務(wù):測試用例見于項(xiàng)目源碼?test/HelloDevCloud.Web.Tests/Controllers /ProjectControllerTest.cs。
實(shí)戰(zhàn)小結(jié)
單元測試用例體現(xiàn)了業(yè)務(wù)規(guī)則;
單元測試同架構(gòu)一樣是分層的。
3.2、需求-迭代2:支持外部 GitLab,支持分支搜索
本迭代預(yù)期添加以下內(nèi)容:
支持使用外部 GitLab 上管理分支;
并支持使用名稱搜索組織下的分支列表。
前期:設(shè)計(jì)模塊與依賴關(guān)系
前期:列舉單元測試用例
項(xiàng)目領(lǐng)域服務(wù):
使用外部 GitLab 倉庫能簽出分支:CreateBranch_UserExternalRepository _ShouldQuoteCommit();
分支倉儲(chǔ):
從配置了外部倉庫的項(xiàng)目獲取分支應(yīng)返回符合預(yù)期的結(jié)果:GetAllByOrganization_ ViaName_ReturnMatched
使用新的工廠接口 IGitlabClient Factory 替換 IGitlabClient;
使用組織 Id 查詢分支列表。
public IList<Branch> GetAllByOrganization(int organizationId, string search)
{var projects = EfUnitOfWork.DbSet<Project>();var branchs = EfUnitOfWork.DbSet<Branch>();var query = from b in branchsjoin p in projectson b.ProjectId equals p.Idwhere 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();
}
領(lǐng)域服務(wù):測試用例見于項(xiàng)目源碼?test/HelloDevCloud.DomainService.Tests/ Projects/ProjectServiceTest.cs;
倉儲(chǔ)實(shí)現(xiàn):測試用例見于項(xiàng)目源碼?test/HelloDevCloud.RepositoriesTests/ Implements/BranchRepositoryTest.cs。
良好的設(shè)計(jì)具有很好的可測試性,可測試性要求反過來會(huì)影響架構(gòu)設(shè)計(jì)與領(lǐng)域?qū)崿F(xiàn);
倉儲(chǔ)邏輯也能夠進(jìn)行有效的測試;
單元測試減少了回歸工作量,提升了交付質(zhì)量。
開發(fā)人員應(yīng)認(rèn)識(shí)和理解單元測試,熟練運(yùn)用相關(guān)工具和技能;
交付質(zhì)量保證應(yīng)在開發(fā)階段就由單元測試覆蓋率保證;
測試先行體現(xiàn)了業(yè)務(wù)規(guī)則,要求邏輯自洽和場景覆蓋;
可測試性要求會(huì)倒推架構(gòu)合理性,避免架構(gòu)劣化甚至反模式。
中期:業(yè)務(wù)邏輯實(shí)現(xiàn)
中期:單元測試實(shí)現(xiàn)
? ? 注意:倉儲(chǔ)仍然是可測且應(yīng)該進(jìn)行測試的,mock 數(shù)據(jù)庫查詢的主要工作是 mock IQuerable<T>,但是 mock 數(shù)據(jù)庫讀寫并不容易。好在 efcore 提供了 UseInMemory Database()?模式,無須我們?cè)偬峁?FackRepository 一類實(shí)現(xiàn)。
[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 = "成本系統(tǒng)",OrganizationId = 1},new Project{Id = 12,Name = "成本系統(tǒng)合同執(zhí)行應(yīng)用",OrganizationId = 1},new Project{Id = 13,Name = "售樓系統(tǒng)",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發(fā)版修復(fù)分支15",ProjectId = 12,Type = BranchType.Hotfix},new Branch{Id = 103,Name = "730Core自動(dòng)化驗(yàn)證",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:依賴具體實(shí)現(xiàn)
? ? 支持外部 GitLab 倉庫需要?jiǎng)討B(tài)生成 IGitlabClient 實(shí)例,故在業(yè)務(wù)邏輯中根據(jù)項(xiàng)目(Project)設(shè)置實(shí)例化 GitlabClinet是很“自然”的事情,但代碼不再具有可測試性。
? ? 對(duì)應(yīng)的實(shí)現(xiàn)邏輯片段如下:
//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 設(shè)置, 則作為外部倉庫 + 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); + } + }? ? 對(duì)于以上實(shí)現(xiàn),調(diào)用 ProjectService 會(huì)真實(shí)地調(diào)用 GitlabClient,注意這引入了依賴具體實(shí)現(xiàn)的反模式,代碼失去了可測試性。
[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 分支的特殊性,允許用戶自行管理,在方法 GetBranch ReferenceForCreate()?上注釋掉分支判斷是否完成了需求?
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}"),};? ? 可以想象大片的測試用例會(huì)掛掉,因?yàn)樵摲椒ū粡V泛使用并斷言。由于單元測試不再成功,單元測試對(duì)業(yè)務(wù)邏輯的保護(hù)也隨之消失。如果不修復(fù)單元測試,我們就無法保證其他業(yè)務(wù)不受影響。
實(shí)戰(zhàn)小結(jié)
四、后話
? ? 以迭代緊張為理由在提交業(yè)務(wù)代碼時(shí)候忽略單元測試的編寫,是項(xiàng)目管理及開發(fā)人員對(duì)單元測試認(rèn)識(shí)有限的體現(xiàn)。本文描述了定義和必要性,基于 DDD 架構(gòu)進(jìn)行了實(shí)踐,展示了單元測試如何影響業(yè)務(wù)邏輯甚至是架構(gòu)設(shè)計(jì)。
------ END ------
作者簡介
馮同學(xué):?研發(fā)工程師,目前負(fù)責(zé)開發(fā)云平臺(tái)相關(guān)研發(fā)工作。
也許您還想看:
技術(shù)分享|To B復(fù)雜系統(tǒng)的性能測試要注意哪些?
ERP平臺(tái)的自動(dòng)化測試技術(shù)實(shí)踐
更多明源云·天際開放平臺(tái)場景案例與開發(fā)小知識(shí),可以關(guān)注明源云天際開發(fā)者社區(qū)公眾號(hào):
建模零代碼之建模賬號(hào)接入DevOps 賬號(hào)體系
繁星計(jì)劃·上海站 邁出企業(yè)數(shù)字化升級(jí)賦能第一步
建模零代碼之業(yè)務(wù)組件的復(fù)用
總結(jié)
以上是生活随笔為你收集整理的技术分享|单元测试推广与实战-在全新的DDD架构上进行单元测试的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 如何排查 StackOverflow 异
- 下一篇: Ant Design Blazor 发布