In the previous article, Best Practices to Write Unit Tests the Right Way (Part 1), we explored some of the best practices for unit testing and then compiled a list of must-have libraries that greatly improve the quality of tests. However, it didn't cover some common scenarios like testing LINQ and mappings, so I decided to fill this gap with one more example-driven post.
Ready to further improve your unit testing skills? Lets' get started🍿
The examples in this article make extensive use of the awesome tools described in the previous post, so it would be a good idea to start with Part 1 so that the code we're about to analyze makes more sense.
It's a matter of fact that all C# developers love LINQ, but we should also treat it with respect and cover queries with tests. By the way, this is one of the many advantages of LINQ over SQL (have you ever seen a real person who wrote at least one unit test for SQL? Neither have I).
Let's take a look at an example.
public class UserRepository : IUserRepository
{
private readonly IDb _db;
public UserRepository(IDb db)
{
_db = db;
}
public Task<User?> GetUser(int id, CancellationToken ct = default)
{
return _db.Users
.Where(x => x.Id == id)
.Where(x => !x.IsDeleted)
.FirstOrDefaultAsync(ct);
}
// other methods
}
In this example, we have a typical repository with a method that returns users by ID, and _db.Users
returns IQueryable<User>
. So what do we need to test here?
null
if the user with the given ID exists, but is marked as deleted.null
if the user with the given ID doesn't exist.
In other words, all Where
, OrderBy
and other method calls must be covered by tests. Now let's write and discuss the first test (💡reminder: the test structure was explained in the previous article):
public class UserRepositoryTests
{
public class GetUser : UserRepositoryTestsBase
{
[Fact]
public async Task Should_return_user_by_id_unless_deleted()
{
// arrange
var expectedResult = F.Build<User>()
.With(x => x.IsDeleted, false)
.Create();
var allUsers = F.CreateMany<User>().ToList();
allUsers.Add(expectedResult);
Db.Users.Returns(allUsers.Shuffle().AsQueryable());
// act
var result = await Repository.GetUser(expectedResult.Id);
// assert
result.Should().Be(expectedResult);
}
[Fact]
public async Task Should_return_null_when_user_is_deleted()
{
// see below
}
[Fact]
public async Task Should_return_null_when_user_doesnt_exist()
{
// see below
}
}
public abstract class UserRepositoryTestsBase
{
protected readonly Fixture F = new();
protected readonly UserRepository Repository;
protected readonly IDb Db;
protected UserRepositoryTestsBase()
{
Db = Substitute.For<IDb>();
Repository = new UserRepository(Db);
}
}
}
First of all, we created a user that meets the requirements (not deleted) and added it to a bunch of other users (with random different IDs and IsDeleted
values). Then we mocked the data source to return the shuffled dataset. Note, that we shuffled the list of users to place the expectedResult
in a random position. Finally, we called Repository.GetUser
and verified the result.
Shuffle()
is a small yet useful extension method:
public static class EnumerableExtensions
{
private static readonly Random _randomizer = new();
public static T GetRandomElement<T>(this ICollection<T> collection)
{
return collection.ElementAt(_randomizer.Next(collection.Count));
}
public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> objects)
{
return objects.OrderBy(_ => Guid.NewGuid());
}
}
The second test is almost identical to the first one.
[Fact]
public async Task Should_return_null_when_user_is_deleted()
{
// arrange
var testUser = F.Build<User>()
.With(x => x.IsDeleted, true)
.Create();
var allUsers = F.CreateMany<User>().ToList();
allUsers.Add(testUser);
Db.Users.Returns(allUsers.Shuffle().AsQueryable());
// act
var result = await Repository.GetUser(testUser.Id);
// assert
result.Should().BeNull();
}
Here we mark our user as deleted and check that the result is null
.
For the last test, we generate a list of random users and a unique ID that doesn't belong to any of them:
[Fact]
public async Task Should_return_null_when_user_doesnt_exist()
{
// arrange
var allUsers = F.CreateMany<User>().ToList();
var userId = F.CreateIntNotIn(allUsers.Select(x => x.Id).ToList());
Db.Users.Returns(allUsers.Shuffle().AsQueryable());
// act
var result = await Repository.GetUser(userId);
// assert
result.Should().BeNull();
}
CreateIntNotIn()
is another useful method often used in tests:
public static int CreateIntNotIn(this Fixture f, ICollection<int> except)
{
var maxValue = except.Count * 2;
return Enumerable.Range(1, maxValue)
.Except(except)
.ToList()
.GetRandomElement();
}
Let's run our tests:
✅ Looks green enough, so let's move on to the next example.
Despite many devs claim that it is boring or a waste of time, I believe unit testing of mappings plays a key role in the development process for the following reasons:
It's easy to overlook small, yet important differences in data types. For example, when a property of class A is of type DateTimeOffset
and the corresponding property of class B is of type DateTime
. The default mapping will not crash, but will produce an incorrect result.
New or removed properties. With mapping tests, whenever we refactor one of the classes, it's impossible to forget to change the other (because the well-written tests won't pass).
Typos and different spelling. We're all humans and often don't notice typos, which, in turn, can lead to incorrect mapping results. Example:
public class ErrorInfo
{
public string StackTrace { get; set; }
public string SerializedException { get; set; }
}
public class ErrorOccurredEvent
{
public string StackTrace { get; set; }
public string SerialisedException { get; set; }
}
public class ErrorMappings : Profile
{
public ErrorMappings()
{
CreateMap<ErrorInfo, ErrorOccurredEvent>();
}
}
It's pretty easy to overlook the issue of different spelling in the code above, and Rider / Resharper won't help with that either, because both Serialized and Serialised look fine to it. In this case, the mapper will always set the target property to null
, which is definitely not desirable.
I hope I managed to convince you and proved the value of unit tests for mappings, so let's move on to the next example. We're going to use AutoMapper, but from a testing standpoint, the choice of mapper makes no difference. For instance, we can replace AutoMapper with Mapster and it won't affect our tests in any way. Moreover, the existing tests will indicate whether our mapping refactoring was successful or not, which is one of the points of having unit tests 🙂
Say we have these entities:
public class User
{
public int Id { get; init; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public bool IsAdmin { get; set; }
public bool IsDeleted { get; set; }
}
public class UserHttpResponse
{
public int Id { get; init; }
public string Name { get; set; }
public string Email { get; set; }
public bool IsAdmin { get; set; }
}
public class BlogPost
{
public int Id { get; set; }
public int UserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Text { get; set; }
}
public class BlogPostDeletedEvent
{
public int Id { get; set; }
public int UserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Text { get; set; }
}
public class Comment
{
public int Id { get; set; }
public int BlogId { get; set; }
public int UserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Text { get; set; }
}
public class CommentDeletedEvent
{
public int Id { get; set; }
public int BlogId { get; set; }
public int UserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Text { get; set; }
}
And mappings:
public class MappingsSetup : Profile
{
public MappingsSetup()
{
CreateMap<User, UserHttpResponse>()
.ForMember(x => x.Name, _ => _.MapFrom(x => $"{x.FirstName} {x.LastName}"));
CreateMap<BlogPost, BlogPostDeletedEvent>();
CreateMap<Comment, CommentDeletedEvent>();
}
}
Nothing especially fancy: the mapping for User
>> UserHttpResponse
is slightly customized while the other two are default "map as is" instructions. Let's write tests for our mapping profile.
To begin with, here is the base class that you can use for all unit tests for mappings:
public abstract class MappingsTestsBase<T> where T : Profile, new()
{
protected readonly Fixture F;
protected readonly IMapper M;
public MappingsTestsBase()
{
F = new Fixture();
M = new MapperConfiguration(x => { x.AddProfile<T>(); }).CreateMapper();
}
}
And our first test for User
>> UserHttpResponse
mapping:
public class MappingsTests
{
public class User_TO_UserHttpResponse : MappingsTestsBase<MappingsSetup>
{
[Theory, AutoData]
public void Should_map(User source)
{
// act
var result = M.Map<UserHttpResponse>(source);
// assert
result.Name.Should().Be($"{source.FirstName} {source.LastName}");
result.Should().BeEquivalentTo(source, _ => _.Excluding(x => x.FirstName)
.Excluding(x => x.LastName)
.Excluding(x => x.Password)
.Excluding(x => x.IsDeleted));
source.Should().BeEquivalentTo(result, _ => _.Excluding(x => x.Name));
}
}
}
In this test we:
User
class.UserHttpResponse
type.Name
property.result
≡ source
and source
≡ result
(in order not to miss anything). Note that we exclude every property that is not present in any of the classes, instead of using ExcludingMissingMembers()
which excludes properties with typos and distinct spelling (the test won't be able to detect the SerializedException
vs SerialisedException
issue).Default mapping tests for classes with the same properties (e.g. BlogPost
>> BlogPostDeletedEvent
) can be written in a more generic and elegant way:
public class SimpleMappings : MappingsTestsBase<MappingsSetup>
{
[Theory]
[ClassData(typeof(MappingTestData))]
public void Should_map(Type sourceType, Type destinationType)
{
// arrange
var source = F.Create(sourceType, new SpecimenContext(F));
// act
var result = M.Map(source, sourceType, destinationType);
// assert
result.Should().BeEquivalentTo(source);
}
private class MappingTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
return new List<object[]>
{
new object[] { typeof(BlogPost), typeof(BlogPostDeletedEvent) },
new object[] { typeof(Comment), typeof(CommentDeletedEvent) }
}
.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
You may have noticed that lovely [ClassData(typeof(MappingTestData))]
attribute. This is a clean way to separate the test data generated by the MappingTestData
class from the test implementation. As you can see, adding a new test for a new default mapping is a matter of one line of code:
return new List<object[]>
{
new object[] { typeof(BlogPost), typeof(BlogPostDeletedEvent) },
new object[] { typeof(Comment), typeof(CommentDeletedEvent) }
}
.GetEnumerator();
Pretty cool, isn't it?
Looks like you've read this far🎉 I hope it wasn't too boring🙂
Anyway, today we've dealt with unit tests for LINQ and mappings, which, in combination with techniques described in the previous post Best Practices to Write Unit Tests the Right Way (Part 1), provide a solid background and understanding of the key principles for writing clean, meaningful, and most importantly, useful unit tests.
Cheers!