您好,登錄后才能下訂單哦!
今天來到了最后的壓軸章節:單元測試
我們已經有了完整的程序結構,現在是時候來對我們的組件做單元測試了。
在UnitTestingWebAPI.Tests類庫上添加UnitTestingWebAPI.Domain, UnitTestingWebAPI.Data, UnitTestingWebAPI.Service和UnitTestingWebAPI.API.Core 同樣要安裝下列的Nuget 包:
Entity Framework
Microsoft.AspNet.WebApi.Core
Microsoft.AspNet.WebApi.Client
Microsoft.AspNet.WebApi.Owin
Microsoft.AspNet.WebApi.SelfHost
Micoroft.Owin
Owin
Micoroft.Owin.Hosting
Micoroft.Owin.Host.HttpListener
Autofac.WebApi2
NUnit
NUnitTestAdapter
從清單中可知,我們將用NUnit 來寫單元測試
Services 單元測試
寫單元測試的第一件事是需要去設置或初始化一些單元測試中要用到的變量,NUnit框架則給要測試的方法添加Setup特性,在任何其他的NUnit測試開始之前,這一方法會先執行,把Services層注入到Controller的構造函數之后的第一件事就是進行單元測試。因此在對WebAPI進行單元測試之前需要仿造Repositories和Service。
在這個例子中會看到如何仿造ArticleService, 并在這個Service的構造函數中注入IArticleRepository和IUnitOfWork,所以我們需要創建兩個"特別的"實例來注入。
ArticleService Constructor
private readonly IArticleRepository articlesRepository; private readonly IUnitOfWork unitOfWork; public ArticleService(IArticleRepository articlesRepository, IUnitOfWork unitOfWork) { this.articlesRepository = articlesRepository; this.unitOfWork = unitOfWork; }
這里的"特別的",是因為這些實例不是真正訪問數據庫的實例.
注意
單元測試必須運行在內存中并且不應該訪問數據庫. 所有核心的方法必須通過像我們的例子中用Mock這樣的框架仿造。這個方式自動的測試會更快些。單元測試最基本的目的是更多的測試組件的行為,而不是真正的結果.
開始測試ArticleService,創建一個ServiceTests的文件并添加下列代碼:
[TestFixture] public class ServicesTests { #region Variables IArticleService _articleService; IArticleRepository _articleRepository; IUnitOfWork _unitOfWork; List<Article> _randomArticles; #endregion #region Setup [SetUp] public void Setup() { _randomArticles = SetupArticles(); _articleRepository = SetupArticleRepository(); _unitOfWork = new Mock<IUnitOfWork>().Object; _articleService = new ArticleService(_articleRepository, _unitOfWork); } public List<Article> SetupArticles() { int _counter = new int(); List<Article> _articles = BloggerInitializer.GetAllArticles(); foreach (Article _article in _articles) _article.ID = ++_counter; return _articles; } public IArticleRepository SetupArticleRepository() { // Init repository var repo = new Mock<IArticleRepository>(); // Setup mocking behavior repo.Setup(r => r.GetAll()).Returns(_randomArticles); repo.Setup(r => r.GetById(It.IsAny<int>())) .Returns(new Func<int, Article>( id => _randomArticles.Find(a => a.ID.Equals(id)))); repo.Setup(r => r.Add(It.IsAny<Article>())) .Callback(new Action<Article>(newArticle => { dynamic maxArticleID = _randomArticles.Last().ID; newArticle.ID = maxArticleID + 1; newArticle.DateCreated = DateTime.Now; _randomArticles.Add(newArticle); })); repo.Setup(r => r.Update(It.IsAny<Article>())) .Callback(new Action<Article>(x => { var oldArticle = _randomArticles.Find(a => a.ID == x.ID); oldArticle.DateEdited = DateTime.Now; oldArticle = x; })); repo.Setup(r => r.Delete(It.IsAny<Article>())) .Callback(new Action<Article>(x => { var _articleToRemove = _randomArticles.Find(a => a.ID == x.ID); if (_articleToRemove != null) _randomArticles.Remove(_articleToRemove); })); // Return mock implementation return repo.Object; } #endregion }
如果你直接copy代碼可能會報錯:
One or more types required to compile a dynaic expression ....
解決辦法:
在Assembiles中添加Microsoft.CSharp.dll
在SetupArticleRepository()方法中我們模仿了_articleRepository的行為,換句話說,當一個特定的方法使用了這個Reporistory的實例,就會得到我們所期待的結果。然后我們在_articleService的構造函數中注入這個實例。我們用下面代碼測試_articleService.GetArticles()的行為是否是我們所期待的.
ServiceShouldReturnAllArticles Test
[Test] public void ServiceShouldReturnAllArticles() { var articles = _articleService.GetArticles(); NUnit.Framework.Assert.That(articles, Is.EqualTo(_randomArticles)); }
編譯項目,運行測試,要確保這個測試變為綠色通過狀態,用同樣的方式創建下面的測試:
Services Test
[Test] public void ServiceShouldReturnRightArticle() { var wcfSecurityArticle = _articleService.GetArticle(2); NUnit.Framework.Assert.That(wcfSecurityArticle, Is.EqualTo(_randomArticles.Find(a => a.Title.Contains("Secure WCF Services")))); } [Test] public void ServiceShouldAddNewArticle() { var _newArticle = new Article() { Author = "Chris Sakellarios", Contents = "If you are an ASP.NET MVC developer, you will certainly..", Title = "URL Rooting in ASP.NET (Web Forms)", URL = "https://chsakell.com/2013/12/15/url-rooting-in-asp-net-web-forms/" }; int _maxArticleIDBeforeAdd = _randomArticles.Max(a => a.ID); _articleService.CreateArticle(_newArticle); NUnit.Framework.Assert.That(_newArticle, Is.EqualTo(_randomArticles.Last())); NUnit.Framework.Assert.That(_maxArticleIDBeforeAdd + 1, Is.EqualTo(_randomArticles.Last().ID)); } [Test] public void ServiceShouldUpdateArticle() { var _firstArticle = _randomArticles.First(); _firstArticle.Title = "OData feat. ASP.NET Web API"; // reversed<img draggable="false" class="emoji" alt="" src="https://s.w.org/p_w_picpaths/core/emoji/2/svg/1f642.svg"> _firstArticle.URL = "http://t.co/fuIbNoc7Zh"; // short link _articleService.UpdateArticle(_firstArticle); NUnit.Framework.Assert.That(_firstArticle.DateEdited, Is.Not.EqualTo(DateTime.MinValue)); NUnit.Framework.Assert.That(_firstArticle.URL, Is.EqualTo("http://t.co/fuIbNoc7Zh")); NUnit.Framework.Assert.That(_firstArticle.ID, Is.EqualTo(1)); // hasn't changed } [Test] public void ServiceShouldDeleteArticle() { int maxID = _randomArticles.Max(a => a.ID); // Before removal var _lastArticle = _randomArticles.Last(); // Remove last article _articleService.DeleteArticle(_lastArticle); NUnit.Framework.Assert.That(maxID, Is.GreaterThan(_randomArticles.Max(a => a.ID))); // Max reduced by 1 }
WebAPI 控制器單元測試
在熟悉了偽造Services行為測試的基礎上,來進行WebAPI控制器的單元測試。
第一件事:設置在測試中需要的變量。
用下面的代碼創建用于測試的控制器:
[TestFixture] public class ControllerTests { #region Variables IArticleService _articleService; IArticleRepository _articleRepository; IUnitOfWork _unitOfWork; List<Article> _randomArticles; #endregion #region Setup [SetUp] public void Setup() { _randomArticles = SetupArticles(); _articleRepository = SetupArticleRepository(); _unitOfWork = new Mock<IUnitOfWork>().Object; _articleService = new ArticleService(_articleRepository, _unitOfWork); } /// <summary> /// Setup Articles /// </summary> /// <returns></returns> public List<Article> SetupArticles() { int _counter = new int(); List<Article> _articles = BloggerInitializer.GetAllArticles(); foreach (Article _article in _articles) _article.ID = ++_counter; return _articles; } /// <summary> /// Emulate _articleRepository behavior /// </summary> /// <returns></returns> public IArticleRepository SetupArticleRepository() { // Init repository var repo = new Mock<IArticleRepository>(); // Get all articles repo.Setup(r => r.GetAll()).Returns(_randomArticles); // Get Article by id repo.Setup(r => r.GetById(It.IsAny<int>())) .Returns(new Func<int, Article>( id => _randomArticles.Find(a => a.ID.Equals(id)))); // Add Article repo.Setup(r => r.Add(It.IsAny<Article>())) .Callback(new Action<Article>(newArticle => { dynamic maxArticleID = _randomArticles.Last().ID; newArticle.ID = maxArticleID + 1; newArticle.DateCreated = DateTime.Now; _randomArticles.Add(newArticle); })); // Update Article repo.Setup(r => r.Update(It.IsAny<Article>())) .Callback(new Action<Article>(x => { var oldArticle = _randomArticles.Find(a => a.ID == x.ID); oldArticle.DateEdited = DateTime.Now; oldArticle.URL = x.URL; oldArticle.Title = x.Title; oldArticle.Contents = x.Contents; oldArticle.BlogID = x.BlogID; })); // Delete Article repo.Setup(r => r.Delete(It.IsAny<Article>())) .Callback(new Action<Article>(x => { var _articleToRemove = _randomArticles.Find(a => a.ID == x.ID); if (_articleToRemove != null) _randomArticles.Remove(_articleToRemove); })); // Return mock implementation return repo.Object; } #endregion }
控制器的類和其它的類一樣,所以我們可以分開各自測試。下面測試_articlesController.GetArticles(),看看是否能返回所有的文章。
[Test] public void ControlerShouldReturnAllArticles() { var _articlesController = new ArticlesController(_articleService); var result = _articlesController.GetArticles(); CollectionAssert.AreEqual(result, _randomArticles); }
請確保測試已綠色通過,我們初始化了3條數據,用_articlesController.GetArticle(3)測試看看能否返回最后一條。
[Test] public void ControlerShouldReturnLastArticle() { var _articlesController = new ArticlesController(_articleService); var result = _articlesController.GetArticle(3) as OkNegotiatedContentResult<Article>; Assert.IsNotNull(result); Assert.AreEqual(result.Content.Title, _randomArticles.Last().Title); }
測試一個無效的Update操作,必須失敗并且返回一個BadRequestResult, 重新調用設置在_articleRepository上的Update操作。
repo.Setup(r => r.Update(It.IsAny<Article>())) .Callback(new Action<Article>(x => { var oldArticle = _randomArticles.Find(a => a.ID == x.ID); oldArticle.DateEdited = DateTime.Now; oldArticle.URL = x.URL; oldArticle.Title = x.Title; oldArticle.Contents = x.Contents; oldArticle.BlogID = x.BlogID; }));
所以,當我們測試一個不存在的文章就應該返回失敗信息。
[Test] public void ControlerShouldPutReturnBadRequestResult() { var _articlesController = new ArticlesController(_articleService) { Configuration = new HttpConfiguration(), Request = new HttpRequestMessage { Method = HttpMethod.Put, RequestUri = new Uri("http://localhost/api/articles/-1") } }; var badresult = _articlesController.PutArticle(-1, new Article() { Title = "Unknown Article" }); Assert.That(badresult, Is.TypeOf<BadRequestResult>()); }
通過分別成功更新第一篇文章、發表一篇新文章、發布失敗一篇文章來完成我們的單元測試。
Controller 單元測試
[Test] public void ControlerShouldPutUpdateFirstArticle() { var _articlesController = new ArticlesController(_articleService) { Configuration = new HttpConfiguration(), Request = new HttpRequestMessage { Method = HttpMethod.Put, RequestUri = new Uri("http://localhost/api/articles/1") } }; IHttpActionResult updateResult = _articlesController.PutArticle(1, new Article() { ID = 1, Title = "ASP.NET Web API feat. OData", URL = "http://t.co/fuIbNoc7Zh", Contents = @"OData is an open standard protocol.." }) as IHttpActionResult; Assert.That(updateResult, Is.TypeOf<StatusCodeResult>()); StatusCodeResult statusCodeResult = updateResult as StatusCodeResult; Assert.That(statusCodeResult.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); Assert.That(_randomArticles.First().URL, Is.EqualTo("http://t.co/fuIbNoc7Zh")); } [Test] public void ControlerShouldPostNewArticle() { var article = new Article { Title = "Web API Unit Testing", URL = "https://chsakell.com/web-api-unit-testing", Author = "Chris Sakellarios", DateCreated = DateTime.Now, Contents = "Unit testing Web API.." }; var _articlesController = new ArticlesController(_articleService) { Configuration = new HttpConfiguration(), Request = new HttpRequestMessage { Method = HttpMethod.Post, RequestUri = new Uri("http://localhost/api/articles") } }; _articlesController.Configuration.MapHttpAttributeRoutes(); _articlesController.Configuration.EnsureInitialized(); _articlesController.RequestContext.RouteData = new HttpRouteData( new HttpRoute(), new HttpRouteValueDictionary { { "_articlesController", "Articles" } }); var result = _articlesController.PostArticle(article) as CreatedAtRouteNegotiatedContentResult<Article>; Assert.That(result.RouteName, Is.EqualTo("DefaultApi")); Assert.That(result.Content.ID, Is.EqualTo(result.RouteValues["id"])); Assert.That(result.Content.ID, Is.EqualTo(_randomArticles.Max(a => a.ID))); } [Test] public void ControlerShouldNotPostNewArticle() { var article = new Article { Title = "Web API Unit Testing", URL = "https://chsakell.com/web-api-unit-testing", Author = "Chris Sakellarios", DateCreated = DateTime.Now, Contents = null }; var _articlesController = new ArticlesController(_articleService) { Configuration = new HttpConfiguration(), Request = new HttpRequestMessage { Method = HttpMethod.Post, RequestUri = new Uri("http://localhost/api/articles") } }; _articlesController.Configuration.MapHttpAttributeRoutes(); _articlesController.Configuration.EnsureInitialized(); _articlesController.RequestContext.RouteData = new HttpRouteData( new HttpRoute(), new HttpRouteValueDictionary { { "Controller", "Articles" } }); _articlesController.ModelState.AddModelError("Contents", "Contents is required field"); var result = _articlesController.PostArticle(article) as InvalidModelStateResult; Assert.That(result.ModelState.Count, Is.EqualTo(1)); Assert.That(result.ModelState.IsValid, Is.EqualTo(false)); }
上面測試的重點,我們請求的幾個方面:返回碼或路由屬性。
管理 Handler單元測試
你可以通過創建HttpMessageInvoker的實例來測試Message Handler, 解析你要測試的Handler實例并調用SendAsync 方法。創建一個MessageHandlerTest.cs文件,并貼上下面的啟動設置代碼
#region Variables private EndRequestHandler _endRequestHandler; private HeaderAppenderHandler _headerAppenderHandler; #endregion #region Setup [SetUp] public void Setup() { // Direct MessageHandler test _endRequestHandler = new EndRequestHandler(); _headerAppenderHandler = new HeaderAppenderHandler() { InnerHandler = _endRequestHandler }; } #endregion
我們在HeaderAppenderHandler的內部設置另外一個可以終止請求的Hanlder.只要Uri中包含一個測試字符,從新調用EndRequestHandler將會終止請求.現在來測試.
[Test] public async void ShouldAppendCustomHeader() { var invoker = new HttpMessageInvoker(_headerAppenderHandler); var result = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/api/test/")), CancellationToken.None); Assert.That(result.Headers.Contains("X-WebAPI-Header"), Is.True); Assert.That(result.Content.ReadAsStringAsync().Result, Is.EqualTo("Unit testing message handlers!")); }
假如要做一個集成測試:當一個請求被消息管道分配到Controller的Action的真實behavior。
這將需要運行WebApi,然后運行單元測試。怎么做呢?必須是 通過Self host的模式運行API,然后設置恰當的配置。
在UnitTestingWebAPI.Tests的項目中添加Startup.cs文件:
Hosting/Startup.cs
public class Startup { public void Configuration(IAppBuilder appBuilder) { var config = new HttpConfiguration(); config.MessageHandlers.Add(new HeaderAppenderHandler()); config.MessageHandlers.Add(new EndRequestHandler()); config.Filters.Add(new ArticlesReversedFilter()); config.Services.Replace(typeof(IAssembliesResolver), new CustomAssembliesResolver()); config.Routes.MapHttpRoute( name: "DefaultApi", routeTemplate: "api/{controller}/{id}", defaults: new { id = RouteParameter.Optional } ); config.MapHttpAttributeRoutes(); // Autofac configuration var builder = new ContainerBuilder(); builder.RegisterApiControllers(typeof(ArticlesController).Assembly); // Unit of Work var _unitOfWork = new Mock<IUnitOfWork>(); builder.RegisterInstance(_unitOfWork.Object).As<IUnitOfWork>(); //Repositories var _articlesRepository = new Mock<IArticleRepository>(); _articlesRepository.Setup(x => x.GetAll()).Returns( BloggerInitializer.GetAllArticles() ); builder.RegisterInstance(_articlesRepository.Object).As<IArticleRepository>(); var _blogsRepository = new Mock<IBlogRepository>(); _blogsRepository.Setup(x => x.GetAll()).Returns( BloggerInitializer.GetBlogs ); builder.RegisterInstance(_blogsRepository.Object).As<IBlogRepository>(); // Services builder.RegisterAssemblyTypes(typeof(ArticleService).Assembly) .Where(t => t.Name.EndsWith("Service")) .AsImplementedInterfaces().InstancePerRequest(); builder.RegisterInstance(new ArticleService(_articlesRepository.Object, _unitOfWork.Object)); builder.RegisterInstance(new BlogService(_blogsRepository.Object, _unitOfWork.Object)); IContainer container = builder.Build(); config.DependencyResolver = new AutofacWebApiDependencyResolver(container); appBuilder.UseWebApi(config); } }
可能注意到和UnitTestingWebAPI.API里的WebSetup類的不同之處在與,這里我們用了假的Repositories和Services。
返回到ControllerTests.cs中。
[Test] public void ShouldCallToControllerActionAppendCustomHeader() { //Arrange var address = "http://localhost:9000/"; using (WebApp.Start<Startup>(address)) { HttpClient _client = new HttpClient(); var response = _client.GetAsync(address + "api/articles").Result; Assert.That(response.Headers.Contains("X-WebAPI-Header"), Is.True); var _returnedArticles = response.Content.ReadAsAsync<List<Article>>().Result; Assert.That(_returnedArticles.Count, Is.EqualTo(BloggerInitializer.GetAllArticles().Count)); } }
媒體類型格式化器 測試
我們在UnitTestingWebAPI.API.Core中創建了ArticleFormatter,現在測試一下,應該返回用逗號分割的文章字符串。它只能是寫文章的實例,但不能讀或者明白其它類型的類。為了應用這個格式化器需要設置請求頭信息的Accept為application/article
[TestFixture] public class MediaTypeFormatterTests { #region Variables Blog _blog; Article _article; ArticleFormatter _formatter; #endregion #region Setup [SetUp] public void Setup() { _blog = BloggerInitializer.GetBlogs().First(); _article = BloggerInitializer.GetChsakellsArticles().First(); _formatter = new ArticleFormatter(); } #endregion }
我們可以創建一個ObjectContent來測試MediaTypeFormatter,傳遞一個對象來檢查是否能被被格式化,如果格式化器不能讀和寫傳遞過去的對象則會拋出異常,例如,文章的格式化器不能識別Blog對象:
[Test] public void FormatterShouldThrowExceptionWhenUnsupportedType() { Assert.Throws<InvalidOperationException>(() => new ObjectContent<Blog>(_blog, _formatter)); }
換句話說,傳一個Article對象就一定會通過測試
[Test] public void FormatterShouldNotThrowExceptionWhenArticle() { Assert.DoesNotThrow(() => new ObjectContent<Article>(_article, _formatter)); }
用下面的代碼測試不符合MediaType formatter的Media type
Media Type Formatters Unit tests
[Test] public void FormatterShouldHeaderBeSetCorrectly() { var content = new ObjectContent<Article>(_article, new ArticleFormatter()); Assert.That(content.Headers.ContentType.MediaType, Is.EqualTo("application/article")); } [Test] public async void FormatterShouldBeAbleToDeserializeArticle() { var content = new ObjectContent<Article>(_article, _formatter); var deserializedItem = await content.ReadAsAsync<Article>(new[] { _formatter }); Assert.That(_article, Is.SameAs(deserializedItem)); } [Test] public void FormatterShouldNotBeAbleToWriteUnsupportedType() { var canWriteBlog = _formatter.CanWriteType(typeof(Blog)); Assert.That(canWriteBlog, Is.False); } [Test] public void FormatterShouldBeAbleToWriteArticle() { var canWriteArticle = _formatter.CanWriteType(typeof(Article)); Assert.That(canWriteArticle, Is.True); }
路由測試
在不Host Web API的情況下,測試路由配置。為了這個目的,需要一個可以從HttpControllerContext的實例中返回Controllerl類型或Controller中Action的幫助類,在測試之前,先創建一個路由配置的HttpConfiguration
Helpers/ControllerActionSelector.cs
public class ControllerActionSelector { #region Variables HttpConfiguration config; HttpRequestMessage request; IHttpRouteData routeData; IHttpControllerSelector controllerSelector; HttpControllerContext controllerContext; #endregion #region Constructor public ControllerActionSelector(HttpConfiguration conf, HttpRequestMessage req) { config = conf; request = req; routeData = config.Routes.GetRouteData(request); request.Properties[HttpPropertyKeys.HttpRouteDataKey] = routeData; controllerSelector = new DefaultHttpControllerSelector(config); controllerContext = new HttpControllerContext(config, routeData, request); } #endregion #region Methods public string GetActionName() { if (controllerContext.ControllerDescriptor == null) GetControllerType(); var actionSelector = new ApiControllerActionSelector(); var descriptor = actionSelector.SelectAction(controllerContext); return descriptor.ActionName; } public Type GetControllerType() { var descriptor = controllerSelector.SelectController(request); controllerContext.ControllerDescriptor = descriptor; return descriptor.ControllerType; } #endregion }
下面是路由測試:
[TestFixture] public class RouteTests { #region Variables HttpConfiguration _config; #endregion #region Setup [SetUp] public void Setup() { _config = new HttpConfiguration(); _config.Routes.MapHttpRoute(name: "DefaultWebAPI", routeTemplate: "api/{controller}/{id}", defaults: new { id = RouteParameter.Optional }); } #endregion #region Helper methods public static string GetMethodName<T, U>(Expression<Func<T, U>> expression) { var method = expression.Body as MethodCallExpression; if (method != null) return method.Method.Name; throw new ArgumentException("Expression is wrong"); } #endregion }
測試一個請求api/articles/5到ArticleController的action GetArticle(int id)
[Test] public void RouteShouldControllerGetArticleIsInvoked() { var request = new HttpRequestMessage(HttpMethod.Get, "http://www.chsakell.com/api/articles/5"); var _actionSelector = new ControllerActionSelector(_config, request); Assert.That(typeof(ArticlesController), Is.EqualTo(_actionSelector.GetControllerType())); Assert.That(GetMethodName((ArticlesController c) => c.GetArticle(5)), Is.EqualTo(_actionSelector.GetActionName())); }
我們用反射得到controller的action名稱,用同樣的方法來測試post提交的action
[Test] public void RouteShouldPostArticleActionIsInvoked() { var request = new HttpRequestMessage(HttpMethod.Post, "http://www.chsakell.com/api/articles/"); var _actionSelector = new ControllerActionSelector(_config, request); Assert.That(GetMethodName((ArticlesController c) => c.PostArticle(new Article())), Is.EqualTo(_actionSelector.GetActionName())); }
下面這個測試,路由會發生異常.
[Test] public void RouteShouldInvalidRouteThrowException() { var request = new HttpRequestMessage(HttpMethod.Post, "http://www.chsakell.com/api/InvalidController/"); var _actionSelector = new ControllerActionSelector(_config, request); Assert.Throws<HttpResponseException>(() => _actionSelector.GetActionName()); }
結論
我們看到了Web API棧很多方面的單元測試,例如: mocking 服務層,單元測試控制器,消息管道,過濾器,定制媒體類型和路由配置。
嘗試在你的程序中總是寫單元測試,你不會后悔的。從里面會得到很多的好處,例如:在repository中一個簡單的修改可能破壞很多方面,如果寫一個合適的測試,則可能破壞你程序的問題會立即出現.
原文:chsakell's Blog
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。