Интеграционный тест Mediatr?
У меня есть некоторый обработчик входного запроса:
- читает данные о объекте из базы данных
- в прочитанных данных проверяет поле OwnerId сравнивая его с информацией о пользователе, создавшем запрос по определённым правилам. В частности, если объект содержится в публичной базе (OwnerId == 0), то можно видеть всем, иначе нужно чтобы OwnerId совпадал с CurrentUserId
В принципе, у меня всё работает, достаточно красиво разделены обязанности по разным классам о самом чтении (находится в Handler) и проверке видимости (находится в PostHandler)
У меня вопрос по покрытию этого тестами: я быстро написал юнит тесты на сам Handler и на PostHandler, но не понимаю, как мне написать интеграционные сценарии, как мне это протестировать и насколько вообще это нужно.
У меня есть определённые сомнения, что это прям так необходимо. Я где-то слышал советы в духе "не тестируйте чтение-запись в базу, потому что майкрософт уже покрыла тестами EF, так что вы делаете лишнюю работу". Тут с одной стороны, у меня есть аргумент "за" то, чтобы не писать: фактически за корректную работу pipeline'ов отвечает сам Mediatr (что после Handler'а запустятся PostHandler'ы) и я могу рассчитывать на то, что пока я не обновлю нугеты на версию с breaking changes всё будет работать как задумывалось. С другой стороны, сколько ни читай release notes, вот как раз хочется как раз соломку и подстелить и видеть, поломает ли обновление работу приложения или нет. Тем более юнит-тесты написаны, значит пора двигаться дальше по пирамиде тестирования.
Но допустим, решено, что пора писать более общие тесты. Как это правильно сделать технически?
Вот мои классы для конкретики, с некоторыми упрощениями.
Запрос:
public class Query : IRequest<Result>
{
public Query(int personId, int currentUserId)
{
this.PersonId = personId;
this.CurrentUserId = currentUserId;
}
public int PersonId { get; }
public int CurrentUserId { get; }
}
Ответ:
public class Result
{
public Result(PersonDto person)
{
this.Person = person;
}
public PersonDto Person { get; }
}
Обработчик:
public class Handler : BaseHandler, IRequestHandler<Query, Result>
{
public Handler(ApplicationDbContext applicationDbContext)
: base(applicationDbContext)
{
}
public async Task<Result> Handle(Query request, CancellationToken cancellationToken)
{
const string query = @"
SELECT
P.Id
, P.OwnerId
, P.FirstName
, P.LastName
FROM dbo.Persons P
WHERE 1 = 1
AND P.Id = @personId
";
var parameters = new
{
@currentUserId = request.CurrentUserId,
@personId = request.PersonId,
};
var person = await this.Db.QueryFirstOrDefaultAsync<PersonDto>(new CommandDefinition(query, parameters, cancellationToken: cancellationToken));
return new Result(person);
}
}
Проверка бизнес-правил:
public class PostHandler : IRequestPostProcessor<Query, Result>
{
public Task Process(Query request, Result response, CancellationToken cancellationToken)
{
if (VisibleForEveryone(response.Person))
return Task.CompletedTask;
if (response.Person.OwnerId != request.CurrentUserId)
throw new SecurityException($"Current user id={request.CurrentUserId} is not an owner for person id={request.PersonId}.");
return Task.CompletedTask;
}
}
Конкретные юнит-тесты приводить не буду, для постобработчика они тривиальны, а для обработчика лезут в базу (и я намучавшись с чрезмерными упрощениями InMemory прям реальную БД на localdb\mssqllocaldb стал подымать, ужас и медленно, но куда деваться). В общем, юниты покрыты достаточно, а вот где (на каком уровне приложения) и как проверять, что создав запрос и отправив его в Mediatr будет вызван не просто Handler, но и обязательно вызовется PostHandler.
Собственно, я вижу две точки, которые влияют на это поведение:
- Сам код библиотеки Mediatr https://github.com/jbogard/MediatR/blob/master/src/MediatR/Pipeline/RequestPostProcessorBehavior.cs
- Код бутстрапера для asp.net-mvc https://github.com/jbogard/MediatR.Extensions.Microsoft.DependencyInjection который гарантирует что соберёт типовой эталонный загрузчик Mediatr чтобы не регистрировать самостоятельно портянку в Startup.cs
Поэтому по идее ничто не мешает мне сделать webapi проект, который подключит проект слоя application таким образом, что классы application слоя не соберутся в нужный мне pipeline, а будут какие-то баги.
Получается, что уровень проекта на котором проверять подобные связки -- самый нижний в иерархии проектов, прямо на уровне Webapi.Test проверять подобное, верно?
С другой стороны хотя кажется что самый правильный тест -- это создать реальный экземпляр Mediatr, кинуть в него запрос и проверять, что отработало, а что нет. Но нет понимания как замокать Handler и как замокать PostHandler, т.к. мне по сути нужно выполнить проверку pipeline, а конкретная реализация уже покрыта отдельно.
Поэтому возможно это вопрос на понимание как конкретно устроить моки для библиотеки Mediatr (у меня nUnit+FakeItEasy, но мне главное понять принцип, поэтому в ответе подойдёт любой фреймворк тестирования, некритично). А возможно и нет, тут не обойтись без более широкого ответа на концептуальном уровне. В общем, если у кого есть мысли по этой теме -- пишите, буду рад мнениям.
Ответы (1 шт):
В итоге я остановился на следующем решении:
В проекте Application появился конфигуратор для Mediatr:
public static class MediatrConfiguration
{
public static void Configure(IServiceCollection services)
{
services.AddMediatR(typeof(Values.GetList.Query).GetTypeInfo().Assembly);
}
}
И соответственно если раньше проект зависел только от медиатора:
<ItemGroup>
<PackageReference Include="MediatR" Version="7.0.0" />
</ItemGroup>
, то сейчас добавилась явная зависимость от штатного бутстрапера
<ItemGroup>
<PackageReference Include="MediatR" Version="7.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="7.0.0" />
</ItemGroup>
и через него одна неявная — от выбранного DI движка (Microsoft.Extensions.DependencyInjection.Abstractions)
Этот конфигуратор используется и в Startup.cs
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
MediatrConfiguration.Configure(services);
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
и при настройке интеграционных тестов:
public class TestBase
{
protected IMediator GetMediator()
{
var services = new ServiceCollection();
MediatrConfiguration.Configure(services);
var provider = services.BuildServiceProvider();
var mediator = provider.GetService<IMediator>();
return mediator;
}
}
Технически, можно было бы вынести в отдельный проект конфигурирование mediatr (на случай замены DI движка), но я решил что это мне не понадобится (или она и так тривиальна), архитектурную границу я вполне и так вижу, а вот иметь в проекте Application уже корректно собранные в пайплайны классы мне очень удобно и понятно (проекты Application.UnitTests и Application.IntegrationTests разделены только в демо-приложении, в реальном это один проект Application.Tests)
Итого, интеграционные тесты с mediatr получились примерно такого вида:
/// <summary>
/// Tests for both <see cref="Handler"/>, <see cref="PreHandler"/> and <see cref="PostHandler"/>
/// </summary>
public class IntegrationTest : TestBase
{
[Test]
public void ZeroValue_Throws()
{
var mediatr = GetMediator();
var id = 0;
var query = new Query(id);
var ex = Assert.ThrowsAsync<Exception>(async () => await mediatr.Send(query));
StringAssert.AreEqualIgnoringCase("Empty request.", ex.Message);
}
...
}

