Let’s take a one ASP.NET Core Web API method and add unit tests using XUnit and FakeItEasy.
My environment: Visual Studio 2019 Enterprise, ASP.NET Core 2.2, FakeItEasy, AutoFixture and XUnit.
Source code
I have a Before and After version of the source code hosted on github. In the After folder, you can view the completed solution.
System Under Test
There is a ProductsController with one HTTP GET method returning a list of products and a unit test project. There is a ProductService injected into ProductsController that returns the Products. In the following section, we would like to add tests around the code and make it more production ready. One of the goals of this post is to show how often we just start like the code snippet as shown below and then when the code goes into production, there are all sorts of conditions that we have to account for. We will add all those conditions but let’s do it by adding tests for each condition.
[Route("api/[controller]")] [ApiController] public class ProductController : ControllerBase { private readonly IProductService _productService; [HttpGet] public ActionResult<IEnumerable<Product>> Get() { return _productService.GetProducts(); } }
Let’s write our first test that would validate that the Get() method shown above.
I like to start with a simple empty method with just the name of the test method split into three parts. This helps me understand under given condition how should the code behave. The three parts are explained as follows:
1.Actual Name of the method being tested – Get
2.Condition – WhenThereAreProducts
3.Expected Outcome - ShouldReturnActionResultOfProductsWith200StatusCode
[Fact] public void Get_WhenThereAreProducts_ShouldReturnActionResultOfProductsWith200StatusCode() { }
We will add all the dependencies that are required for ProductsController, below is the code that does that.
using AutoFixture; using System; using UnitTestingDemo.Controllers; using UnitTestingDemo.Services; using Xunit; using FakeItEasy; namespace UnitTestingDemo.Tests { public class ProductControllerTest { //Fakes private readonly IProductService _productService; //Dummy Data Generator private readonly Fixture _fixture; //System under test private readonly ProductsController _sut; public ProductControllerTest() { _productService = A.Fake<IProductService>(); _sut = new ProductsController(_productService); _fixture = new Fixture(); } [Fact] public void Get_WhenThereAreProducts_ShouldReturnActionResultOfProductsWith200StatusCode() { //Arrange //Act //Assert } } }
In the above code, I have added comments that explain what each line of code does. The public constructor is responsible for setting up our private objects. The system under test is called as _sut, so in all the methods it is easier to locate the system under test. In the unit test, we have three sections, Arrange, Act and Assert. I like to put these comments, so it is easier to scan different pieces of logic.
Next, we will add code to Arrange and Act as shown below,
[Fact] public void Get_WhenThereAreProducts_ShouldReturnActionResultOfProductsWith200StatusCode() { //Arrange var products = _fixture.CreateMany<Product>(3).ToList(); A.CallTo(() => _productService.GetProducts()).Returns(products); //Act var result = _sut.Get(); //Assert }
In the above code, we create 3 fake products using _fixture and then using FakeItEasy we define that when GetProducts() is called it should return those three fake products. And then we call the _sut.Get().
In the Assert part, we want to make sure that our method _productService.GetProducts() was called and it returned a result that is of type ActionResult and it returns a 200 status code. If it doesn’t return that status code, then it should fail and then we will refactor our ProductsController code.
[Fact] public void Get_WhenThereAreProducts_ShouldReturnActionResultOfProductsWith200StatusCode() { //Arrange var products = _fixture.CreateMany<Product>(3).ToList(); A.CallTo(() => _productService.GetProducts()).Returns(products); //Act var result = _sut.Get() as ActionResult<IEnumerable<Product>>; //Assert A.CallTo(() => _productService.GetProducts()).MustHaveHappenedOnceExactly(); Assert.IsType<ActionResult<IEnumerable<Product>>>(result); Assert.NotNull(result); Assert.Equal(products.Count, result.Value.Count()); }
In the above code, in the Assert section, we are making sure that _productService.GetProducts() must have been called only once, the type of the result is of type ActionResult
Lesson learned, just use ActionResult in the method signature and instead of returning directly a List
Controller Method is now modified as below and the test is now modified to test for a valid 200 status code.
[HttpGet] public ActionResult Get() { return Ok(_productService.GetProducts()); } [Fact] public void Get_WhenThereAreProducts_ShouldReturnActionResultOfProductsWith200StatusCode() { //Arrange var products = _fixture.CreateMany<Product>(3).ToList(); A.CallTo(() => _productService.GetProducts()).Returns(products); //Act var result = _sut.Get() as OkObjectResult; //Assert A.CallTo(() => _productService.GetProducts()).MustHaveHappenedOnceExactly(); Assert.NotNull(result); var returnValue = Assert.IsType<List<Product>>(result.Value); Assert.Equal(products.Count, returnValue.Count()); Assert.Equal(StatusCodes.Status200OK, result.StatusCode); }
Now that we have all the Asserts statements passing, we ask ourselves another question, Is this the only test case to test against? Can there be more test cases that we haven’t accounted for? What if there is an unhandled exception or when there is no product found?
Let’s add another test that covers the test case of an unhandled exception. Before you look at the following code, ask yourself, what should be the expected outcome when there is an exception being thrown by the ProductService? It should probably return a 500 error code and possibly log the error.
First, let’s begin by writing an empty test with a descriptive name as shown below.
[Fact] public void Get_WhenThereIsUnhandledException_ShouldReturn500StatusCode() { //Arrange //Act //Assert }
Next, we define the behavior of ProductService to throw an exception whenever GetProducts is called. If an exception is thrown, then we want to ensure that 500 HTTP Status Code is returned from the web service. The following test will fail, since we are not handling that case properly.
[Fact] public void Get_WhenThereIsUnhandledException_ShouldReturn500StatusCode() { //Arrange A.CallTo(() => _productService.GetProducts()).Throws<Exception>(); //Act var result = _sut.Get() as StatusCodeResult; //Assert A.CallTo(() => _productService.GetProducts()).MustHaveHappenedOnceExactly(); Assert.NotNull(result); Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); }
Let’s modify the Get method to handle unhandled exception by putting the processing logic into a try and catch block. After modify the code as shown below, you can run the test again and this time it would pass.
[HttpGet] public ActionResult Get() { try { return Ok(_productService.GetProducts()); } catch (Exception ex) { } return StatusCode(StatusCodes.Status500InternalServerError); }
We would like to add some kind of logging in the catch exception part. Logging using ILogger is the way to go, however, unit testing using ILogger is a bit problematic, because you have to use Adapter pattern to create your own logger that uses ILogger. For this part, I created a simple Logger called MyLogger with just a Log method to demonstrate unit testing.
The MyLogger.cs code is shown below.
using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace UnitTestingDemo.Services { public interface IMyLogger { void Log(string message, Exception ex); } public class MyLogger : IMyLogger { public void Log(string message, Exception ex) { //Log to database or use application insights. } } }
The ProductsController.cs is modified to log exception as shown below.
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using UnitTestingDemo.Models; using UnitTestingDemo.Services; namespace UnitTestingDemo.Controllers { [Route("api/[controller]")] [ApiController] public class ProductsController : ControllerBase { private readonly IProductService _productService; private readonly IMyLogger _logger; public ProductsController(IProductService productService, IMyLogger logger) { _productService = productService; _logger = logger; } [HttpGet] public ActionResult Get() { try { return Ok(_productService.GetProducts()); } catch (Exception ex) { _logger.Log($"The method {nameof(ProductService.GetProducts)} caused an exception", ex); } return StatusCode(StatusCodes.Status500InternalServerError); } } }
The Startup.cs modified as shown below
public void ConfigureServices(IServiceCollection services) { services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); services.AddTransient<IProductService, ProductService>(); services.AddSingleton<IMyLogger, MyLogger>(); }
We now modify our unit test to in the following ways to make it pass,
1. We renamed the method to include logging piece.
2. Mocked behavior of MyLogger.cs class
3. Asserting that MyLogger’s Log method must have been called when there was an exception.
[Fact] public void Get_WhenThereIsUnhandledException_ShouldReturn500StatusCodeAndLogAnException() { //Arrange A.CallTo(() => _productService.GetProducts()).Throws<Exception>(); A.CallTo(() => _logger.Log(A<string>._, A<Exception>._)).DoesNothing(); //Act var result = _sut.Get() as StatusCodeResult; //Assert A.CallTo(() => _productService.GetProducts()).MustHaveHappenedOnceExactly(); A.CallTo(() => _logger.Log(A<string>._, A<Exception>._)).MustHaveHappenedOnceExactly(); Assert.NotNull(result); Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); }
Let’s add another test case to account for when there are no products found, then we would like to return a 404 Not Found result.
[Fact] public void Get_WhenThereAreNoProductsFound_ShouldReturn404NotFoundResult() { //Arrange //Act //Assert }
Let’s write a failing unit test and then add the condition in our Controller Get method.
[Fact] public void Get_WhenThereAreNoProductsFound_ShouldReturn404NotFoundResult() { //Arrange var products = new List<Product>(); A.CallTo(() => _productService.GetProducts()).Returns(products); //Act var result = _sut.Get() as NotFoundResult; //Assert A.CallTo(() => _productService.GetProducts()).MustHaveHappenedOnceExactly(); Assert.NotNull(result); Assert.Equal(StatusCodes.Status404NotFound, result.StatusCode); }
Modify the Get method as follows
[HttpGet] public ActionResult Get() { try { var products = _productService.GetProducts(); if (products?.Count > 0) { return Ok(products); } return NotFound(); } catch (Exception ex) { _logger.Log($"The method {nameof(ProductService.GetProducts)} caused an exception", ex); } return StatusCode(StatusCodes.Status500InternalServerError); }
Finally, if you are using Swagger then adding the Produces attribute will result into better documentation. I know this isn’t related to unit testing but it is a nice to have.
[HttpGet] [ProducesResponseType(typeof(List<Product>), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public ActionResult Get() { try { var products = _productService.GetProducts(); if (products?.Count > 0) { return Ok(products); } return NotFound(); } catch (Exception ex) { _logger.Log($"The method {nameof(ProductService.GetProducts)} caused an exception", ex); } return StatusCode(StatusCodes.Status500InternalServerError); }
We have accounted for all the test cases to make this code ready for Production.
If you can think of any test case that I haven’t accounted for, then please let me know in the comments section below. The final version of the test can be found here.
No comments:
Post a Comment