返回總目錄《一步一步使用ABP框架搭建正式項目系列教程》
在這篇博客中,我們來說說基於ABP項目的單元測試。說到單元測試(Unit Test),估計很多人只有在上《軟件工程》這門課時才接觸過這個概念,平時寫代碼基本不寫測試的,測試的唯一辦法就是代碼寫完後跑一遍,看看符不符合預期的效果,如果符合就算完成任務了。但是,在大公司或者項目比較大(比如開發一個框架)的時候,單元測試很重要,它是保證軟件質量的一個重要指標。
在這篇博客中,我會在同一個解決方案中創建一個測試項目,而不是另外創建一個新的解決方案。解決方案的結構如下所示:
我將會測試該項目的應用服務,包括LcErp.Application,LcErp.Core,LcErp.EntityFramework子項目。至於如何使用ABP框架搭建項目,您可以參考之前的文章,本篇單講測試話題。
如果你是用ABP啟動模板創建的項目,那麼它會自動創建測試項目的,否則,你可以手動創建一個測試項目。比如,我這裡創建了一個叫做LcErp.Tests的類庫項目,它位於Tests文件夾下。如果你是手動添加的類庫項目,請添加下面的nuget包:
當我們添加了這些包之後,它們的依賴包也會自動添加到項目中。最後,我們要將LcErp.Application,LcErp.Core,LcErp.EntityFramework的引用添加到LcErp項目中,因為我們要測試這些項目。
為了使創建測試類更簡單,我們要先創建一個基類,該基類准備了一個偽造的數據庫連接:
/// <summary>
/// 這是我們所有測試類的基類。
/// 它准備了ABP系統,模塊和一個偽造的內存數據庫。
/// 具有初始數據的種子數據庫。
/// 提供了容易使用的方法<see cref="LcErpDbContext"/>
/// </summary>
public abstract class AppTestBase : AbpIntegratedTestBase
{
protected AppTestBase()
{
//Seed initial data
UsingDbContext(context =>
{
new InitialDbBuilder(context).Create();
new TestDataBuilder(context).Create();
});
LoginAsDefaultTenantAdmin();
}
protected override void PreInitialize()
{
base.PreInitialize();
//Fake DbConnection using Effort!
LocalIocManager.IocContainer.Register(
Component.For<DbConnection>()
.UsingFactoryMethod(DbConnectionFactory.CreateTransient)
.LifestyleSingleton()
);
}
protected override void AddModules(ITypeList<AbpModule> modules)
{
base.AddModules(modules);
//Adding testing modules. Depended modules of these modules are automatically added.
modules.Add<LcErpTestModule>();
}
#region UsingDbContext
protected void UsingDbContext(Action<LcErpDbContext> action)
{
using (var context = LocalIocManager.Resolve<LcErpDbContext>())
{
context.DisableAllFilters();
action(context);
context.SaveChanges();
}
}
protected async Task UsingDbContextAsync(Action<LcErpDbContext> action)
{
using (var context = LocalIocManager.Resolve<LcErpDbContext>())
{
context.DisableAllFilters();
action(context);
await context.SaveChangesAsync();
}
}
protected T UsingDbContext<T>(Func<LcErpDbContext, T> func)
{
T result;
using (var context = LocalIocManager.Resolve<LcErpDbContext>())
{
context.DisableAllFilters();
result = func(context);
context.SaveChanges();
}
return result;
}
protected async Task<T> UsingDbContextAsync<T>(Func<LcErpDbContext, Task<T>> func)
{
T result;
using (var context = LocalIocManager.Resolve<LcErpDbContext>())
{
context.DisableAllFilters();
result = await func(context);
await context.SaveChangesAsync();
}
return result;
}
#endregion
......這裡省略其他方法...
該基類繼承了AbpIntegratedTestBase,它是一個初始化了ABP系統的基類,定義了protected IIocManager LocalIocManager { get; }
。每個測試都會使用這個專用的IIocManager。因此,測試之間是相互隔離的。
我們重寫了AddModules方法來添加我們想要測試的模塊(依賴的模塊會自動添加)。
在PreInitialize中,我們使用Effort將 DbConnection注冊到依賴注入系統中,注冊類型為Singleton。因此,即使我們在相同的測試中創建了不止一個DbContext,也會在一個測試中使用相同的數據庫(和連接)。為了使用該內存數據庫,LcErp必須有一個獲取DbConnection的構造函數。因此,數據庫上下文LcErp類中的構造函數會多一個,如下:
/* This constructor is used in tests to pass a fake/mock connection.
*/
public LcErpDbContext(DbConnection dbConnection)
: base(dbConnection, true)
{
}
在AppTestBase的構造函數中,我們也在數據庫中創建了一個初始化數據(initial data)。這是很重要的,因為一些測試要求數據庫中存在的數據。InitialDbBuilder類填充數據庫的內容如下(詳細信息可自行查看項目):
public class InitialDbBuilder
{
private readonly LcErpDbContext _context;
public InitialDbBuilder(LcErpDbContext context)
{
_context = context;
}
public void Create()
{
_context.DisableAllFilters();
new DefaultEditionCreator(_context).Create();
new DefaultLanguagesCreator(_context).Create();
new DefaultTenantRoleAndUserCreator(_context).Create();
new DefaultSettingsCreator(_context).Create();
_context.SaveChanges();
}
}
AppTestBase的UsingDbContext方法使得當需要直接使用DbContext連接數據庫時創建DbContext更容易。在構造函數中我們使用了它,接下來我們將會在測試中看到如何使用它。
我們所有的測試類都會從AppTestBase繼承。因此,所有的測試都會通過初始化ABP啟動,使用一個具有初始化數據的偽造數據庫。為使測試更容易,我們也可以給這個基類添加通用的幫助方法。
接下來,我們正式創建第一個單元測試。下面的ProductionOrderAppService類中有一個CreateOrder方法,定義如下:
public class ProductionOrderAppService : LcErpAppServiceBase, IProductionOrderAppService
{
private readonly IRepository<Order> _orderRepository;
public ProductionOrderAppService(IRepository<Order> orderRepository)
{
_orderRepository = orderRepository;
}
public void CreateOrder(CreateOrderInput input)
{
var order = input.MapTo<Order>();//將dto對象映射為實體對象
_orderRepository.Insert(order);
}
......其他方法
}
一般來說,單元測試中,測試類的依賴是假的(通過使用一些模仿框架如Moq和NSubstitute來創建偽造的實現)。這使得單元測試更加困難,特別是當依賴逐漸增多時。
我們這裡不會這樣處理,因為我們使用了依賴注入,所有的依賴會通過具有真實實現的依賴注入自動填充,而不是偽造。我們偽造的東西只有數據庫。實際上,這是一個集成測試,因為它不僅測試了ProductionOrderAppService,還測試了倉儲,甚至我們測試了驗證,工作單元和ABP的其他基礎設施。這是非常具有價值的,因為我們正在更加真實地測試這個應用程序。
現在,我們開始創建第一個測試來測試CreateOrder 方法。
public class ProductionOrderAppService_Tests:AppTestBase
{
private readonly IProductionOrderAppService _orderAppService;
public ProductionOrderAppService_Tests()
{
//創建被測試的類(SUT-Software Under Test[被測系統])
_orderAppService = LocalIocManager.Resolve<IProductionOrderAppService>();
}
[Fact]
public void Should_Create_New_Order()
{
//准備測試
var initialCount = UsingDbContext(ctx => ctx.Orders.Count());
//運行被測系統
_orderAppService.CreateOrder(new CreateOrderInput
{
Amount = 10,
CustomerId = 10,
OrderId = "abc",
OrderrDateTime = DateTime.Now,
OrderUserId = 10,
Sum = 10,
Remark = "測試一"
});
_orderAppService.CreateOrder(new CreateOrderInput
{
OrderId = "efd",
Remark = "測試二"
});
//校驗結果
UsingDbContext(ctx =>
{
ctx.Orders.Count().ShouldBe(initialCount+2);
ctx.Orders.FirstOrDefault(o=>o.Remark=="測試一").ShouldNotBe(null);
var order2 = ctx.Orders.FirstOrDefault(o => o.OrderId == "efd");
order2.ShouldNotBe(null);
order2.Remark.ShouldBe("測試二");
//Assert.Equal("測試二",order2.Remark);
});
}
}
正如之前所講,我們繼承了AppTestBase這個測試基類。在一個單元測試中,我們首先應該創建被測試的對象。在上面的構造函數中,使用LocalIocManager(依賴注入管理者)來創建了一個 IProductionOrderAppService(因為ProductionOrderAppService實現了IProductionOrderAppService,所以會創建ProductionOrderAppService)。通過這種方法,就避免了創建偽造的依賴實現。
Should_Create_New_Order是測試方法。它使用了xUnit的 Fact特性進行修飾。這樣,xUnit就理解了這是個測試方法,然後運行這個方法。
在一個測試方法中,我們一般遵循包含三步驟的AAA模式:
在Should_Create_New_Order方法中,我們創建了2個訂單,因此,我們的三步驟是:
這裡,我們使用了UsingDbContext方法來直接使用DbContext。如果測試成功,我們就知道了當輸入合理時,CreateOrder方法可以創建訂單。
要運行測試,我們要打開VS的測試管理器,選擇測試->窗口->測試資源管理器(如果沒有找到剛才創建的測試類和方法,先保存生成一下):
選中剛才創建的測試,右鍵“運行該測試”:
如上所示,我們的第一個單元測試通過了。恭喜恭喜!如果測試或者測試代碼不正確,那麼測試會失敗!
假設我注釋掉第二個訂單對象的Remark的賦值,然後再次運行測試,結果會失敗:
Shouldly類庫使得失敗信息更加清晰,也使得編寫斷言更加容易。比較一下xUnit的 Assert.Equal和 Shouldly的 ShouldBe擴展方法:
order2.Remark.ShouldBe("測試二");//使用Shouldly
Assert.Equal("測試二",order2.Remark);//使用xUnit的Assert
第一個讀寫更簡單且自然,並且Shouldly提供了很多其他的擴展方法來方便我們的編程,請查看Shouldly相應的文檔。
我想為CreateOrder方法再創建一個測試方法,但是,這次輸入不合法:
[Fact]
public void Should_Not_Create_New_Order_WithoutOrderId()
{
Assert.Throws<AbpValidationException>(() => _orderAppService.CreateOrder(new CreateOrderInput
{
Remark = "該訂單的OrderId沒有賦值"
}));
}
如果沒有為創建的訂單的OrderId屬性賦值,那麼我期望CreateOrder會拋異常。因為在CreateOrderInput DTO類中,OrderId被標記為 Required,所以,如果CreateOrder拋出異常,測試就會成功,否則失敗。注意:驗證輸入和拋異常是ABP基礎設施處理的。
測試結果如下:
下面在測試方法中使用倉儲,改造上面創建訂單的測試方法:
[Fact]
public void Should_Create_New_Order()
{
//准備測試
//var initialCount = UsingDbContext(ctx => ctx.Orders.Count());
//使用倉儲代替DbContext
var orderRepo = LocalIocManager.Resolve<IRepository<Order>>();
//運行被測系統
_orderAppService.CreateOrder(new CreateOrderInput
{
Amount = 10,
CustomerId = 10,
OrderId = "abc",
OrderrDateTime = DateTime.Now,
OrderUserId = 10,
Sum = 10,
Remark = "測試一"
});
_orderAppService.CreateOrder(new CreateOrderInput
{
OrderId = "efd",
Remark = "測試二"
});
//校驗結果
//UsingDbContext(ctx =>
//{
// ctx.Orders.Count().ShouldBe(initialCount+2);
// ctx.Orders.FirstOrDefault(o=>o.Remark=="測試一").ShouldNotBe(null);
// var order2 = ctx.Orders.FirstOrDefault(o => o.OrderId == "efd");
// order2.ShouldNotBe(null);
// order2.Remark.ShouldBe("測試二");
// //Assert.Equal("測試二",order2.Remark);
//});
orderRepo.GetAll().Count().ShouldBe(2);
}
我們也可以使用xUnit測試異步方法。比如,ProductionOrderAppService的GetAllOrders方法是異步方法,那麼測試方法也應該是異步的(async)。
[Fact]
public async Task Should_Get_All_People()
{
var output = await _orderAppService.GetAllPeople();
output.People.Count.ShouldBe(4);
}
這篇文章中,我只想展示一下基於ABP框架搭建的項目的測試。ABP提供了一個很好的基礎設施來實現測試驅動開發(TDD),或者為你的應用程序簡單地創建一些單元測試或集成測試。
Effort類庫提供了一個偽造的數據庫,它和EF協作地很好。只要你使用了EF或者Linq來執行數據庫操作,它就會工作。如果你使用了存儲過程,並想測試它,那麼Effort不支持。對於這些情況,建議使用LocalDB。