在上一篇文章以正确方式编写单元测试的最佳实践(第 1 部分)中,我们探讨了单元测试的一些最佳实践,然后编制了一个必备库列表,这些库可以极大地提高测试质量。但是,它没有涵盖一些常见的场景,比如测试 LINQ 和映射,所以我决定用另外一篇示例驱动的帖子来填补这个空白。
\ 准备好进一步提高您的单元测试技能了吗?让我们开始吧?
\ 本文中的示例广泛使用了上一篇文章中描述的出色工具,因此最好从第 1 部分开始,这样我们将要分析的代码更有意义。
测试 LINQ
事实上,所有 C# 开发人员都喜欢 LINQ,但我们也应该尊重它,并用测试覆盖查询。顺便说一句,这是 LINQ 相对于 SQL 的众多优势之一(您见过为 SQL 编写至少一个单元测试的真人吗?我也没有)。
\ 让我们看一个例子。
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 }
在这个例子中,我们有一个典型的存储库,它有一个按 ID 返回用户的方法, _db.Users
返回IQueryable<User>
。那么我们需要在这里测试什么?
\
- 如果用户没有被删除,我们希望确保此方法按 ID 返回用户。
- 如果具有给定 ID 的用户存在,则该方法返回
null
,但被标记为已删除。 - 如果具有给定 ID 的用户不存在,则该方法返回
null
。
\ 换句话说,所有的Where
、 OrderBy
和其他方法调用都必须被测试覆盖。现在让我们编写并讨论第一个测试(?提醒:测试结构在上一篇文章中解释过):
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); } } }
首先,我们创建了一个满足要求的用户(未删除)并将其添加到一堆其他用户中(具有随机不同的 ID 和IsDeleted
值)。然后我们模拟数据源以返回打乱后的数据集。请注意,我们打乱了用户列表以将expectedResult
放置在随机位置。最后,我们调用了Repository.GetUser
并验证了结果。
\ Shuffle()
是一个小而有用的扩展方法:
\
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()); } }
\ 第二个测试几乎与第一个相同。
\
[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(); }
\ 这里我们将我们的用户标记为已删除,并检查结果是否为null
。
\ 对于最后一个测试,我们生成一个随机用户列表和一个不属于任何用户的唯一 ID:
\
[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()
是另一个经常在测试中使用的有用方法:
\
public static int CreateIntNotIn(this Fixture f, ICollection<int> except) { var maxValue = except.Count * 2; return Enumerable.Range(1, maxValue) .Except(except) .ToList() .GetRandomElement(); }
\ 让我们运行我们的测试:
\
✅ 看起来够绿了,让我们继续下一个例子。
测试映射 (AutoMapper)
我们首先需要对映射进行测试吗?
尽管许多开发人员声称这很无聊或浪费时间,但我相信映射的单元测试在开发过程中起着关键作用,原因如下:
-
很容易忽略数据类型中微小但重要的差异。例如,当 A 类的属性是
DateTimeOffset
类型,而 B 类的相应属性是DateTime
类型时。默认映射不会崩溃,但会产生不正确的结果。 -
新的或删除的属性。使用映射测试,每当我们重构其中一个类时,就不可能忘记更改另一个类(因为编写良好的测试不会通过)。
-
错别字和不同的拼写。我们都是人类,通常不会注意到拼写错误,这反过来又会导致错误的映射结果。例子:
\
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>(); } }
\ 在上面的代码中,很容易忽略不同拼写的问题,Rider / Resharper 也无济于事,因为 Seriali z ed 和 Seriali s ed 看起来都很好。在这种情况下,映射器将始终将目标属性设置为null
,这绝对是不可取的。
\ 我希望我设法说服了你并证明了单元测试对映射的价值,所以让我们继续下一个例子。我们将使用AutoMapper ,但从测试的角度来看,映射器的选择没有区别。例如,我们可以用Mapster替换 AutoMapper,它不会以任何方式影响我们的测试。此外,现有的测试将表明我们的映射重构是否成功,这是进行单元测试的要点之一?
\ 假设我们有这些实体:
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; } }
\ 和映射:
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>(); } }
\ 没什么特别花哨的: User
>> UserHttpResponse
的映射是稍微定制的,而另外两个是默认的“按原样映射”指令。让我们为我们的映射配置文件编写测试。
首先,这是可用于所有映射单元测试的基类:
\
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(); } }
\ 以及我们对User
>> UserHttpResponse
映射的第一个测试:
\
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)); } } }
\ 在这个测试中我们:
- 生成
User
类的随机实例。 - 将其映射到
UserHttpResponse
类型。 - 验证
Name
属性。 - 通过比较
result
≡source
和source
≡result
来验证剩余的属性(为了不遗漏任何东西)。请注意,我们排除了任何类中不存在的每个属性,而不是使用ExcludingMissingMembers()
排除具有拼写错误和不同拼写的属性(测试将无法检测到SerializedException
与SerialisedException
问题)。
具有相同属性的类(例如BlogPost
>> BlogPostDeletedEvent
)的默认映射测试可以用更通用和优雅的方式编写:
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(); } }
\ 您可能已经注意到可爱的[ClassData(typeof(MappingTestData))]
属性。这是一种将MappingTestData
类生成的测试数据与测试实现分开的干净方法。如您所见,为新的默认映射添加新测试只需要一行代码:
\
return new List<object[]> { new object[] { typeof(BlogPost), typeof(BlogPostDeletedEvent) }, new object[] { typeof(Comment), typeof(CommentDeletedEvent) } } .GetEnumerator();
\ 很酷,不是吗?
最后的话
看起来你已经读到这里了?我希望它不会太无聊?
无论如何,今天我们已经处理了 LINQ 和映射的单元测试,结合上一篇文章以正确方式编写单元测试的最佳实践(第 1 部分)中描述的技术,提供了坚实的背景和对关键原则的理解用于编写干净、有意义且最重要的是有用的单元测试。
\ 干杯!
原文: https://hackernoon.com/best-practices-to-write-unit-tests-the-right-way-part-2?source=rss