paint-brush
Best Practices to Write Unit Tests the Right Way (Part 2)by@powerz
629 reads
629 reads

Best Practices to Write Unit Tests the Right Way (Part 2)

by Aleksei ZagoskinMay 18th, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

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 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. 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.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Best Practices to Write Unit Tests the Right Way (Part 2)
Aleksei Zagoskin HackerNoon profile picture

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.

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
}

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

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:


LINQ test results

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

Testing Mappings (AutoMapper)

Do we need tests for mappings in the 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>();
        }
    }
    


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:

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


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?

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 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!