單元測試布道之二:在全新的 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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM