Table of Contents
Thông thường, một ứng dụng ASP.NET Core chia nhiều layer. Điều khó nhất là làm sao test đúng những gì một method thực sự làm, và giả lập, thuật ngữ TA hay dùng là “mock”, tất cả những bước không thuộc method đó. Mỗi layer trong ASP.NET sẽ cần một số kỹ thuật mock riêng biệt. Cùng tìm hiểu với mình nào.
Unit Testing Framework và thư viện
- xUnit.NET: một testing framework được dùng phổ biến nhất hiện tại cho các ứng dụng .NET được port từ JUnit.
- Moq thư viện giả lập object khá phổ biến.
- Fluent Assertions – Fluent Assertions cung cấp extensions method cho phép mô tả expected result theo cách tự nhiên.
xUnit sample code
Khung template một unit test class sẽ như thế này:
using System; using Xunit; // Code samples by minhphien.com namespace ProgramUnitTests { public class ClassNameTests : IDisposable { public ClassNameTests() { } [Fact] public void SingleMethodName_Input_Output() { } [Theory] [ClassData(typeof(TestDataTemplate))] public void MethodNameWithTestData_Input_Output(int a, string b) { var temp = a; Assert.Equal(temp, a); } public void Dispose() { } } // Test Data using Theory Data. public class TestDataTemplate : TheoryData<int, string> { public TestDataTemplate() { Add(1, "Test"); } } }
Unit Test ASP.NET Web API Controllers
Khi test một controller, điều cần phải test chính là: Dựa trên dữ liệu ngữ cảnh đang có trong một action, ta phải thực hiện setup một số thứ (RouteData, khởi tạo Service) cho Controller trước. Thông thường một controller sẽ gọi những service khác nhau, nhưng nhiệm vụ chính của nó là cung cấp một HTTP gateway, thực hiện một số bước validate cơ bản sau đó trả về HTTP reponse tương ứng.
Để test phương thức Post, bạn cần thiết lập HttpRequestContext và RouteData của nó, vì action sử dụng UrlHelper để tạo một liên kết, nó dựa vào RouteData.
Cuối cùng, ta có thể assert những gì liên quan đến response mà action trả về: HttpStatusCode, thuộc tính trong Headers, Content.
public async void WhenItemIsPostedResponseShouldBe201AndLocationHeaderShouldBeSet() { var item = new Item { Id = 1, Name = "Filip" }; var service = new Mock<IItemService>().Object; var controller = new ItemsController(service) { Configuration = new HttpConfiguration(), Request = new HttpRequestMessage { Method = HttpMethod.Post, RequestUri = new Uri("http://localhost/items") } }; controller.Configuration.MapHttpAttributeRoutes(); controller.Configuration.EnsureInitialized(); controller.RequestContext.RouteData = new HttpRouteData( new HttpRoute(), new HttpRouteValueDictionary { { "controller", "Items" } }); var result = await controller.Post(item); Assert.Equal(HttpStatusCode.Created, result.StatusCode); Assert.Equal("http://localhost/items/1", result.Headers.Location.AbsoluteUri); }
Phương thức này sẽ test method Post trong 1 controller khi url “http://localhost/items” được gọi. Để test một route cụ thể nào đó, ta phải thêm nó vào MapHttpRoute. Còn 2 lệnh: Configuration.MapHttpAttributeRoutes() và Configuration.EnsureInitialized() để đảm bảo controller mới vừa tạo có thể route theo attributes.
controller.Configuration.Routes.MapHttpRoute( name: "MethodOne", routeTemplate: "api/{controller}/methodone/{id}/{type}" );
Khi Action được redirect qua một Action khác…
Việc mock sẽ phức tạp hơn, vì ta không muốn nó thực thi những gì trong action kia, ta có thể mock class UrlHelper (.NET đã hỗ trợ Mock từ Web API 2) để trả ra một url xác địch nào đó khi một
var url = "https://minhphien.com"; var urlHelper = new Mock<UrlHelper>(); urlHelper.Setup(x => x.Link(It.IsAny<string>(), It.IsAny<object>())).Returns(url);
Khi Controller có trả về Exception
Ví dụ dưới đây là cách để assert một exception, sử dụng thư viện AssertEx, khi model Name bị thiếu lúc truyền vô bằng cách dùng ModelState và AddModelError. Trong ví dụ này, ta thấy khả năng assert một async method thực hiện dễ dàng nhờ vào thư viện này.
controller.ModelState.AddModelError("Name", "Name is required"); var ex = AssertEx.TaskThrows<HttpResponseException>(async () => await controller.Post(item)); Assert.Equal(HttpStatusCode.BadRequest, ex.Response.StatusCode);
Unit Test Message Handlers
HTTP Message Handler là một layer trong ASP.NET giúp nhận HTTP request và trả về HTTP Response, kế thừa lớp cha tên là HttpMessageHandler.
Thông thường, khi có một request gửi đến ASP.NET Web APIs, nó sẽ đi qua một seri những Message Handler này. Handler đầu tiên sẽ nhận và xử lý, sau đó đưa tiếp đến Handler tiếp theo. Pattern này ta gọi là Delegating Handler.
public static class WebApiConfig { public static void Register(HttpConfiguration config) { config.MessageHandlers.Add(new MessageHandler1()); config.MessageHandlers.Add(new MessageHandler2()); // Other code not shown... } }
Code phía trên là cách ta thêm 2 custom handler vào pipeline xử lý của một ứng dụng ASP.NET. Còn đây là nội dung của một Handler được viết sẵn mà ta sẽ test.
public class LoggingHandler : DelegatingHandler { public ILoggingService LoggingService { get; set; } protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var loggingService = LoggingService ?? request.GetDependencyScope().GetService(typeof(ILoggingService)) as ILoggingService; if (loggingService != null) { loggingService.Log(request.RequestUri.ToString()); } return base.SendAsync(request, cancellationToken); } }
Chỗ khó trong khi test class này ở chỗ, hàm SendAsync là hàm protected, không để gọi trực tiếp. May mắn là trong System.Net.Http có hỗ trợ hàm HttpMessageInvoker, ta có thể tận dụng để gọi hàm SendAsync.
var handler = new MyMessageHandler(); var invoker = new HttpMessageInvoker(handler); var result = await invoker.SendAsync(new HttpRequestMessage(), CancellationToken.None);
Với class handler ở trên, điều ta cần test là loggingService có được gọi khi nó đi qua handler này hay không. Đây là unit test hoàn chỉnh assert được lệnh Log được gọi một lần khi LoggingService hiện hữu.
[Fact] public async void WhenCalledShouldLogTheRequestUrl() { var mockLogger = new Mock<ILoggingService>(); var handler = new LoggingHandler { LoggingService = mockLogger.Object }; var invoker = new HttpMessageInvoker(handler); await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, new Uri("https://minhphien.com/resource")), new CancellationToken()); mockLogger.Verify(x => x.Log("https://minhphien.com/resource"), Times.Once); }
Unit Test Action Filter Attribute
Làm việc trên ASP.NET, bạn sẽ biết đến action filter như một attribute, có khả năng apply vào một action trong controller, thậm chí toàn bộ controller, nhằm thay đổi cách một action/controller thực thi. Và việc test sẽ nằm ở chỗ làm sao test được những thay đổi xảy ra trong một filter.
Vì các filter sẽ làm việc trên HttpActionContext và HttpActionExecutedContext, ta cần phải kết hợp thêm một số thuộc tính khác như Request, RequestContext.
public class HttpActionContext { public HttpActionContext(); public HttpActionContext(HttpControllerContext controllerContext, HttpActionDescriptor actionDescriptor); public Dictionary<string, object> ActionArguments { get; } public HttpActionDescriptor ActionDescriptor { get; set; } public HttpControllerContext ControllerContext { get; set; } public ModelStateDictionary ModelState { get; } public HttpRequestMessage Request { get; } public HttpRequestContext RequestContext { get; } public HttpResponseMessage Response { get; set; } } public class HttpActionExecutedContext { public HttpActionExecutedContext(HttpActionContext actionContext, Exception exception); public HttpActionContext ActionContext { get; set; } public Exception Exception { get; set; } public HttpResponseMessage Response { get; set; } }
[Fact] public void WhenActionErrorsOutShouldNotCache() { var attribute = new CacheAttribute(); var executedContext = new HttpActionExecutedContext(new HttpActionContext { Response = new HttpResponseMessage(HttpStatusCode.InternalServerError) }, null); attribute.OnActionExecuted(executedContext); Assert.Null(executedContext.Response.Headers.CacheControl); } [Fact] public void WhenActionIsSuccessfulRelevantCacheControlIsSet() { var attribute = new CacheAttribute {ClientTimeSpan = 100}; var executedContext = new HttpActionExecutedContext(new HttpActionContext { Response = new HttpResponseMessage(HttpStatusCode.OK) }, null); attribute.OnActionExecuted(executedContext); Assert.Equal(TimeSpan.FromSeconds(100), executedContext.Response.Headers.CacheControl.MaxAge); Assert.Equal(true, executedContext.Response.Headers.CacheControl.Public); Assert.Equal(true, executedContext.Response.Headers.CacheControl.MustRevalidate); }
Unit Test Media Type Formatter
Media Type là định dạng dữ liệu mà server gửi cho client, khi client gửi một request đến server, nó có thể yêu cầu định dạng mà nó muốn nhận thông qua header tên là Accept, khi server trả về gói tin HTTP, bên cạnh content chính, client sẽ có thể đọc được định dạng dữ liệu nhận được thông qua header là Content-Type. Định dạng có có thể là XML, JSON, BSON,… Ngoài ra ta có thể viết một custom media type bằng cách định nghĩa một formatter.
Formatter (cụ thể là MediaType Formatter) là những lớp trựu tượng được kế thừa chính từ một trong lớp MediaTypeFormatter and BufferedMediaTypeFormatter.
Đây là code mẫu để tạo một custom formatter định dạng CSV
public class ProductCsvFormatter : BufferedMediaTypeFormatter { public ProductCsvFormatter() { // Add the supported media type. SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/csv")); } public override bool CanWriteType(System.Type type) { if (type == typeof(Product)) { return true; } else { Type enumerableType = typeof(IEnumerable<Product>); return enumerableType.IsAssignableFrom(type); } } public override bool CanReadType(Type type) { return false; } public override void WriteToStream(Type type, object value, Stream writeStream, HttpContent content) { using (var writer = new StreamWriter(writeStream)) { var products = value as IEnumerable<Product>; if (products != null) { foreach (var product in products) { WriteItem(product, writer); } } else { var singleProduct = value as Product; if (singleProduct == null) { throw new InvalidOperationException("Cannot serialize type"); } WriteItem(singleProduct, writer); } } } // Helper methods for serializing Products to CSV format. private void WriteItem(Product product, StreamWriter writer) { ... } static char[] _specialChars = new char[] { ',', '\n', '\r', '"' }; }
Trong class này, có 3 phương thức bị ghi đè là:
- CanWriteType: xác định xem định dạng media type thì formatter này có được serialize được không
- CanReadType: tương tự, xác định xem định dạng media type thì formatter này có được deserialize được không
- WriteToStream: Phương thức này sẽ serialize dữ liệu ghi vào stream.
Ngoài ra ta có thể ghi đè luôn phương thức ReadFromStream nếu CanReadType trả về true.
Rồi, quay lại vấn đề chính sau khi hiểu được formatter là gì, vậy điều gì ta cần test với một formatter như thế này, chính là các hàm mới được ghi đè.
Để làm được điều này, ta cần dùng một instance của class ObjectContent<T> (System.Net.Http.Formatting), class này trong constructor đã hỗ trợ sẵn việc đưa vào formatter liên quan và kiểu mediatype cần phải test.
public ObjectContent(Type type, object value, MediaTypeFormatter formatter, MediaTypeHeaderValue mediaType) { if (type == null) { throw Error.ArgumentNull("type"); } if (formatter == null) { throw Error.ArgumentNull("formatter"); } if (!formatter.CanWriteType(type)) { throw Error.InvalidOperation(Properties.Resources.ObjectContent_FormatterCannotWriteType, formatter.GetType().FullName, type.Name); } _formatter = formatter; ObjectType = type; VerifyAndSetObject(value); _formatter.SetDefaultContentHeaders(type, Headers, mediaType); }
Thông qua ObjectContent, ta có thể xem được output của một deserializer như sau và kiểm tra xem nó có giống với object mà ta cần không.
[Fact] public async void WhenUsedToDeserializeShouldCreateCorrectObject() { var formatter = new ProtoBufFormatter(); var item = new Item { Id = 1, Name = "Filip" }; var content = new ObjectContent<Item>(item, formatter); var deserializedItem = await content.ReadAsAsync<Item>(new[] { formatter }); Assert.Same(item, deserializedItem); }
Còn dây là một unit test bình thường để đọc xem output sau khi deserialized item nhận được gọi bởi WriteToStreamAsync().
[Fact] public async void WhenWritingToStreamShouldSuccessfullyComplete() { var formatter = new ProtoBufFormatter(); var item = new Item { Id = 1, Name = "Filip" }; var ms = new MemoryStream(); await formatter.WriteToStreamAsync(typeof(Item), item, ms, new ByteArrayContent(new byte[0]), new Mock<TransportContext>().Object); var deserialized = ProtoBufFormatter.Model.Deserialize(ms, null, typeof(Item)); Assert.Same(deserialized, item); } [Fact] public async void WhenReadingFromStreamShouldSuccessfullyComplete() { var formatter = new ProtoBufFormatter(); var item = new Item { Id = 1, Name = "Filip" }; var ms = new MemoryStream(); ProtoBufFormatter.Model.Serialize(ms, item); var deserialized = await formatter.ReadFromStreamAsync(typeof(Item), ms, new ByteArrayContent(new byte[0]), new Mock<IFormatterLogger>().Object); Assert.Same(deserialized as Item, item); }
— Phần kết thúc phần 1. —
Còn phần còn lại sẽ nói về:
Unit Test cho Routing,
Viết một Integration Test
Reference:
- ASP.NET Web API 2 A Problem-Solution Approach (Filip Wojcieszyn)
- Designing Evolvable Web APIs with ASP.NET (Glenn Block)
- HTTP Message Handlers in ASP.NET Web API (Mike Wasson)
- Media Formatters in ASP.NET Web API 2 (Mike Wasson)