DEV Community

Aleksei Zagoskin
Aleksei Zagoskin

Posted on • Originally published at zagosk.in on

Unit Tests Done Right (Part 2)

In the previous article, Unit Tests Done Right (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.

Testing LINQ

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
}

Enter fullscreen mode Exit fullscreen mode

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?

  1. We want to make sure that this method returns a user by ID if it hasn't been deleted.
  2. The method returns null if the user with the given ID exists, but is marked as deleted.
  3. The method returns 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);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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());
    }
}

Enter fullscreen mode Exit fullscreen mode

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();
}

Enter fullscreen mode Exit fullscreen mode

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();
}

Enter fullscreen mode Exit fullscreen mode

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();
}

Enter fullscreen mode Exit fullscreen mode

Let's run our tests:

LINQ test results

Looks green enough, so let's move on to the next example.

Testing Mappings (AutoMapper)

Do we need tests for mappings at first place?

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:

  1. 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.
  2. 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).
  3. 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>();
    }
}

Enter fullscreen mode Exit fullscreen mode

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 Seriali z ed and Seriali s ed 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; }
}

Enter fullscreen mode Exit fullscreen mode

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>();
    }
}

Enter fullscreen mode Exit fullscreen mode

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();
    }
}

Enter fullscreen mode Exit fullscreen mode

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));
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

In this test we:

  1. Generate a random instance of the User class.
  2. Map it to the UserHttpResponse type.
  3. Verify the Name property.
  4. Verify the remaining properties by comparing resultsource and sourceresult (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();
        }
    }

Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

Pretty cool, isn't it?

Final Words

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 Unit Tests Done Right (Part 1), provide a solid background and understanding of the key principles for writing clean, meaningful, and most importantly, useful unit tests.

Cheers!

Top comments (0)