Unit Test ASP.NET CORE 2.1 System.NullReferenceException response

172
18 мая 2019, 19:20

Делал модульные тесты для проекта ASP.NET CORE . Через Moq делал заглушки... Вообщем в коде самого теста всё как надо. Ошибка NullReferenceException возникает при вызове строки Response.Cookies.Append(userName, account.ExternalId);. Вот пример кода контроллера LoginController:

public async Task<IActionResult> Login(string userName) {
    var account = await _db.FindByUserNameAsync(userName);
    Response.Cookies.Append(userName, account.ExternalId);
    return Ok();
}

Вот пример кода из теста:

public async Task WhenLoginUserBobNameThenPageReturnOkAndCookieExist() {
    string userName = "Имя пользователя, которое есть в базе данных, 
         хотя это не важно, я же использую mock";
    var db = new Mock<ИнтерфейсБд>();
    var account = new Account() {
        ExternalId = userName,
    };
    db.Setup(repo => repo.FindByUserNameAsync(userName)).ReturnsAsync(account);
    var controller = new LoginController(db.Object);
    var loginResult = await controller.Login(userName);
    //var expected = controller.Request.Cookies[userName];
    Assert.IsType<OkResult>(loginResult);
    Assert.Equal(expected, userName);
}

Основная задача теста была проверить, что куки правильно сохранились (это и так понятно, но я новичок и поэтому всё равно хотел затестить), но столкнулся с такой ошибкой. Я так понимаю ошибка вызывается при обращении к полю Response - оно равно null. Но при сборке проекта сервис работает. Дело в неправильном построении теста. Может надо запускать объект Startup.cs?

Answer 1

Похоже, дело в строке:

db.Setup(repo => repo.FindByUserNameAsync(userName)).ReturnsAsync(account);

Здесь вы просите библиотеку Moq при вызове FindByUserNameAsynс с определёнными параметрами возвращать определённое значение. Если вы хотите поставить в соответствие конкретное значение, передавайте в качестве параметра константу. В данном случае можно поставить в соответствие любое строковое значение, поскольку метод будет вызван ровно один раз и у нас не будет нескольких вариантов. Любой значение записывается как It.IsAny<string>():

db.Setup(repo => repo.FindByUserNameAsync(It.IsAny<string>())).ReturnsAsync(account);

После этого изменения метод начнёт подставляться при любом значение параметра и будет возвращать существующее значение account.

UPDATE

Дописываю ответ после комментария. Если речь идёт о поле Request, то тестировать его изменение можно несколькими способами.

Способ номер один: если свойство Request публичное и изменяемое, мы можем установить его значение перед тестом.

var controller = new LoginController(db.Object);
controller.Request = new HttpRequest { … };

Этот метод не сработает в нашем случае, потому что свойство Request только для чтения. Мы могли бы попробовать способ номер два: если свойство только для чтения, но оно виртуальное, библиотека Moq может его переопределить.

var controllerMock = new Mock<LoginController>(db.Object);

Здесь возникает проблема: Moq переопределяет свойство Request, но также переопределяет и все остальные виртуальные методы. Если бы метод Login был виртуальным, мы не могли бы его протестировать.

В этом случае мы должны попросить библиотеку вызывать старые методы. Часть методов нам нужна для тестов и мы их должны явно перечислить:

controllerMock.SetupGet(x => x.Request)
              .Returns(new HttpRequest { … });
controllerMock.CallBase = true;

Невиртуальные методы будут вызываться, как и раньше. Виртуальные тоже будут взываться, как раньше, из-за CallBase. Свойство Request будет возвращать указанное значение.

Вроде подходящий вариант, но, заглянув в документацию, мы выясняем, что свойство Request невиртуальное, и мы не можем его переопределить. Как быть в этом случае?

Если код ваш, то вы можете сделать свойство виртуальным. Если вы при разработке библиотеки покрываете её тестами, все такие неправильные свойства и методы всплывают быстро. Но код базового котроллера System.Web.Mvc.Controller не ваш, изменить его вы не можете.

Используем способ три: избавимся от прямой зависимости. Добавим в LoginController метод:

public async Task<IActionResult> Login(string userName)
{
    var account = await _db.FindByUserNameAsync(userName);
    StoreUserInCookied(userName, account.ExternalId);
    return Ok();
}

public virtual void StoreUserInCookies(string username, string externalId)
{
    Response.Cookies.Append(username, account.ExternalId);
}

Для того, чтобы протестировать такой код, соберём всё, что мы уже знаем:

var controllerMock = new Mock<LoginController>(db.Object);
controllerMock.Setup(x => x.StoreUserInCookies("john doe", It.IsAny<string>());
controllerMock.CallBase = true;
. . .
await controllerMock.Object.Login("john doe");

Видим, что код не очень простой. Это произошло в том числе и потому, что мы перегрузили наш контроллер ответственностью. Логику складывания пользовательских данных в куки и их извлечения можно вынести совсем в отдельный класс:

public class CookieStorage
{
    public virtual void StoreUser(HttpCookie cookie, string name, string id)
    {
        cookie.Append(name, id);
    }
    . . .
}

Внедрить такой класс в LoginController можно через конструктор. Метод Login станет выглядеть так:

public async Task<IActionResult> Login(string userName)
{
    var account = await _db.FindByUserNameAsync(userName);
    _cookieStorage.StoreUser(Request.Cookie, userName, account.ExternalId);
    return Ok();
}

Поскольку мы сделали метод StoreUser виртуальным, мы можем использовать его в качестве заглушки.

Здесь у нас получается случай, когда тестирование подсказывает то, как улучшить дизайн системы. К методу StoreUser надо написать парный GetUser, который, вероятно, будет вызываться из самых разных контроллеров в нескольких местах.

Создав CookieStorage мы инкапсулировали логику работы с куками в одном месте и можем вносить согласованные изменения.

READ ALSO
Простой файловый менеджер на Mono под Linux

Простой файловый менеджер на Mono под Linux

Необходимо написать простейший проводник или файловый менеджер, который будет работать с НМЖД и внешним USB накопителемДолжны быть функции...

141
Получить данные с двух таблиц

Получить данные с двух таблиц

Для работы с базами данных использую ORM RedBeanPHP

145
Ошибка mysql SQLSTATE[HY000] [2054]

Ошибка mysql SQLSTATE[HY000] [2054]

При попытки подключения к MySQL из PHP Yii2, да и просто из PHP методом PDO Происходит ошибка: SQLSTATE[HY000] [2054] The server requested authentication method unknown to the client

186