.NET Core 3.0 單元測試與 Asp.Net Core 3.0 整合測試
單元測試與整合測試
測試必要性說明
相信大家在看到單元測試與整合測試這個標題時,會有很多感慨,我們無數次的在實踐中提到要做單元測試、整合測試,但是大多數專案都沒有做或者僅建了專案檔案。這裡有客觀原因,已經接近交付日期了,我們沒時間做白盒測試了。也有主觀原因,面對業務複雜的程式碼我們不知道如何入手做單元測試,不如就留給黑盒測試吧。但是,當我們的程式碼無法進行單元測試的時候,往往就是程式碼開始散發出壞味道的時候。長此以往,將欠下技術債務。在實踐過程中,技術債務常常會存在,關鍵在於何時償還,如何償還。
上圖說明了隨著時間的推移開發/維護難度的變化。
測試框架選擇
在 .NET Core 中,提供了 xUnit 、NUnit 、 MSTest 三種單元測試框架。
MSTest | UNnit | xUnit | 說明 | 提示 |
---|---|---|---|---|
[TestMethod] | [Test] | [Fact] | 標記一個測試方法 | |
[TestClass] | [TestFixture] | n/a | 標記一個 Class 為測試類,xUnit 不需要標記特性,它將查詢程式集下所有 Public 的類 | |
[ExpectedException] | [ExpectedException] | Assert.Throws 或者 Record.Exception | xUnit 去掉了 ExpectedException 特性,支援 Assert.Throws | |
[TestInitialize] | [SetUp] | Constructor | 我們認為使用 [SetUp] 通常來說不好。但是,你可以實現一個無參構造器直接替換 [SetUp]。 | 有時我們會在多個測試方法中用到相同的變數,熟悉重構的我們會提取公共變數,並在構造器中初始化。但是,這裡我要強調的是:在測試中,不要提取公共變數,這會破壞每個測試用例的隔離性以及單一職責原則。 |
[TestCleanup] | [TearDown] | IDisposable.Dispose | 我們認為使用 [TearDown] 通常來說不好。但是你可以實現 IDisposable.Dispose 以替換。 | [TearDown] 和 [SetUp] 通常成對出現,在 [SetUp] 中初始化一些變數,則在 [TearDown] 中銷燬這些變數。 |
[ClassInitialize] | [TestFixtureSetUp] | IClassFixture< T > | 共用前置類 | 這裡 IClassFixture< T > 替換了 IUseFixture< T > ,參考 |
[ClassCleanup] | [TestFixtureTearDown] | IClassFixture< T > | 共用後置類 | 同上 |
[Ignore] | [Ignore] | [Fact(Skip="reason")] | 在 [Fact] 特性中設定 Skip 引數以臨時跳過測試 | |
[Timeout] | [Timeout] | [Fact(Timeout=n)] | 在 [Fact] 特性中設定一個 Timeout 引數,當允許時間太長時引起測試失敗。注意,xUnit 的單位時毫秒。 | |
[DataSource] | n/a | [Theory], [XxxData] | Theory(資料驅動測試),表示執行相同程式碼,但具有不同輸入引數的測試套件 | 這個特性可以幫助我們少寫很多程式碼。 |
以上寫了 MSTest 、UNnit 、 xUnit 的特性以及比較,可以看出 xUnit 在使用上相對其它兩個框架來說提供更多的便利性。但是這裡最終實現還是看個人習慣以選擇。
單元測試
新建單元測試專案
新建 Class
新增測試方法
/// <summary> /// 新增地址 /// </summary> /// <returns></returns> [Fact] public async Task Add_Address_ReturnZero() { DbContextOptions<AddressContext> options = new DbContextOptionsBuilder<AddressContext>().UseInMemoryDatabase("Add_Address_Database").Options; var addressContext = new AddressContext(options); var createAddress = new AddressCreateDto { City = "昆明", County = "五華區", Province = "雲南省" }; var stubAddressRepository = new Mock<IRepository<Domain.Address>>(); var stubProvinceRepository = new Mock<IRepository<Province>>(); var addressUnitOfWork = new AddressUnitOfWork<AddressContext>(addressContext); var stubAddressService = new AddressServiceImpl.AddressServiceImpl(stubAddressRepository.Object, stubProvinceRepository.Object, addressUnitOfWork); await stubAddressService.CreateAddressAsync(createAddress); int addressAmountActual = await addressContext.Addresses.CountAsync(); Assert.Equal(1, addressAmountActual); }
- 測試方法的名字包含了測試目的、測試場景以及預期行為。
- UseInMemoryDatabase 指明使用記憶體資料庫。
- 建立 createAddress 物件。
- 建立 Stub 。在單元測試中常常會提到幾個概念 Stub , Mock 和 Fake ,那麼在應用中我們該如何選擇呢?
- Fake - Fake 通常被用於描述 Mock 或 Stub ,如何判斷它是 Stub 還是 Mock 依賴於使用上下文,換句話說,Fake 即是 Stub 也是 Mock 。
- Stub - Stub 是系統中現有依賴項的可控替代品。通過使用 Stub ,你可以不用處理依賴直接測試你的程式碼。預設情況下, 偽造物件以stub 開頭。
- Mock - Mock 物件是系統中的偽造物件,它決定單元測試是否通過或失敗。Mock 會以 Fake 開頭,直到被斷言為止。
- Moq4 ,使用 Moq4 模擬我們在專案中依賴物件。參考
開啟檢視 -> 測試資源管理器。
點選執行,得到測試結果。
至此,一個單元測試結束。
整合測試
整合測試確保應用的元件功能在包含應用的基礎支援下是正確的,例如:資料庫、檔案系統、網路等。
新建整合測試專案。
新增工具類 Utilities 。
using System.Collections.Generic; using AddressEFRepository; namespace Address.IntegrationTest { public static class Utilities { public static void InitializeDbForTests(AddressContext db) { List<Domain.Address> addresses = GetSeedingAddresses(); db.Addresses.AddRange(addresses); db.SaveChanges(); } public static void ReinitializeDbForTests(AddressContext db) { db.Addresses.RemoveRange(db.Addresses); InitializeDbForTests(db); } public static List<Domain.Address> GetSeedingAddresses() { return new List<Domain.Address> { new Domain.Address { City = "貴陽", County = "測試縣", Province = "貴州省" }, new Domain.Address { City = "昆明市", County = "武定縣", Province = "雲南省" }, new Domain.Address { City = "昆明市", County = "五華區", Province = "雲南省" } }; } } }
新增 CustomWebApplicationFactory 類,
using System; using System.IO; using System.Linq; using AddressEFRepository; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Address.IntegrationTest { public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class { protected override void ConfigureWebHost(IWebHostBuilder builder) { string projectDir = Directory.GetCurrentDirectory(); string configPath = Path.Combine(projectDir, "appsettings.json"); builder.ConfigureAppConfiguration((context, conf) => { conf.AddJsonFile(configPath); }); builder.ConfigureServices(services => { ServiceDescriptor descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AddressContext>)); if (descriptor != null) { services.Remove(descriptor); } services.AddDbContextPool<AddressContext>((options, context) => { //var configuration = options.GetRequiredService<IConfiguration>(); //string connectionString = configuration.GetConnectionString("TestAddressDb"); //context.UseMySql(connectionString); context.UseInMemoryDatabase("InMemoryDbForTesting"); }); // Build the service provider. ServiceProvider sp = services.BuildServiceProvider(); // Create a scope to obtain a reference to the database // context (ApplicationDbContext). using IServiceScope scope = sp.CreateScope(); IServiceProvider scopedServices = scope.ServiceProvider; var db = scopedServices.GetRequiredService<AddressContext>(); var logger = scopedServices.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>(); // Ensure the database is created. db.Database.EnsureCreated(); try { // Seed the database with test data. Utilities.ReinitializeDbForTests(db); } catch (Exception ex) { logger.LogError(ex, "An error occurred seeding the " + "database with test messages. Error: {Message}", ex.Message); } }); } } }
- 這裡為什麼要新增 CustomWebApplicationFactory 呢?
WebApplicationFactory 是用於在記憶體中引導應用程式進行端到端功能測試的工廠。通過引入自定義 CustomWebApplicationFactory 類重寫 ConfigureWebHost 方法,我們可以重寫我們在 StartUp 中定義的內容,換句話說我們可以在測試環境中使用正式環境的配置,同時可以重寫,例如:資料庫配置,資料初始化等等。 - 如何準備測試資料?
我們可以使用資料種子的方式加入資料,資料種子可以針對每個整合測試做資料準備。 - 除了記憶體資料庫,還可以使用其他資料庫進行測試嗎?
可以。
- 這裡為什麼要新增 CustomWebApplicationFactory 呢?
新增整合測試 AddressControllerIntegrationTest 類。
using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Address.Api; using Microsoft.AspNetCore.Mvc.Testing; using Newtonsoft.Json; using Xunit; namespace Address.IntegrationTest { public class AddressControllerIntegrationTest : IClassFixture<CustomWebApplicationFactory<Startup>> { public AddressControllerIntegrationTest(CustomWebApplicationFactory<Startup> factory) { _client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); } private readonly HttpClient _client; [Fact] public async Task Get_AllAddressAndRetrieveAddress() { const string allAddressUri = "/api/Address/GetAll"; HttpResponseMessage allAddressesHttpResponse = await _client.GetAsync(allAddressUri); allAddressesHttpResponse.EnsureSuccessStatusCode(); string allAddressStringResponse = await allAddressesHttpResponse.Content.ReadAsStringAsync(); var addresses = JsonConvert.DeserializeObject<IList<AddressDto.AddressDto>>(allAddressStringResponse); Assert.Equal(3, addresses.Count); AddressDto.AddressDto address = addresses.First(); string retrieveUri = $"/api/Address/Retrieve?id={address.ID}"; HttpResponseMessage addressHttpResponse = await _client.GetAsync(retrieveUri); // Must be successful. addressHttpResponse.EnsureSuccessStatusCode(); // Deserialize and examine results. string addressStringResponse = await addressHttpResponse.Content.ReadAsStringAsync(); var addressResult = JsonConvert.DeserializeObject<AddressDto.AddressDto>(addressStringResponse); Assert.Equal(address.ID, addressResult.ID); Assert.Equal(address.Province, addressResult.Province); Assert.Equal(address.City, addressResult.City); Assert.Equal(address.County, addressResult.County); } } }
在測試資源管理器中執行整合測試方法。
結果。
至此,整合測試完成。需要注意的是,整合測試往往耗時比較多,所以建議能使用單元測試時就不要使用整合測試。
總結:當我們寫單元測試時,一般不會同時存在 Stub 和 Mock 兩種模擬物件,當同時出現這兩種物件時,表明單元測試寫的不合理,或者業務寫的太過龐大,同時,我們可以通過單元測試驅動業務程式碼重構。當需要重構時,我們應儘量完成重構,不要留下欠下過多技術債務。整合測試有自身的複雜度存在,我們不要節約時間而打破單一職責原則,否則會引發不可預期後果。為了應對業務修改,我們應該在業務修改以後,進行迴歸測試,迴歸測試主要關注被修改的業務部分,同時測試用例如果有沒要可以重寫,執行整個和修改業務有關的測試用例集。
原始碼地