ABP入門系列(11)——編寫單元測試

weixin_33769207發表於2017-02-20

ABP入門系列目錄——學習Abp框架之實操演練
原始碼路徑:Github-LearningMpaAbp


1. 前言

In computer programming, unit testing is a software testing method by which individual units of source code, sets of one or more computer program modules together with associated control data, usage procedures, and operating procedures, are tested to determine whether they are fit for use.
在電腦程式設計中,單元測試是一種軟體測試方法。通過該方法來測試程式碼的單個單元、一個或多個計算機程式模組的集合以及相關聯的控制資料、使用過程和操作過程,以確定它們是否適合使用。

單元測試是保證軟體質量的重要指標。單元測試能夠幫助我們提高程式的穩定性,使用單元測試更容易發現問題,也便於重構。TDD(測試驅動開發)的原理就是在開發功能程式碼之前先編寫單元測試。但寫單元測試也是一個浩大的工程。其中優劣也只有真正實踐才能有更深的體會。

TDD開發流程

Abp作為一個優秀的框架,自然也應用了單元測試。Abp的程式碼都通過XUnit進行了單元測試。下面我們就延續Abp的優良作風,為我們的業務程式碼編寫單元測試。

2. 對Abp模板測試專案一探究竟

Test Project

2.1. 測試專案結構

如圖所示,通過在Abp官網建立的模板專案中,預設就已經為我們建立好了測試專案。並對Session、User建立了單元測試。其中LearningMpaAbpTestBase是繼承的整合測試基類,主要用來偽造一個資料庫連線。該專案新增了對Application、Core、EntityFramework專案的引用,以便於我們針對它們進行測試,從這我們也可以看出,Abp是按照Service-->Repository-->Domain這條線來進行整合測試
開啟測試專案的NuGet程式包我們可以發現主要依賴了以下幾個NuGet包:

  • Abp.TestBase:提供了測試基類和基礎架構以便我們建立單元整合測試。
  • Effort.EF6:對基於EF的應用程式提供了一種便利的方式來進行單元測試。
  • XUnit:.Net上好用的測試框架。
  • Shouldly:斷言框架,方便我們書寫斷言。

2.2. Effort(EF單元測試工具)

It is basically an ADO.NET provider that executes all the data operations on a lightweight in-process main memory database instead of a traditional external database. It provides some intuitive helper methods too that make really easy to use this provider with existing ObjectContext or DbContext classes. A simple addition to existing code might be enough to create data driven tests that can run without the presence of the external database.
簡而言之,Effort提供了一個輕量級的記憶體資料庫,來執行所有資料操作。

想對Effort有更對了解,請直接訪問Effort Github官方連結

2.3. xUnit(.Net測試框架)

xUnit專門為.Net Framework打造的一個免費的開源的單元測試工具。
同樣,想對Xunit有更對了解,請直接訪問xUnit 官方連結

這裡我們就簡要介紹下xUnit的基本用法。
xUnit.net 支援兩種主要型別的單元測試:facts and theories(事實和理論)。

Facts are tests which are always true. They test invariant conditions.
Theories are tests which are only true for a particular set of data.

Facts:使用[Fact]標記的測試方法,表示不需要傳參的常態測試方法。
Theories:使用[Theory]標記的測試方法,表示期望一個或多個DataAttribute例項用來提供引數化測試的方法的引數的值。

首先來看下[Fact]的簡單示例:

    public class Class1
    {
        [Fact]
        public void PassingTest()
        {
            Assert.Equal(4, Add(2, 2));
        }

        [Fact]
        public void FailingTest()
        {
            Assert.Equal(5, Add(2, 2));
        }

        int Add(int x, int y)
        {
            return x + y;
        }
    }

其中xUnit.Net提供了三種繼承於DataAttribute的特性([InlineData]、 [ClassData]、 [PropertyData])用於為[Theory]標記的引數化測試方法傳參。
下面是使用這三種特性傳參的例項:
InlineData Example

public class StringTests1
{
    [Theory,
    InlineData("goodnight moon", "moon", true),
    InlineData("hello world", "hi", false)]
    public void Contains(string input, string sub, bool expected)
    {
        var actual = input.Contains(sub);
        Assert.Equal(expected, actual);
    }
}

PropertyData Example

public class StringTests2
{
    [Theory, PropertyData("SplitCountData")]
    public void SplitCount(string input, int expectedCount)
    {
        var actualCount = input.Split(' ').Count();
        Assert.Equal(expectedCount, actualCount);
    }
 
    public static IEnumerable<object[]> SplitCountData
    {
        get
        {
            // Or this could read from a file. :)
            return new[]
            {
                new object[] { "xUnit", 1 },
                new object[] { "is fun", 2 },
                new object[] { "to test with", 3 }
            };
        }
    }
}

ClassData Example

public class StringTests3
{
    [Theory, ClassData(typeof(IndexOfData))]
    public void IndexOf(string input, char letter, int expected)
    {
        var actual = input.IndexOf(letter);
        Assert.Equal(expected, actual);
    }
}
 
public class IndexOfData : IEnumerable<object[]>
{
    private readonly List<object[]> _data = new List<object[]>
    {
        new object[] { "hello world", 'w', 6 },
        new object[] { "goodnight moon", 'w', -1 }
    };
 
    public IEnumerator<object[]> GetEnumerator()
    { return _data.GetEnumerator(); }
 
    IEnumerator IEnumerable.GetEnumerator()
    { return GetEnumerator(); }
}

2.4. Shouldly(斷言框架)

Shouldly提供的斷言方式與傳統的Assert相比更實用易懂。
對比一下就明白了:

Assert.That(contestant.Points, Is.EqualTo(1337));
//Expected 1337 but was 0
contestant.Points.ShouldBe(1337);
//contestant.Points should be 1337 but was 0

首先上寫法上更清晰易懂,第二當測試失敗時,提示訊息也更清楚直接。

同樣,想對Shouldly有更對了解,請直接訪問Shouldly官方連結

2.5. 測試基類XxxTestBase

首先來看看程式碼:

public abstract class LearningMpaAbpTestBase : AbpIntegratedTestBase<LearningMpaAbpTestModule>
    {
        private DbConnection _hostDb;
        private Dictionary<int, DbConnection> _tenantDbs; //only used for db per tenant architecture

        protected LearningMpaAbpTestBase()
        {
            //Seed initial data for host
            AbpSession.TenantId = null;
            UsingDbContext(context =>
            {
                new InitialHostDbBuilder(context).Create();
                new DefaultTenantCreator(context).Create();
            });

            //Seed initial data for default tenant
            AbpSession.TenantId = 1;
            UsingDbContext(context =>
            {
                new TenantRoleAndUserBuilder(context, 1).Create();
            });

            LoginAsDefaultTenantAdmin();
            UsingDbContext(context => new InitialDataBuilder().Build(context));
        }
        protected override void PreInitialize()
        {
            base.PreInitialize();

            /* You can switch database architecture here: */
            UseSingleDatabase();
            //UseDatabasePerTenant();
        }

        /* Uses single database for host and all tenants.
         */
        private void UseSingleDatabase()
        {
            _hostDb = DbConnectionFactory.CreateTransient();

            LocalIocManager.IocContainer.Register(
                Component.For<DbConnection>()
                    .UsingFactoryMethod(() => _hostDb)
                    .LifestyleSingleton()
                );
        }
//...省略後續程式碼
}

從該段程式碼中我們可以看出該測試基類繼承自AbpIntegratedTestBase<T>
PreInitialize()方法中指定了為租戶建立單一資料庫還是多個資料庫。
_hostDb = DbConnectionFactory.CreateTransient();是Effort提供的方法用來建立的DbConnection(資料庫連線)。然後將其使用單例的模式註冊到IOC容器中,這樣在測試中,所有的資料庫連線都將使用Effort為我們建立的資料庫連線。
在建構函式中主要做了兩件事,預置了初始資料和種子資料,並以預設租戶Admin登入。

至此我們對abp為我們預設建立的測試專案有了一個大概的認識。下面我們就開始實戰階段。

3. 單元測試實戰

3.1. 理清要測試的方法邏輯

我們以應用服務層的TaskAppService的CreateTask方法為例,建立單元測試。先來看看該方法的程式碼:

public int CreateTask(CreateTaskInput input) {
    //We can use Logger, it's defined in ApplicationService class.
    Logger.Info("Creating a task for input: " + input);

    //判斷使用者是否有許可權
    if (input.AssignedPersonId.HasValue && input.AssignedPersonId.Value != AbpSession.GetUserId()) PermissionChecker.Authorize(PermissionNames.Pages_Tasks_AssignPerson);

    var task = Mapper.Map < Task > (input);

    int result = _taskRepository.InsertAndGetId(task);

    //只有建立成功才傳送郵件和通知
    if (result > 0) {
        task.CreationTime = Clock.Now;

        if (input.AssignedPersonId.HasValue) {
            task.AssignedPerson = _userRepository.Load(input.AssignedPersonId.Value);
            var message = "You hava been assigned one task into your todo list.";

            //TODO:需要重新配置QQ郵箱密碼
            //SmtpEmailSender emailSender = new SmtpEmailSender(_smtpEmialSenderConfig);
            //emailSender.Send("ysjshengjie@qq.com", task.AssignedPerson.EmailAddress, "New Todo item", message);
            _notificationPublisher.Publish("NewTask", new MessageNotificationData(message), null, NotificationSeverity.Info, new[] {
                task.AssignedPerson.ToUserIdentifier()
            });
        }
    }

該方法主要有三步,第一步判斷許可權,第二步儲存資料庫並返回Id,第三步傳送通知。

3.2. 建立單元測試類並注入依賴

建立TaskAppSerice_Tests類並繼承自XxxTestBase類,並注入需要的依賴。

public class TaskAppService_Tests : LearningMpaAbpTestBase
    {
        private readonly ITaskAppService _taskAppService;

        public TaskAppService_Tests()
        {
            _taskAppService = Resolve<TaskAppService>();
        }
}

3.3. 建立單元測試方法

第一個方法我們應該測試Happy path(即測試方法的預設場景,沒有異常和錯誤資訊)。

[Fact] 
public void Should_Create_New_Task_WithPermission() {
    //Arrange
    //LoginAsDefaultTenantAdmin();//基類的建構函式中已經以預設租戶Admin登入。
    var initalCount = UsingDbContext(ctx = >ctx.Tasks.Count());
    var task1 = new CreateTaskInput() {
        Title = "Test Task",
        Description = "Test Task",
        State = TaskState.Open
    };

    var task2 = new CreateTaskInput() {
        Title = "Test Task2",
        Description = "Test Task2",
        State = TaskState.Open
    };

    //Act
    int taskResult1 = _taskAppService.CreateTask(task1);
    int taskResult2 = _taskAppService.CreateTask(task2);

    //Assert
    UsingDbContext(ctx = >{
        taskResult1.ShouldBeGreaterThan(0);
        taskResult2.ShouldBeGreaterThan(0);
        ctx.Tasks.Count().ShouldBe(initalCount + 2);
        ctx.Tasks.FirstOrDefault(t = >t.Title == "Test Task").ShouldNotBe(null);
        var task = ctx.Tasks.FirstOrDefault(t = >t.Title == "Test Task2");
        task.ShouldNotBe(null);
        task.State.ShouldBe(TaskState.Open);
    });
}

在這裡囉嗦一下單元測試的AAA原則:

  • Arrange:為測試做準備工作
  • Act:執行實際測試的程式碼
  • Assert:斷言,校驗結果

再說明一下單元測試的方法推薦命名規則:
some_result_occurs_when_doing...

回到我們這個測試方法。
Arrange階段我們先以Admin登入(Admin具有所有許可權),然後獲取資料庫中初始Task的數量,再準備了兩條測試資料。
Act階段,直接呼叫TaskAppService的CreateTask方法。
Assert階段:首先判斷CreateTask的返回值大於0 ;再判斷現在資料庫的數量是否增加了2條;再校驗資料庫中是否包含建立的Task,並核對Task的狀態。

3.4. 預置資料

在進行測試的時候,我們肯定需要一些測試資料,以便我們進行合理的測試。
在基礎設施層,我們有專門的SeedData目錄用來預置種子資料。但是進行單元測試的測試資料不應該汙染實體資料庫,所以直接在SeedData目錄預置資料就不太現實。

3.4.1 建立TestDataBuilder

所以,我們就直接在測試專案中,新建一個TestDatas資料夾來管理測試種子資料。
然後建立TestDataBuilder類,通過該類來統一建立所需的測試資料。(注意,需要修改下類中的_context型別為你自己專案對應的DbContext)

namespace LearningMpaAbp.Tests.TestDatas
{
    public class TestDataBuilder
    {
        private readonly LearningMpaAbpDbContext _context;
        private readonly int _tenantId;

        public TestDataBuilder(LearningMpaAbpDbContext context, int tenantId)
        {
            _context = context;
            _tenantId = tenantId;
        }

        public void Create()
        {
            _context.DisableAllFilters();

            //new TestUserBuilder(_context,_tenantId).Create();
            //new TestTasksBuilder(_context,_tenantId).Create();

            _context.SaveChanges();
        }
    }
}

然後修改我們的測試基類XxxTestBase,在建構函式呼叫我們新建的TestDataBuilderCreate()方法。new TestDataBuilder(context, 1).Create();,如下圖:

1240

3.4.2. 建立Task測試資料

建立TestTasksBuilder,如下:(注意,需要修改下類中的_context型別為你自己專案對應的DbContext)

namespace LearningMpaAbp.Tests.TestDatas
{
    public class TestTasksBuilder
    {
        private readonly LearningMpaAbpDbContext _context;
        private readonly int _tenantId;

        public TestTasksBuilder(LearningMpaAbpDbContext context, int tenantId)
        {
            _context = context;
            _tenantId = tenantId;
        }

        public void Create()
        {
            for (int i = 0; i < 8; i++)
            {
                var task = new Task()
                {
                    Title = "TestTask" + i,
                    Description = "Test Task " + i,
                    CreationTime = DateTime.Now,
                    State = (TaskState)new Random().Next(0, 1)
                };
                _context.Tasks.Add(task);
            }
            
        }
    }
}

然後再在TestDataBuild中呼叫該類的Create()的方法即可。
new TestTasksBuilder(_context,_tenantId).Create();

3.5. Run the test(單元測試跑起來)

UT Passed

喜聞樂見的綠色,單元測試通過。

3.6. 完善測試用例

單元測試中我們僅僅測試Happy Path是遠遠不夠的。因為畢竟我們只是測試了正常的正確場景。為了提高單元測試的覆蓋度,我們應該針對程式碼可能出現的異常問題進行測試。
還拿我們剛剛的CreateTask方法為例,其中第二步有一個驗證許可權操作,當使用者沒有許可權的時候,Task應該不能建立並丟擲異常。那我們就針對無許可權的場景補充一個單元測試吧。

3.6.1. 預置資料

無許可權簡單,直接建立一個新使用者登入就ok了。但為了使用者複用,我們還是在種子資料中預置測試使用者吧。

回到我們的TestDatas目錄,建立TestUserBuilder,來預置測試使用者。

namespace LearningMpaAbp.Tests.TestDatas
{
    /// <summary>
    /// 預置測試使用者(無許可權)
    /// </summary>
    public class TestUserBuilder
    {
        private readonly LearningMpaAbpDbContext _context;
        private readonly int _tenantId;

        public TestUserBuilder(LearningMpaAbpDbContext context, int tenantId)
        {
            _context = context;
            _tenantId = tenantId;
        }

        public void Create()
        {
            var testUser =
                _context.Users.FirstOrDefault(u => u.TenantId == _tenantId && u.UserName == "TestUser");
            if (testUser == null)
            {
                testUser = new User
                {
                    TenantId = _tenantId,
                    UserName = "TestUser",
                    Name = "Test User",
                    Surname = "Test",
                    EmailAddress = "test@defaulttenant.com",
                    Password = User.DefaultPassword,
                    IsEmailConfirmed = true,
                    IsActive = true
                };

                _context.Users.Add(testUser);
            }

        }
    }
}

然後再在TestDataBuild中呼叫該類的Create()的方法即可。
new TestUserBuilder(_context,_tenantId).Create();

3.6.2. 完善單元測試

/// <summary>
/// 若沒有分配任務給他人的許可權,建立的任務指定給他人,則任務建立不成功。
/// </summary>
[Fact]
public void Should_Not_Create_New_Order_AssignToOrther_WithoutPermission()
{
    //Arrange
    LoginAsTenant(Tenant.DefaultTenantName, "TestUser");

    //獲取admin使用者
    var adminUser = UsingDbContext(ctx => ctx.Users.FirstOrDefault(u => u.UserName == User.AdminUserName));

    var newTask = new CreateTaskInput()
    {
        Title = "Test Task",
        Description = "Test Task",
        State = TaskState.Open,
        AssignedPersonId = adminUser.Id //TestUser建立Task並分配給Admin
    };

    //Act,Assert
    Assert.Throws<AbpAuthorizationException>(() => _taskAppService.CreateTask(newTask));

}

當使用者無許可權時,將丟擲Abp封裝的AbpAuthorizationException(未授權異常)。

UT Passed

單元測試用例,就講這兩個,剩下的自己動手完善吧。原始碼中已經覆蓋測試,可供參考。

1240

4. 總結

這篇文章中主要梳理了Abp中如何進行單元測試,以及依賴的xUnit、Effort、Shouldly框架的用法。並基於以上內容的總結,進行了單元測試的實戰演練。
相信看完此篇文章的總結,對你在Abp中進行單元測試,有所裨益。

相關文章