Я работал над настройкой модульного тестирования с помощью Moq для некоторых наших команд CQRS. Я смоделировал обработчик команд, который работает в моей среде для запуска команды.
Проблема, с которой я сталкиваюсь, - это часть модульного тестирования. При попытке имитировать вызов AddAsync я продолжаю получать нулевое значение для newUser
в функции RaiseDomainEvent
.
Вот настройка команды и обработчика. По сути, она означает, что если есть какие-либо настройки, вызовите событие домена.
Объекты настроек пользователя/пользователя – здесь пытаемся выполнить расширенное моделирование предметной области.
public class User
{
private User(int id, string username)
{
UserId = id;
UserName = username;
}
public int UserId {get; private set;}
public string UserName {get; private set;}
public List<UserSetting> UserSettings {get; private set;}
public static User Create(int id, string username)
{
return new User(id, username);
}
public static DomainEvent RaiseDomainEvent(User user, List<UserSetting> settings)
{
// Raise the event and handle it
}
}
public class UserSetting
{
public int SettingId {get; set;}
public int UserId {get; set;}
public int SettingTypeId {get; set;}
public string SettingValue {get; set;}
}
Добавить пользовательскую команду
public sealed record AddUserCommand (
int UserId, string UserName, List<UserSetting> UserSettings
) : ICommand;
Добавить обработчик пользовательских команд — использует Mediatr
internal sealed class AddUserCommandHandler : ICommandHandler<AddUserCommand>
{
private readonly IMapper _mapper;
private readonly IUserRepo _userRepo;
public AddUserCommandHandler(IMapper mapper, IUserRepo userRepo)
{
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
_userRepo = userRepo ?? throw new ArgumentNullException(nameof(userRepo));
}
public async Task<Result> Handle(AddUserCommand cmd, CancellationToken ct = default)
{
User dbUser = await _userRepo.GetUserByIdAsync(cmd.UserId, ct);
if (dbUser != null) throw new Exception($"User exists with id: {cmd.UserId}");
User newUser = User.Create(cmd.UserId, cmd.UserName);
// Since the id is generated on the DB add, we need to get it to add a FK relationship to add settings to it
newUser = await _userRepo.AddAsync(newUser, ct);
if (cmd.UserSettings != null && cmd.UserSettings.Any())
{
User.RaiseDomainEvent(newUser, cmd.UserSettings);
}
return new Result
{
Success = true
};
}
}
Добавить асинхронный
public async Task<User> AddAsync(User entity, CancellationToken ct = default)
{
_context.Users.Add(entity);
await _context.SaveChangesAsync(ct);
return entity;
}
Приведенный выше код работает в своем реальном качестве.
Проблема возникает, когда я пытаюсь провести модульное тестирование.
Я тестирую его с использованием Moq.
public class AddUserCommandHandlerTests
{
private readonly Mock<IMapper> _mapperMock;
private readonly Mock<IUserRepo> _userRepoMock;
public AddUserCommandHandlerTests()
{
_mapperMock = new Mock<IMapper>();
_userRepoMock = new Mock<IUserRepo>();
}
// This test works!
[Fact]
public async Task Handle_Should_Return_SuccessResult_NoSettings()
{
AddUserCommand cmd = new AddUserCommand(0, "stacktest", new List<UserSetting>());
User mockDbUser = null;
_userRepoMock.Setup(x => x.GetUserByIdAsync(cmd.UserId, default).Result).Returns(mockDbUser);
var handler = new AddUserCommandHandler(_mapperMock.Object, _userRepoMock.Object);
var result = await handler.Handle(cmd, default);
Assert.True(result.Success);
}
// This test fails, saying that newUser is null at User.RaiseDomainEvent(newUser, cmd.UserSettings)
[Fact]
public async Task Handle_Should_Return_SuccessResult_WithSettings()
{
AddUserCommand cmd = new AddUserCommand(0, "stacktest", new List<UserSetting> { SettingId = 0, SettingTypeId = 1, UserId = 0, SettingValue = "Test"});
User mockDbUser = null;
User addedDbUser = User.Create(10, cmd.UserName);
_userRepoMock.Setup(x => x.GetUserByIdAsync(cmd.UserId, default).Result).Returns(mockDbUser);
_userRepoMock.Setup(x => x.AddAsync(mockDbUser, default).Result).Returns(addedDbUser);
var handler = new AddUserCommandHandler(_mapperMock.Object, _userRepoMock.Object);
var result = await handler.Handle(cmd, default);
Assert.True(result.Success);
}
}
Почему результат AddAsync не обрабатывается правильно?
Не используйте DbContext.AddAsync
. Оно ничего не добавляет, как и Add
ничего не добавляет. Add
прикрепляет отсоединенный объект (т. е. тот, который не загружен из базы данных) в состоянии Added
, чтобы SaveChanges
мог генерировать для него INSERT, когда он сохраняет ВСЕ ожидающие изменения. AddAsync
используется ТОЛЬКО для политик генерации редких идентификаторов, таких как HiLo или диапазоны клиентов, которым необходимо получить некоторые данные с сервера для расчета идентификаторов на клиенте.
@PanagiotisKanavos AddAsync — это моя функция, которая использует Add(entity) efcore и сохраняет изменения асинхронно для своего конкретного контекста.
В этом случае вам необходимо опубликовать свой код, иначе помочь невозможно.
@Ralf Не знал о строгости для Mocks, я посмотрю документацию, пойду оттуда и посмотрю, найду ли я что-нибудь
@Travis and save changes async
— критическая ошибка, которая полностью нарушает шаблон единицы работы. DbContext — это единица работы, а не соединение. SaveChanges
фиксирует единицу работы, что означает, что ваш код вполне может УДАЛИТЬ 43 объекта продукта вместе с вставкой 1 пользователя.
@PanagiotisKanavos Я добавил в вопрос свою функцию AddAsync. Итак, вы говорите, что в функции AddAsync мне следует использовать _context.SaveChanges вместо SaveChangesAsync?
Нет, я говорю, что вся идея этого псевдорепозитория CRUD неверна. Это известный антипаттерн, то есть ошибка. Как вы собираетесь откатывать изменения, если что-то пойдет не так? Вы пытаетесь поместить абстракцию многокомпонентной единицы работы высокого уровня внутри класса CRUD низкого уровня. ORM, такие как NHibernate и EF, пытаются создать впечатление работы с объектной базой данных или объектами в памяти, а не с таблицами и сущностями.
@PanagiotisKanavos Какую хорошую документацию можно было бы прочитать? Я хотел бы провести рефакторинг, чтобы избежать указанного антипаттерна... Я этого не знал и определенно хотел бы сделать это правильно. Погуглив, я нашел эту статью: Learn.microsoft.com/en-us/aspnet/mvc/overview/older-versions/… Решил, что это место для начала.
@Travis Относитесь к каждому тесту как к изолированному блоку. Моки должны создаваться для каждого теста, так как ваш текущий пример приведет к тому, что конфликтующие настройки будут переопределять друг друга в зависимости от порядка выполнения.
У вас неверная настройка AddAsync
.
Вы регистрируете его для вызова с помощью mockDbUser
, но Handle
не вызывает AddAsync
с помощью mockDbUser
. Поскольку фактический объект, используемый Handle
для вызова AddAsync
, генерируется внутри самого Handle
, у вас не будет к нему доступа. Поэтому, когда Handle
вызывает AddAsync
в модульном тесте, он игнорирует вашу настройку, поскольку параметры не соответствуют тем, что у вас есть в настройке.
Вы можете решить проблему, используя методы Moq Is
. It.IsAny
позволяет указать любой аргумент заданного типа в качестве параметра настраиваемого метода:
_userRepoMock.SetUp(x => x.AddAsync(It.IsAny<User>(), default).Result).Returns(addedDbUser);
It.Is
позволяет вам указать любой аргумент заданного типа, который соответствует некоторым выбранным условиям, в качестве параметра настраиваемого метода, например:
_userRepoMock.SetUp(x => x.AddAsync(It.Is<User>(x => x.UserId == 10 && x.UserName == cmd.UserName), default).Result).Returns(addedDbUser);
Ах! В этом есть большой смысл, я думал, что он ищет ожидаемый объект, а не MockDbUser. Я добавил эту часть, и значение больше не было нулевым результатом.
Вы можете попробовать установить для Mocks строгий режим, а затем посмотреть, где вы получите MockException в тестовом запуске, который сообщит вам, какая настройка неправильная или вы забыли. Вы кодируете вызовы User.Create, игнорируя ваш макет MockDbUser. Следовательно, ваша настройка AddAsync для этого отсутствует, посколькуockDbUser не используется в реальном коде.