CProgramming

Введение и подготовка к работе

В данной статье мы продолжаем работу над построением и развертыванием “ходячего скелета” приложения при помощи ASP.NET Core WebApi и клиента Angular. На данном этапе API уже почти готов. У нас есть контроллер, принимающий местоположение города, сервис, вызывающий сторонний API OpenWeatherMap для возврата прогноза погоды в заданном городе, а в рамках предыдущей статьи мы также добавили фреймворк xUnit для описания этого API. 

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

Если вы начинаете знакомство с материалом именно с этой статьи, то можете клонировать следующую ветку и продолжить изменять код с этого момента (имейте в виду, что для работы вам потребуется .NET Core SDK):

$ git clone -b 3_adding-tests --single-branch [email protected]:jsheridanwells/WeatherWalkingSkeleton.git
$ cd WeatherWalkingSkeleton
$ dotnet restore

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

Процесс TDD 

В предыдущем шаге мы начали с одного метода контроллера, который вызывал один метод сервиса, а затем настроили библиотеку тестирования с помощью xUnit и Moq. Имея один готовый к работе фреймворк для тестирования, мы будем использовать модульные тесты для внесения некоторых улучшений в конечную точку нашего API, которая получает прогноз погоды.

TDD расшифровывается как “разработка через тестирование” и представляет собой технику, позволяющую обеспечить выполнение кода в соответствии с нашими требованиями, задокументировать текущие ожидания и произвести тесты, чтобы убедиться в том, что вносимые изменения не нарушат уже имеющуюся функциональность, что особенно важно при развертывании кода в средах более высокого уровня. 

При разработке мы будем следовать паттерну Red, Green, Refactor (красный/зеленый/рефакторинг):

  1. Красный: мы будем писать тест и обеспечивать его провал. Таким образом мы убедимся, что вносимые изменения меняют поведение программы нужным нам образом и не вызывают неожиданных побочных эффектов.
  2. Зеленый: мы будем модифицировать методы, чтобы тесты проходили успешно.
  3. Рефакторинг: мы будем выполнять весь необходимый рефакторинг в отношении внесенных изменений, чтобы код выглядел наилучшим образом и при этом проходил все тесты.

Изменения

На данный момент наш API состоит из одной конечной точки — GET http:/loicalhost:5000/WeatherForecast/:location, которая закрывает WeatherForecastController и вызывает метод Get. Затем в данном методе вызывается метод OpenWeatherService.GetFiveDayForecastAsync, возвращающий список из пяти прогнозов на следующие пятнадцать часов. 

Несмотря на то, что ручное тестирование этой конечной точки при помощи Postman и выполнение трех текущих модульных тестов доказывает, что все работает, на данный момент наши методы весьма неустойчивы. Если потребитель API вызывает конечную точку, не указав локацию или указав ее неверно, то неожидаемый результат не обрабатывается. Если мы развернем этот API в другой среде, не зарегистрировав ключ API OpenWeatherMap, то нам потребуется обработать и этот сбой так, чтобы сообщить о данной проблеме другим разработчикам. Кроме того, API OpenWeatherMap может дать сбой сам по себе, и нам нужна возможность сообщить об источнике проблемы. На данный же момент в случае возникновения неожиданного события от API вернется лишь длинное и бесполезное исключение NullReferceneException.

Давайте рефакторизуем методы для обработки следующих сценариев:

  1. Один из пользователей отправляет локацию, которую API OpenWeatherMap не распознает: на мой взглядтакое может происходить часто и не по вине приложения, поэтому для обработки данного случая мы будем отправлять обратно пользователю пояснительное сообщение, не выбрасывая исключение.
  2. Недействительный ключ API OpenWeatherMap: на данный момент приложение выполняется на локальных машинах с настроенным ключом API. При развертывании в других средах для его работы их серверам также потребуется ключ API. Если приложение будет развернуто без такого ключа или срок его действия истечет, нам потребуется пояснить это для всех разработчиков при возвращении OpenWeatherMap непредусмотренного ответа.
  3. OpenWeatherMap возвращает собственную ошибку: поскольку это сторонний ресурс, мы не можем гарантировать его постоянную работоспособность. На случай провала запроса к OpenWeatherMap нам нужно также этот сценарий обработать.

Тестирование сервиса

Сначала мы изменим класс OpenWeatherService. Откройте соответствующий файл модульного теста: ./WeatherWalkingSkeletonTests/Services_Tests/OpenWeatherService_Tests.cs. Обратите внимание, что в предыдущей статье мы также создали статический класс-фикстуру OpenWeatherResponses, возвращающий от API OpenWeatherMap три симулированных ответа с ошибками: NotFoundResponse, UnauthorizedResponse и InternalErrorResponse. Мы будем использовать эти ответы для активации ошибок, которые можем получить от стороннего API.

Добавьте в OpenWeatherService_Tests следующие тесты:

[Fact]
public async Task Returns_OpenWeatherException_When_Called_With_Bad_Argument()
{
    var opts = OptionsBuilder.OpenWeatherConfig();
    var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.NotFoundResponse,
        HttpStatusCode.NotFound);
    var sut = new OpenWeatherService(opts, clientFactory);

    var result = await Assert.ThrowsAsync<OpenWeatherException>(() => sut.GetFiveDayForecastAsync("Westeros"));
    Assert.Equal(404, (int)result.StatusCode);
}

[Fact]
public async Task Returns_OpenWeatherException_When_Unauthorized()
{
    var opts = OptionsBuilder.OpenWeatherConfig();
    var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.UnauthorizedResponse,
        HttpStatusCode.Unauthorized);
    var sut = new OpenWeatherService(opts, clientFactory);
    var result = await Assert.ThrowsAsync<OpenWeatherException>(() => sut.GetFiveDayForecastAsync("Chicago"));
    Assert.Equal(401, (int)result.StatusCode);
}

[Fact]
public async Task Returns_OpenWeatherException_On_OpenWeatherInternalError()
{
    var opts = OptionsBuilder.OpenWeatherConfig();
    var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.InternalErrorResponse,
        HttpStatusCode.InternalServerError);
    var sut = new OpenWeatherService(opts, clientFactory);

    var result = await Assert.ThrowsAsync<OpenWeatherException>(() => sut.GetFiveDayForecastAsync("New York"));
    Assert.Equal(500, (int)result.StatusCode);
}

Эти тесты используют базовую настройку двух предыдущих тестов, но для них мы настроили другие возможные ответы с ошибками от имитированного API. Мы хотим, чтобы при возвращении OpenWeatherMap неожиданного результата наш сервис выбрасывал настраиваемое исключение OpenWeatherException. Это исключение будет сообщать получающему классу, что причина сбоя в стороннем API.

Если вы запустите тест, используя выполнитель тестов из IDE или набрав команду $ dotnet test в терминале, то увидите, что тесты провалятся. Мы ожидали получить настроенное исключение, но вместо него выбрасывается NullReferenceException, поскольку наш сервис пока еще не способен обрабатывать ответ, который не может проанализировать.

Откройте ./Api/Services/OpenWeatherService.cs и перейдите к методу GetFiveDayForecastAsync. Проходя по нему строка за строкой, найдите точку, где метод ожидает ответа от OpenWeatherMap:

var response = await client.GetAsync(url);

Далее проверяем, успешен ли ответ, и если да, то десериализуем его. Если результат окажется иным, создадим и выбросим OpenWeatherException, чтобы получающий класс мог ответить соответствующим образом. Блок if/else будет выглядеть так (ниже я скопирую весь метод целиком):

if (response.IsSuccessStatusCode)
{
    // десериализуем и возвращаем OpenWeatherResponse
    var json = await response.Content.ReadAsStringAsync();
    var openWeatherResponse = JsonSerializer.Deserialize<OpenWeatherResponse>(json);
    foreach (var forecast in openWeatherResponse.Forecasts)
    {
        forecasts.Add(new WeatherForecast
        {
            Date = new DateTime(forecast.Dt),
            Temp = forecast.Temps.Temp,
            FeelsLike = forecast.Temps.FeelsLike,
            TempMin = forecast.Temps.TempMin,
            TempMax = forecast.Temps.TempMax,
        });
    }     return forecasts;
}
else
{
    // создаем исключение с информацией от стороннего API
    throw new OpenWeatherException(response.StatusCode, "Error response from OpenWeatherApi: " + response.ReasonPhrase);
}

Это исключение будет содержать HTTP-статус OpenWeatherMap и простое сообщение, а получающий его класс затем сможет создать на основе этой информации логику.

Так должен выглядеть весь метод GetFiveDayFirecastAsync:

public async Task<List<WeatherForecast>> GetFiveDayForecastAsync(string location, Unit unit = Unit.Metric)
{
    string url = BuildOpenWeatherUrl("forecast", location, unit);
    var forecasts = new List<WeatherForecast>();    var client = _httpFactory.CreateClient("OpenWeatherClient");
    var response = await client.GetAsync(url);    if (response.IsSuccessStatusCode)
    {
        var json = await response.Content.ReadAsStringAsync();
        var openWeatherResponse = JsonSerializer.Deserialize<OpenWeatherResponse>(json);
        foreach (var forecast in openWeatherResponse.Forecasts)
        {
            forecasts.Add(new WeatherForecast
            {
                Date = new DateTime(forecast.Dt),
                Temp = forecast.Temps.Temp,
                FeelsLike = forecast.Temps.FeelsLike,
                TempMin = forecast.Temps.TempMin,
                TempMax = forecast.Temps.TempMax,
            });
        }

        return forecasts;
    }
    else
    {
        throw new OpenWeatherException(response.StatusCode, "Error response from OpenWeatherApi: " + response.ReasonPhrase);
    } 
}

Снова запустите тесты — теперь они должны пройти успешно. К этому моменту мы проделали красный и зеленый шаги процесса тестирования. Вы можете на свое усмотрение выполнить дополнительный рефакторинг этого метода или оставить его как есть.

Тестирование контроллера

Теперь наш сервис может успешно отображать сбой при получении ответа от API OpenWeatherMap. Далее нам нужно, чтобы контроллер передавал эти исключения обратно получающим клиентам.

Что касается трех наших изначальных сценариев, то контроллер может отвечать на каждый из них следующим образом:

  1. Если OpenWeatherMap не распознает локацию, то контроллер может вернуть ответ 400 BadRequest и сообщить получателю имя вызвавшей сбой локации. Кроме того, если запрос произведен без указания локации, то мы должны вернуть 400 еще до вызова этого сервиса. 
  2. Если OpenWeatherMap возвращает ответ Unauthorized, то это вызвано неверным ключом API, и в данном проекте причина тому, скорее всего, в плохой конфигурации. Мы будем возвращать 500 Internal Server Error с сообщением от API OpenWeatherMap, которое будет указывать на неавторизованный запрос.
  3. Если возникнет какая-либо другая ошибка, мы будем возвращать другой ответ 500 с сообщением от OpenWeatherMap. В завершении мы также будем возвращать ответ 500 для любого другого исключения, выбрасываемого в рамках приложения.

Из приведенных выше ответов проистекают три теста, которые мы добавим в ./Tests/Controllers_Tests/:

[Fact]
public async Task Returns_400_Result_When_Missing_Location()
{
    var opts = OptionsBuilder.OpenWeatherConfig();
    var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.NotFoundResponse);
    var service = new OpenWeatherService(opts, clientFactory);
    var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service);

    var result = await sut.Get(String.Empty) as ObjectResult;

    Assert.Equal(400, result.StatusCode);
}

[Fact]
public async Task Returns_BadRequestResult_When_Location_Not_Found()
{
    var opts = OptionsBuilder.OpenWeatherConfig();
    var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.NotFoundResponse,
        HttpStatusCode.NotFound);
    var service = new OpenWeatherService(opts, clientFactory);
    var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service);

    var result = await sut.Get("Westworld") as ObjectResult;

    Assert.Contains("not found", result.Value.ToString());
    Assert.Equal(400, result.StatusCode);
}
[Fact]
public async Task Returns_OpenWeatherException_When_Unauthorized()
{
    var opts = OptionsBuilder.OpenWeatherConfig();
    var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.UnauthorizedResponse,
        HttpStatusCode.Unauthorized);
    var sut = new OpenWeatherService(opts, clientFactory);

    var result = await Assert.ThrowsAsync<OpenWeatherException>(() => sut.GetFiveDayForecastAsync("Chicago"));
    Assert.Equal(401, (int)result.StatusCode);
}

[Fact]
public async Task Returns_500_When_Api_Returns_Error()
{
    var opts = OptionsBuilder.OpenWeatherConfig();
    var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.UnauthorizedResponse,
        HttpStatusCode.Unauthorized);
    var service = new OpenWeatherService(opts, clientFactory);
    var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service);

    var result = await sut.Get("Rio de Janeiro") as ObjectResult;

    Assert.Contains("Error response from OpenWeatherApi: Unauthorized", result.Value.ToString());
    Assert.Equal(500, result.StatusCode); 
}

При выполнении они должны давать сбой.

Теперь мы открываем тестируемый класс в ./Api/Controllers/WeatherForecastController.cs и находим метод Get(). Далее для проверки наличия в запросе допустимой локации добавляем в качестве первого его шага следующее:

[HttpGet]
public async Task<IActionResult> Get(string location, Unit unit = Unit.Metric)
{
   if (string.IsNullOrEmpty(location))
       return BadRequest("location parameter is missing");
   // [ ... ] 
}

Теперь три из четырех новых тестов должны давать сбой.

Прохождения остальных из них мы можем добиться, возвращая результат 400 Bar Request в случае, если OpenWeatherMap не может найти локацию, или 500 Internal Server Error в случае иной причины, сопроводив эти ответы пояснительным сообщением. Кроме того, мы можем обернуть логику в блок try/catch, который будет обрабатывать OpenWeatherException, как показано выше, а затем обрабатывать любое другое исключение. Обновленный метод Get() теперь может выглядеть так:

[HttpGet]
public async Task<IActionResult> Get(string location, Unit unit = Unit.Metric)
{
    if (string.IsNullOrEmpty(location))
        return BadRequest("location parameter is missing");
    try
    {
        var forecast = await _weatherService.GetFiveDayForecastAsync(location, unit);
        return Ok(forecast);
    }
    catch (OpenWeatherException e)
    {
        if (e.StatusCode == HttpStatusCode.NotFound)
            return BadRequest($"Location: \"{ location }\" not found.");
        else
            return StatusCode(500, e.Message);
    }
    catch (Exception e)
    {
        return StatusCode(500, e.Message);
    }
}

Запустите тесты, и можно считать дело успешно завершенным, если все девять из них пройдут успешно. Как и ранее, если вы хотите поэкспериментировать с другими способами обработки исключений, получаемых от OpnWeatherService, то теперь можете рефакторизовать метод, защищенный соответствующими тестами.

Заключение

В данном руководстве мы начали проект с конечной точкой API, которая могла обрабатывать “счастливый путь”, но не могла осмысленно обрабатывать исключения. Мы разобрали три возможных сценария с ошибками и использовали разработку через тестирование для описания желаемого поведения наших классов, внося изменения до тех пор, пока все тесты не были пройдены. Теперь у нас есть более надежный пример проекта ASP.NET Core. 

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Jeremy Wells: TDD and Exception Handling With xUnit in ASP.NET Core

Предыдущая статьяКак восстановить положение прокрутки виджета RecyclerView
Следующая статьяНе слушай профи - делай print()