Модульное тестирование C# EF Core с использованием Moq

Я работал над настройкой модульного тестирования с помощью 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 не обрабатывается правильно?

Вы можете попробовать установить для Mocks строгий режим, а затем посмотреть, где вы получите MockException в тестовом запуске, который сообщит вам, какая настройка неправильная или вы забыли. Вы кодируете вызовы User.Create, игнорируя ваш макет MockDbUser. Следовательно, ваша настройка AddAsync для этого отсутствует, посколькуockDbUser не используется в реальном коде.

Ralf 09.04.2024 15:53

Не используйте DbContext.AddAsync. Оно ничего не добавляет, как и Add ничего не добавляет. Add прикрепляет отсоединенный объект (т. е. тот, который не загружен из базы данных) в состоянии Added, чтобы SaveChanges мог генерировать для него INSERT, когда он сохраняет ВСЕ ожидающие изменения. AddAsync используется ТОЛЬКО для политик генерации редких идентификаторов, таких как HiLo или диапазоны клиентов, которым необходимо получить некоторые данные с сервера для расчета идентификаторов на клиенте.

Panagiotis Kanavos 09.04.2024 15:53

@PanagiotisKanavos AddAsync — это моя функция, которая использует Add(entity) efcore и сохраняет изменения асинхронно для своего конкретного контекста.

Travis 09.04.2024 15:55

В этом случае вам необходимо опубликовать свой код, иначе помочь невозможно.

Panagiotis Kanavos 09.04.2024 15:55

@Ralf Не знал о строгости для Mocks, я посмотрю документацию, пойду оттуда и посмотрю, найду ли я что-нибудь

Travis 09.04.2024 15:55

@Travis and save changes async — критическая ошибка, которая полностью нарушает шаблон единицы работы. DbContext — это единица работы, а не соединение. SaveChanges фиксирует единицу работы, что означает, что ваш код вполне может УДАЛИТЬ 43 объекта продукта вместе с вставкой 1 пользователя.

Panagiotis Kanavos 09.04.2024 15:56

@PanagiotisKanavos Я добавил в вопрос свою функцию AddAsync. Итак, вы говорите, что в функции AddAsync мне следует использовать _context.SaveChanges вместо SaveChangesAsync?

Travis 09.04.2024 16:01

Нет, я говорю, что вся идея этого псевдорепозитория CRUD неверна. Это известный антипаттерн, то есть ошибка. Как вы собираетесь откатывать изменения, если что-то пойдет не так? Вы пытаетесь поместить абстракцию многокомпонентной единицы работы высокого уровня внутри класса CRUD низкого уровня. ORM, такие как NHibernate и EF, пытаются создать впечатление работы с объектной базой данных или объектами в памяти, а не с таблицами и сущностями.

Panagiotis Kanavos 09.04.2024 16:07

@PanagiotisKanavos Какую хорошую документацию можно было бы прочитать? Я хотел бы провести рефакторинг, чтобы избежать указанного антипаттерна... Я этого не знал и определенно хотел бы сделать это правильно. Погуглив, я нашел эту статью: Learn.microsoft.com/en-us/aspnet/mvc/overview/older-versions‌​/… Решил, что это место для начала.

Travis 09.04.2024 17:04

@Travis Относитесь к каждому тесту как к изолированному блоку. Моки должны создаваться для каждого теста, так как ваш текущий пример приведет к тому, что конфликтующие настройки будут переопределять друг друга в зависимости от порядка выполнения.

Nkosi 09.04.2024 17:19
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
10
75
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

У вас неверная настройка 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. Я добавил эту часть, и значение больше не было нулевым результатом.

Travis 09.04.2024 18:41

Другие вопросы по теме