In this article we are going to learn how to implement Unit and Integration tests on .NET using xUnit.
Prerequisites:
- Visual Studio 2022 with .NET 6 SDK
- Download or clone the base project from here
1. Create Unit test project
You should have the following structure:
Then, right click in the solution. Add / New Solution Folder, named tests.
In the tests folder, right click. Add / New Project ... / xUnit Test project, named Store.UnitTests and select .NET 6 as target framework.
Add the reference to Store.ApplicationCore project.
Create the following folders:
- DTOs
- Mappings
- Utils
In Utils folder, create DateUtilTests class. In this example we are verifying that the date is gotten by DateUtil.GetCurrentDate() has a year great or equal to 2021.
using Store.ApplicationCore.Utils;
using Xunit;
namespace Store.UnitTests.Utils
{
public class DateUtilTests
{
[Fact]
public void GetCurrentDate_ReturnsCorrectDate()
{
var currentDate = DateUtil.GetCurrentDate();
Assert.True(currentDate.Year >= 2021);
}
}
}
If we want to run this test, right click in Store.UnitTests project, Run Tests.
A Test Explorer window is opened and there we can see all the tests we ran and if they passed or failed. Also, we can see how much time each test took.
Let's continue with the other tests.
In Mappings folder, create MappingTests class.
using AutoMapper;
using Store.ApplicationCore.DTOs;
using Store.ApplicationCore.Entities;
using Store.ApplicationCore.Mappings;
using System;
using System.Runtime.Serialization;
using Xunit;
namespace Store.UnitTests.Mappings
{
public class MappingTests
{
private readonly IConfigurationProvider _configuration;
private readonly IMapper _mapper;
public MappingTests()
{
_configuration = new MapperConfiguration(cfg =>
{
cfg.AddProfile<GeneralProfile>();
});
_mapper = _configuration.CreateMapper();
}
[Fact]
public void ShouldBeValidConfiguration()
{
_configuration.AssertConfigurationIsValid();
}
[Theory]
[InlineData(typeof(CreateProductRequest), typeof(Product))]
[InlineData(typeof(Product), typeof(ProductResponse))]
public void Map_SourceToDestination_ExistConfiguration(Type origin, Type destination)
{
var instance = FormatterServices.GetUninitializedObject(origin);
_mapper.Map(instance, origin, destination);
}
}
}
In ShouldBeValidConfiguration method we are verifyng if the configuration in GeneralProfile class from Store.ApplicationCore project is correct, and in Map_SourceToDestination_ExistConfiguration method we are testing if the source and destination combination is already present in GeneralProfile.
If we run the tests again, we are going to see the following error in ShouldBeValidConfiguration method.
There are some unmapped properties for Product class:
- Id
- Stock
- CreatedAt
- UpdatedAt
To fix this, go to GeneralProfile in Store.ApplicationCore project and update CreateMap<CreateProductRequest, Product>()
.
CreateMap<CreateProductRequest, Product>()
.ForMember(dest =>
dest.Id,
opt => opt.Ignore()
)
.ForMember(dest =>
dest.Stock,
opt => opt.Ignore()
)
.ForMember(dest =>
dest.CreatedAt,
opt => opt.Ignore()
)
.ForMember(dest =>
dest.UpdatedAt,
opt => opt.Ignore()
);
Then, run the tests again and the error will be gone.
In DTOs folder, create two classes:
- BaseTest
- ProductRequestTests
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Store.UnitTests.DTOs
{
public abstract class BaseTest
{
public IList<ValidationResult> ValidateModel(object model)
{
var validationResults = new List<ValidationResult>();
var ctx = new ValidationContext(model, null, null);
Validator.TryValidateObject(model, ctx, validationResults, true);
return validationResults;
}
}
}
ValidateModel method returns all the errors that the model has. These validations are from the annotations we added in the request's model in the Product class from the DTOs folder in Store.ApplicationCore project.
In ProductRequestTests class, we inheritance BaseTest and then create the methods to test CreateProductRequest and UpdateProductRequest.
using Store.ApplicationCore.DTOs;
using Xunit;
namespace Store.UnitTests.DTOs
{
public class ProductRequestTests : BaseTest
{
[Theory]
[InlineData("Test", "Description", 0.02, 0)]
[InlineData("Test", null, 0.02, 1)]
[InlineData(null, null, 0.02, 2)]
[InlineData(null, null, -1, 3)]
public void ValidateModel_CreateProductRequest_ReturnsCorrectNumberOfErrors(string name, string description, double price, int numberExpectedErrors)
{
var request = new CreateProductRequest
{
Name = name,
Description = description,
Price = price
};
var errorList = ValidateModel(request);
Assert.Equal(numberExpectedErrors, errorList.Count);
}
[Theory]
[InlineData("Test", "Description", 0.02, 4, 0)]
[InlineData("Test", null, 0.02, 9, 1)]
[InlineData(null, null, 0.02, 1, 2)]
[InlineData(null, null, -1, 8, 3)]
[InlineData(null, null, -1, 200, 4)]
public void ValidateModel_UpdateProductRequest_ReturnsCorrectNumberOfErrors(string name, string description, double price, int stock, int numberExpectedErrors)
{
var request = new UpdateProductRequest
{
Name = name,
Description = description,
Price = price,
Stock = stock
};
var errorList = ValidateModel(request);
Assert.Equal(numberExpectedErrors, errorList.Count);
}
}
}
In each method we pass the parameters to create the request model. Also, we pass the number of expected errors according to the parameters.
Run the tests again, and all passed.
That's all in this project.
2. Create Integration test project
Create another xUnit project, named Store.IntegrationTests.
Add the reference to Store.Infrastructure project.
Install the following packages:
- Bogus
- Microsoft.EntityFrameworkCore.Sqlite
Create a SharedDatabaseFixture class.
using Bogus;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Store.ApplicationCore.Entities;
using Store.ApplicationCore.Utils;
using Store.Infrastructure.Persistence.Contexts;
using System;
using System.Data.Common;
namespace Store.IntegrationTests
{
public class SharedDatabaseFixture : IDisposable
{
private static readonly object _lock = new object();
private static bool _databaseInitialized;
private string dbName = "TestDatabase.db";
public SharedDatabaseFixture()
{
Connection = new SqliteConnection($"Filename={dbName}");
Seed();
Connection.Open();
}
public DbConnection Connection { get; }
public StoreContext CreateContext(DbTransaction? transaction = null)
{
var context = new StoreContext(new DbContextOptionsBuilder<StoreContext>().UseSqlite(Connection).Options);
if (transaction != null)
{
context.Database.UseTransaction(transaction);
}
return context;
}
private void Seed()
{
lock (_lock)
{
if (!_databaseInitialized)
{
using (var context = CreateContext())
{
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
SeedData(context);
}
_databaseInitialized = true;
}
}
}
private void SeedData(StoreContext context)
{
var productIds = 1;
var fakeProducts = new Faker<Product>()
.RuleFor(o => o.Name, f => $"Product {productIds}")
.RuleFor(o => o.Description, f => $"Description {productIds}")
.RuleFor(o => o.Id, f => productIds++)
.RuleFor(o => o.Stock, f => f.Random.Number(1, 50))
.RuleFor(o => o.Price, f => f.Random.Double(0.01, 100))
.RuleFor(o => o.CreatedAt, f => DateUtil.GetCurrentDate())
.RuleFor(o => o.UpdatedAt, f => DateUtil.GetCurrentDate());
var products = fakeProducts.Generate(10);
context.AddRange(products);
context.SaveChanges();
}
public void Dispose() => Connection.Dispose();
}
}
We use bogus to generate fake data for products.
In this article you can learn more about test fixture.
In Repositories folder, create ProductRepositoryTests class.
using AutoMapper;
using Store.ApplicationCore.DTOs;
using Store.ApplicationCore.Exceptions;
using Store.ApplicationCore.Mappings;
using Store.Infrastructure.Persistence.Repositories;
using Xunit;
namespace Store.IntegrationTests.Repositories
{
public class ProductRepositoryTests : IClassFixture<SharedDatabaseFixture>
{
private readonly IMapper _mapper;
private SharedDatabaseFixture Fixture { get; }
public ProductRepositoryTests(SharedDatabaseFixture fixture)
{
Fixture = fixture;
var configuration = new MapperConfiguration(cfg =>
{
cfg.AddProfile<GeneralProfile>();
});
_mapper = configuration.CreateMapper();
}
[Fact]
public void GetProducts_ReturnsAllProducts()
{
using (var context = Fixture.CreateContext())
{
var repository = new ProductRepository(context, _mapper);
var products = repository.GetProducts();
Assert.Equal(10, products.Count);
}
}
[Fact]
public void GetProductById_ProductDoesntExist_ThrowsNotFoundException()
{
using (var context = Fixture.CreateContext())
{
var repository = new ProductRepository(context, _mapper);
var productId = 56;
Assert.Throws<NotFoundException>(() => repository.GetProductById(productId));
}
}
[Fact]
public void CreateProduct_SavesCorrectData()
{
using (var transaction = Fixture.Connection.BeginTransaction())
{
var productId = 0;
var request = new CreateProductRequest
{
Name = "Product 11",
Description = "Description 11",
Price = 5
};
using (var context = Fixture.CreateContext(transaction))
{
var repository = new ProductRepository(context, _mapper);
var product = repository.CreateProduct(request);
productId = product.Id;
}
using (var context = Fixture.CreateContext(transaction))
{
var repository = new ProductRepository(context, _mapper);
var product = repository.GetProductById(productId);
Assert.NotNull(product);
Assert.Equal(request.Name, product.Name);
Assert.Equal(request.Description, product.Description);
Assert.Equal(request.Price, product.Price);
Assert.Equal(0, product.Stock);
}
}
}
[Fact]
public void UpdateProduct_SavesCorrectData()
{
using (var transaction = Fixture.Connection.BeginTransaction())
{
var productId = 1;
var request = new UpdateProductRequest
{
Name = "Product 1",
Description = "Description 1",
Price = 5.12,
Stock = 23
};
using (var context = Fixture.CreateContext(transaction))
{
var repository = new ProductRepository(context, _mapper);
repository.UpdateProduct(productId, request);
}
using (var context = Fixture.CreateContext(transaction))
{
var repository = new ProductRepository(context, _mapper);
var product = repository.GetProductById(productId);
Assert.NotNull(product);
Assert.Equal(request.Name, product.Name);
Assert.Equal(request.Description, product.Description);
Assert.Equal(request.Price, product.Price);
Assert.Equal(request.Stock, product.Stock);
}
}
}
[Fact]
public void UpdateProduct_ProductDoesntExist_ThrowsNotFoundException()
{
var productId = 15;
var request = new UpdateProductRequest
{
Name = "Product 15",
Description = "Description 15",
Price = 5.12,
Stock = 23
};
using (var context = Fixture.CreateContext())
{
var repository = new ProductRepository(context, _mapper);
var action = () => repository.UpdateProduct(productId, request);
Assert.Throws<NotFoundException>(action);
}
}
[Fact]
public void DeleteProductById_EnsuresProductIsDeleted()
{
using (var transaction = Fixture.Connection.BeginTransaction())
{
var productId = 2;
using (var context = Fixture.CreateContext(transaction))
{
var repository = new ProductRepository(context, _mapper);
var products = repository.GetProducts();
repository.DeleteProductById(productId);
}
using (var context = Fixture.CreateContext(transaction))
{
var repository = new ProductRepository(context, _mapper);
var action = () => repository.GetProductById(productId);
Assert.Throws<NotFoundException>(action);
}
}
}
[Fact]
public void DeleteProductById_ProductDoesntExist_ThrowsNotFoundException()
{
var productId = 48;
using (var context = Fixture.CreateContext())
{
var repository = new ProductRepository(context, _mapper);
var action = () => repository.DeleteProductById(productId);
Assert.Throws<NotFoundException>(action);
}
}
}
}
We use IClassFixture
to have an unique instance of SharedDatabaseFixture to all the test in ProductRepositoryTests class.
In this class we are validating all the methods from ProductRepository.
Let's run the Integration tests.
All passed. In the next section we are going to see how much code we are covering in these tests.
3. Generate the coverage reports
Open your CMD and execute dotnet tool install -g dotnet-reportgenerator-globaltool
.
Copy the solution path and paste it in the CMD.
Go to Store.UnitTests and exeute dotnet test --collect:"XPlat Code Coverage"
.
This created a TestResults folder and a folder with a xml file inside this.
To generate the report, copy the GUID from the folder that was generated inside TestResults.
Execute the following command: reportgenerator -reports:"TestResults\<GUID>\coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html
.
This generated a coveragereport folder in Store.UnitTests path. Then, execute the index.html file.
In the report, you can see a summary with the covered and uncovered lines
In the second image, you can realize that there are some files that aren't covered 100%. This is due to these files don't need to be tested or have some fields we don't need to test.
To exclude a class, method or field, we can add [ExcludeFromCodeCoverage]
.
Let's do that and rerun the commands from this section.
As you can see, now we are covering all the code from Store.ApplicationCore. The same we can do in Store.IntegrationTests.
You can find the source code here.
You can watch the tutorial in Spanish here.
Thanks for reading
Thank you very much for reading, I hope you found this article interesting and may be useful in the future. If you have any questions or ideas that you need to discuss, it will be a pleasure to be able to collaborate and exchange knowledge together.
Top comments (1)
Very helpful, Thanks so much!!