上一篇使用 [Unit Test] 小技巧-利用 Header 提高 Web API 可測試性,這篇的作法會讓被測目標物的職責變多,我們可以改用 Autofac 來改善這問題
開發環境
- VS 2019
- .NET Framework 4.7.2
新增一個單元測試專案,名為Server.UnitTest,從 Nuget 安裝以下套件
Install-Package Microsoft.AspNet.WebApi.OwinSelfHost -Version 5.2.7
Install-Package Microsoft.Owin.Diagnostics -Version 4.0.1
Install-Package Microsoft.Owin.Host.SystemWeb -Version 4.0.1
Install-Package Autofac -Version 4.9.4
Install-Package Autofac.WebApi2 -Version 4.3.0
Install-Package NSubstitute -Version 4.2.1
使用 Autofac 注入
Repository 如下
public interface IProductRepository { string GetName(); } public class ProductRepository : IProductRepository { public string GetName() { return "Product"; } } public class Product2Repository : IProductRepository { public string GetName() { return "Product2"; } }
在 DefaultController 的建構函數依賴 IProductRepository。
預設的情況下 ApiController 還要有一個無參數的建構函數,不然它會無法被建立,因為等一下要用 Autofac 注入建構函數,所以先寫這樣就好。
public class DefaultController : ApiController { public IProductRepository ProductRepository { get; set; } public DefaultController(IProductRepository productRepository) { this.ProductRepository = productRepository; } // GET api/default/ public string Get() { return this.ProductRepository.GetName(); } }
Autofac 設定
- 註冊所有 ApiController:
builder.RegisterApiControllers(assembly); - 掃描組件內實作 IProductRepository 的物件:
builder.RegisterAssemblyTypes(assembly)
.As<IProductRepository>()
.AsImplementedInterfaces()
.Keyed<IProductRepository>(k => k.Name); - HttpConfiguration 使用 Autofac 的 DependencyResolver:
var dependencyResolver = new AutofacWebApiDependencyResolver(container);
this.HttpConfig.DependencyResolver = dependencyResolver;
public class AutofacManager { private readonly HttpConfiguration HttpConfig; private ContainerBuilder Builder; private IContainer Container; public AutofacManager(HttpConfiguration httpConfig) { this.HttpConfig = httpConfig; } public ContainerBuilder CreateApiBuilder() { this.Builder = new ContainerBuilder(); var builder = this.Builder; var assembly = Assembly.GetExecutingAssembly(); builder.RegisterApiControllers(assembly); builder.RegisterAssemblyTypes(assembly) .As<IProductRepository>() .AsImplementedInterfaces() .Keyed<IProductRepository>(k => k.Name); return builder; } public IContainer CreateContainer(ContainerBuilder builder) { this.Container = builder.Build(); var container = this.Container; var dependencyResolver = new AutofacWebApiDependencyResolver(container); this.HttpConfig.DependencyResolver = dependencyResolver; return container; } }
Startup.Configuration 裡面設定 Route 以及 Autofac
public class Startup { public static AutofacManager AutofacManager { get; internal set; } public void Configuration(IAppBuilder app) { var config = new HttpConfiguration(); ConfigureRoute(config); ConfigureAutofac(config); app.UseWebApi(config); } private static void ConfigureAutofac(HttpConfiguration config) { AutofacManager = new AutofacManager(config); var builder = AutofacManager.CreateApiBuilder(); AutofacManager.CreateContainer(builder); } private static void ConfigureRoute(HttpConfiguration config) { config.Routes.MapHttpRoute( "DefaultApi", "api/{controller}/{id}", new {id = RouteParameter.Optional}); } }
來跑個簡單的測試,看看 DefaultController 是否成功的被 Autofac 建立,成功的話會得到綠燈
不知道如何用單元測試+OWIN 測試 Web Api 請看 https://dotblogs.com.tw/yc421206/2019/01/05/webapi_test_via_owin
[TestClass] public class UnitTest1 { private const string HOST_ADDRESS = "http://localhost:9527"; private static IDisposable s_webApp; private static HttpClient s_client; [AssemblyCleanup] public static void AssemblyCleanup() { s_webApp.Dispose(); } [AssemblyInitialize] public static void AssemblyInitialize(TestContext context) { s_webApp = WebApp.Start<Startup>(HOST_ADDRESS); Console.WriteLine("Web API started!"); s_client = new HttpClient(); s_client.BaseAddress = new Uri(HOST_ADDRESS); Console.WriteLine("HttpClient started!"); } [TestMethod] public void When_Call_Get_Should_Be_Product2() { var url = "api/Default"; var response = s_client.GetAsync(url).Result; var result = response.Content.ReadAsAsync<string>().Result; Assert.AreEqual("Product2", result); } }
這個案例我用 NSub 動態建立 ProductRepository,然後替換掉原本 Autofac Container 的 ProductRepository。不會使用 NSub 有興趣的可以參考舊文章 https://dotblogs.com.tw/yc421206/series/1?qq=NSubstitute
- Rebuild Container:
SetProductRepository() - 註冊 ProductRepository Instance:
builder.RegisterInstance(repository).As<IProductRepository>();
[TestMethod] public void Given_ChangeInstance_When_Call_Get_Should_Be_FakeRepository() { var fakeRepository = Substitute.For<IProductRepository>(); fakeRepository.GetName().Returns("Fake Repository"); SetProductRepository(fakeRepository); var url = "api/Default"; var response = s_client.GetAsync(url).Result; var result = response.Content.ReadAsAsync<string>().Result; Assert.AreEqual("Fake Repository", result); } private static void SetProductRepository(IProductRepository repository) { var autofacManager = Startup.AutofacManager; var builder = autofacManager.CreateApiBuilder(); builder.RegisterInstance(repository).As<IProductRepository>(); autofacManager.CreateContainer(builder); }
原本要使用 builder.Update(),發現這個方法已經過時,Autofac 則是建議 Rebuild。
[SuppressMessage("Microsoft.Design", "CA1011:ConsiderPassingBaseTypesAsParameters", Justification = "You can't update any arbitrary context, only containers.")] [Obsolete("Containers should generally be considered immutable. Register all of your dependencies before building/resolving. If you need to change the contents of a container, you technically should rebuild the container. This method may be removed in a future major release.")] public void Update(IContainer container) { Update(container, ContainerBuildOptions.None); }
而且註冊的物件(Registrations )也會越來越多
最後,為了確保 Container 不會因為有案例變更了 ProductRepository 的實例,於是在每一次案例開始之前重建 Container
[TestInitialize] public void TestInitialize() { var autofacManager = Startup.AutofacManager; var builder = autofacManager.CreateApiBuilder(); var container = autofacManager.CreateContainer(builder); }
使用 InstanceUtility 注入
同場加映,如果真的還無法接受 Autofac,咱們也可以讓測試專案能控制 DefaultController 依賴的物件,這次我把它搬到 InstanceUtility,而且只有測試專案能摸的到 InstanceUtility
public class DefaultController : ApiController { // GET api/default/ public string Get() { return InstanceUtility.ProductRepository.GetName(); } } internal class InstanceUtility { private static IProductRepository s_productRepository; public static IProductRepository ProductRepository { get { if (s_productRepository == null) { s_productRepository = new ProductRepository(); } return s_productRepository; } set => s_productRepository = value; } }
InstanceUtility.ProductRepository 是全域使用的物件,為了滿足所有案例,開始跑測試之前,保留它的狀態,結束後還原它,下一個案例才不會壞掉
改變實例:InstanceUtility.ProductRepository = fakeRepository
[TestMethod] public void Given_ChangeInstance_When_Call_Get_Should_Be_FakeRepository2() { var currentRepository = InstanceUtility.ProductRepository; var fakeRepository = Substitute.For<IProductRepository>(); fakeRepository.GetName().Returns("Fake Repository2"); InstanceUtility.ProductRepository = fakeRepository; var url = "api/Default"; var response = s_client.GetAsync(url).Result; var result = response.Content.ReadAsAsync<string>().Result; Assert.AreEqual("Fake Repository2", result); InstanceUtility.ProductRepository = currentRepository; }
用這個方法也可以輕易地改變被測目標物所依賴的物件狀態,不過要小心物件的存取範圍是 internal 還要設定 InternalsVisibleTo 唷
[assembly: InternalsVisibleTo("Server2.UnitTest")]
範例位置:
https://github.com/yaochangyu/sample.dotblog/tree/master/WebAPI/Lab.AutofacDI
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET