Модульное тестирование асинхронного кода

OSzone.net » Microsoft » Разработка приложений » Другое » Модульное тестирование асинхронного кода
Автор: Стивен Клири
Иcточник: msdn.microsoft.com
Опубликована: 12.10.2015

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

Написание модульных тестов для асинхронного кода ставит несколько уникальных задач. Более того, на данный момент уровень поддержки асинхронности в инфраструктурах модульного тестирования и имитации варьируется в широких пределах и все еще находится в развитии. В этой статье рассматриваются MSTest, NUnit и xUnit, но общие принципы применимы к любой инфраструктуре модульного тестирования. В большинстве примеров в этой статье будет использоваться синтаксис MSTest, но попутно я буду отмечать любые отличия в поведении разных инфраструктур. В исходном коде, сопутствующем этой статье, содержатся примеры для всех трех инфраструктур.

Прежде чем углубиться в специфику, я дам краткий обзор концептуальной модели того, как работают ключевые слова async и await.

Кратко об async и await

Ключевое слово async делает две вещи: оно разрешает использовать ключевое слово await внутри метода и преобразует метод в конечный автомат (по аналогии с тем, как ключевое слово yield преобразует блоки итератора в конечные автоматы). Методы с ключевым словом async должны по возможности возвращать Task или Task<T>. Такие методы могут возвращать void, но это не рекомендуется, так как асинхронный void-метод трудно использовать или тестировать.

Экземпляр задачи, возвращаемый из async-метода, управляется конечным автоматом. Конечный автомат будет создавать экземпляр задачи для возврата, а позднее — возвращать эту задачу.

Асинхронный метод начинает выполняться синхронно. И лишь когда он достигнет оператора await, этот метод может стать асинхронным. Оператор await принимает единственный аргумент, ожидаемый объект (awaitable), такой как экземпляр Task. Сначала оператор await проверит ожидаемый объект, чтобы понять, закончено ли его выполнение; если да, метод продолжает свою работу (синхронно). Если ожидаемый объект еще не выполнен, оператор await поставит метод «на паузу» и возобновит его, когда выполнение ожидаемого объекта завершится. Второе, что делает оператор await, — получает любые результаты от ожидаемого объекта и генерирует исключения, если выполнение ожидаемого объекта закончилось с какой-либо ошибкой.

Task или Task<T>, возвращаемый async-методом, концептуально представляет выполнение этого метода. Задача будет завершена, когда завершится метод. Если метод возвращает какое-то значение, задача завершена с этим значением в качестве результата. Если метод генерирует исключение (и не захватывает его), значит, задача завершилась с этим исключением.

Из этого краткого обзора можно сразу же извлечь два урока. Во-первых, при проверке результатов асинхронного метода важным элементом является возвращаемый им Task. Async-метод использует свою Task для уведомления о завершении, передачи результатов и исключений. Во-вторых, оператор await имеет особое поведение, когда ожидаемый объект (awaitable) уже завершен. Я еще вернусь к этому при рассмотрении асинхронных заглушек.

Модульный тест, пройденный с ложным успехом

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

Модульный тест, который предположительно должен завершиться неудачей, будет ложно успешным при тестировании не того, чего нужно. Вот почему в разработке на основе тестов (TDD) интенсивно используется цикл «красный/зеленый/рефакторинг» (red/green/refactor loop): «красная» часть цикла гарантирует, что модульный тест будет проваливаться, когда код некорректен. Поначалу идея тестирования кода, который, как вам хорошо известно, неправилен, звучит дико, но на самом деле это весьма важно, поскольку вы должны быть уверены, что тесты завершаются неудачей именно тогда, когда иначе быть и не может. «Красная» часть цикла TDD в действительности тестирует тесты.

С учетом этого рассмотрим следующий асинхронный метод, который нужно протестировать:

public sealed class SystemUnderTest
{
  public static async Task SimpleAsync()
  {
    await Task.Delay(10);
  }
}

Новички в модульном тестировании асинхронных методов часто при первой попытке будут писать тест примерно так:

// Внимание: плохой код!
[TestMethod]
public void IncorrectlyPassingTest()
{
  SystemUnderTest.SimpleAsync();
}

К сожалению, этот модульный тест на самом деле неправильно тестирует асинхронный метод. Если я модифицирую тестируемый код так, чтобы тест проваливался, он все равно будет проходить успешно:

public sealed class SystemUnderTest
{
  public static async Task SimpleAsync()
  {
    await Task.Delay(10);
    throw new Exception("Should fail.");
  }
}

Это иллюстрирует первый урок, извлеченный нами из концептуальной модели async/await: чтобы протестировать поведение асинхронного метода, нужно наблюдать за возвращаемой им задачей. Лучший способ сделать это — ожидать задачу, возвращаемую тестируемым методом. Этот пример также демонстрирует преимущество цикла разработки «красный/зеленый/рефакторинг»; вы должны быть уверены, что тесты будут проваливаться, когда тестируемый код дает ошибку.

Большинство современных инфраструктур модульного тестирования поддерживает асинхронные модульные тесты, возвращающие Task. Метод IncorrectlyPassingTest вызовет предупреждение компилятора CS4014, которое рекомендует использовать await для использования задачи, возвращаемой из SimpleAsync. Когда метод модульного теста изменяется на ожидание задачи, самый естественный подход — изменить метод теста так, чтобы он стал асинхронным методом, который возвращает Task. Это гарантирует корректный провал метода теста:

[TestMethod]
public async Task CorrectlyFailingTest()
{
  await SystemUnderTest.FailAsync();
}

Избегайте модульных тестов async void

Опытный пользователи ключевого слова async знают, что следует избегать применения async void. Я описывал проблему с async void в своей статье «Best Practices in Asynchronous Programming» за март 2013 г. (bit.ly/1ulDCiI). Async-методы модульных тестов, возвращающие void, затрудняют их инфраструктурам модульного тестирования извлечение результатов теста. Ввиду этого некоторые инфраструктуры модульного тестирования реально поддерживают модульные тесты async void, предоставляя собственный SynchronizationContext, в котором выполняются модульные тесты.

Предоставление SynchronizationContext в какой-то мере является спорным, потому что он изменяет среду, в которой выполняются тесты. В частности, когда async-метод ожидает задачу, по умолчанию оператор await возобновит работу этого async-метода в текущем SynchronizationContext. Поэтому наличие или отсутствие SynchronizationContext косвенно изменит поведение тестируемой системы. Если вас интересуют детали SynchronizationContext, см. мою статью в «MSDN Magazine» на эту тему по ссылке bit.ly/1hIar1p.

MSTest не предоставляет SynchronizationContext. По сути, когда MSBuild обнаруживает тесты в проекте, который модульные тесты async void, эта утилита выдает предупреждение UTA007, уведомляющее пользователя о том, что метод модульного теста должен возвращать Task вместо void. MSBuild не будет выполнять модульные тесты async void.

NUnit поддерживает модульные тесты async void, начиная с версии 2.6.2. В следующем большом обновлении NUnit, в версии 2.9.6, эта поддержка сохранена, но разработчики уже решили удалить ее в версии 2.9.7. NUnit предоставляет SynchronizationContext только для модульных тестов async void.

На момент написания этой статьи в xUnit версии 2.0.0 планируется добавить поддержку модульных тестов async void. В отличие от NUnit инфраструктура xUnit предоставляет SynchronizationContext для всех методов тестов — даже для синхронных. Однако, учитывая, что MSTest не поддерживает модульные тесты async void, а в NUnit предполагается отказ от прежнего решения и удаление поддержки, я не удивился бы, если в xUnit тоже предпочтут отказаться от поддержки модульных тестов async void до выпуска версии 2.

Вывод: модульные тесты async void сложны для поддержки в инфраструктурах, требуют изменений в среде, исполняющей тесты, и не дают никаких преимуществ по сравнению с модульными тестами async Task. Более того, уровень поддержки модульных тестов async void варьируется в разных инфраструктурах и даже в их версиях. По этим причинам лучше всего избегать модульных тестов async void.

Модульные тесты async Task

Асинхронные модульные тесты, возвращающие Task, не имеют проблем, свойственных асинхронным модульным тестам, возвращающим void. Первые широко поддерживаются почти всеми инфраструктурами модульного тестирования. MSTest добавляет поддержку в Visual Studio 2012, NUnit — в версиях 2.6.2 и 2.9.6, а xUnit — в версии 1.9. Таким образом, если ваша инфраструктура модульного тестирования не старше трех лет, модульные тесты async Task должны работать без проблем.

К сожалению, устаревшие инфраструктуры модульного тестирования не понимают асинхронных модульных тестов, возвращающих Task. На момент написания этой статьи их не поддерживает одна из основных платформ: Xamarin. Xamarin использует адаптированную устаревшую версию NUnitLite, а она в настоящее время не поддерживает асинхронные модульные тесты, возвращающие Task. Предполагаю, что эту поддержку добавят в ближайшем будущем. А тем временем я использую довольно неэффективный, но работающий обходной способ: буду выполнять асинхронную логику теста в другом потоке из пула, а затем синхронно блокировать метод модульного теста, пока не завершится сам тест. Соответствующий код использует GetAwaiter().GetResult() вместо Wait, так как Wait будет обертывать любые исключения в AggregateException:

[Test]
public void XamarinExampleTest()
{
  // Этот обходной способ необходим в Xamarin,
  // которая не поддерживает async-методы модульных тестов
  Task.Run(async () =>
  {
    // Здесь помещается код теста
  }).GetAwaiter().GetResult();
}

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

При тестировании естественно проверяется успешный сценарий, например пользователь может обновить свой профиль. Однако очень важно тестирование и исключений, например пользователь не должен иметь возможность изменять чужой профиль. Исключения являются такой же частью поверхности API, что и параметры методов. Поэтому важно иметь модульные тесты для кода, который должен терпеть неудачу.

Изначально ExpectedExceptionAttribute помещался в метод модульного теста для указания на то, что данный модульный тест должен провалиться. Однако с ExpectedExceptionAttribute была пара проблем. Первая заключалась в том, что этот атрибут предполагал только провал модульного теста в целом; не было никакого способа сообщить, что ожидается провал лишь определенной части теста. В очень простых тестах это не проблема, но в тестах посложнее это может давать недостоверные результаты. Вторая проблема с ExpectedExceptionAttribute — он ограничен проверкой типа исключения; нет никакого способа проверки других атрибутов, таких как коды ошибок или сообщения.

Ввиду этого в последние годы произошел сдвиг в сторону применения вариантов, более близких к Assert.ThrowsException, который принимает важную часть кода как делегат и возвращает сгенерированное исключение. Это устраняет недостатки ExpectedExceptionAttribute. Настольная инфраструктура MSTest поддерживает только ExpectedExceptionAttribute, тогда как более новая инфраструктура MSTest, используемая для проектов модульного тестирования приложений Windows Store, — только Assert.ThrowsException. В xUnit поддерживается лишь Assert.Throws, а NUnit поддерживает оба подхода. На рис. 1 приведен пример обоих видов тестов, использующих синтаксис MSTest.

Рис. 1. Тестирование исключений с помощью синхронных методов тестов

// Старый стиль, работает только в настольной версии
[TestMethod]
[ExpectedException(typeof(Exception))]
public void ExampleExpectedExceptionTest()
{
  SystemUnderTest.Fail();
}
// Новый стиль, работает только в Windows Store
[TestMethod]
public void ExampleThrowsExceptionTest()
{
  var ex = Assert.ThrowsException<Exception>(()
    => { SystemUnderTest.Fail(); });
}

А как насчет асинхронного кода? Модульные тесты async Task отлично работают с ExpectedExceptionAttribute как в MSTest, так и в NUnit (xUnit вообще не поддерживает ExpectedExceptionAttribute). Однако поддержка ThrowsException, готового к async, менее однородная. MSTest поддерживает async ThrowsException, но только для проектов модульных тестов под Windows Store. В xUnit ввели async ThrowsAsync в предварительные сборки xUnit 2.0.0.

NUnit более сложна. На момент написания этой статьи NUnit поддерживает асинхронный код в своих методах верификации, таких как Assert.Throws. Однако, чтобы это работало, NUnit предоставляет SynchronizationContext, который создает те же проблемы, что и модульные тесты async void. Кроме того, синтаксис в настоящее время весьма хрупкий, как показывает пример на рис. 2. NUnit уже планирует отказаться от поддержки модульных тестов async void. Вывод: не советую использовать этот подход.

Рис. 2. Хрупкое тестирование исключений в NUnit

[Test]
public void FailureTest_AssertThrows()
{
  // Это работает, хотя на самом деле реализует вложенный цикл,
  // синхронно блокирующий вызов Assert.Throws до завершения
  // асинхронного вызова FailAsync
  Assert.Throws<Exception>(async () => await SystemUnderTest.FailAsync());
}
// Не проходит тест
[Test]
public void BadFailureTest_AssertThrows()
{
  Assert.Throws<Exception>(() => SystemUnderTest.FailAsync());
}

Таким образом, на данный момент поддержка готового к async ThrowsException/Throws не особо функциональна. В своем коде модульного тестирования я использую тип, очень похожий на AssertEx (рис. 3). Этот тип весьма упрощен в том плане, что он генерирует «голые» объекты Exception вместо выполнения каких-либо утверждений (assertions), зато этот же код работает во всех основных инфраструктурах модульного тестирования.

Рис. 3. Класс AssertEx для асинхронного тестирования исключений

using System;
using System.Threading.Tasks;
public static class AssertEx
{
  public static async Task<TException>
    ThrowsAsync<TException>(Func<Task> action,
    bool allowDerivedTypes = true) where TException : Exception
  {
    try
    {
      await action();
    }
    catch (Exception ex)
    {
      if (allowDerivedTypes && !(ex is TException))
        throw new Exception("Delegate threw exception of type " +
          ex.GetType().Name + ", but " + typeof(TException).Name +
          " or a derived type was expected.", ex);
      if (!allowDerivedTypes && ex.GetType() != typeof(TException))
        throw new Exception("Delegate threw exception of type " +
          ex.GetType().Name + ", but " + typeof(TException).Name +
          " was expected.", ex);
      return (TException)ex;
    }
    throw new Exception("Delegate did not throw expected exception " +
      typeof(TException).Name + ".");
  }
  public static Task<Exception> ThrowsAsync(Func<Task> action)
  {
    return ThrowsAsync<Exception>(action, true);
  }
}

Это позволяет модульным тестам async Task использовать более современный ThrowsAsync вместо ExpectedExceptionAttribute, например:

[TestMethod]
public async Task FailureTest_AssertEx()
{
  var ex = await AssertEx.ThrowsAsync(()
    => SystemUnderTest.FailAsync());
}

Асинхронные заглушки и имитации

На мой взгляд, только самый простейший код можно тестировать без каких-либо видов заглушек (stubs), имитаций (mock) и др. В этой вводной статье я буду называть все эти вспомогательные средства тестирования имитациями. При использовании имитаций полезно программировать под интерфейсы, а не реализации. Асинхронные методы прекрасно работают с интерфейсами; на рис. 4 показано, как код может использовать интерфейс с помощью асинхронного метода.

Рис. 4. Использование асинхронного метода из интерфейса

public interface IMyService
{
  Task<int> GetAsync();
}
public sealed class SystemUnderTest
{
  private readonly IMyService _service;
  public SystemUnderTest(IMyService service)
  {
    _service = service;
  }
  public async Task<int> RetrieveValueAsync()
  {
    return 42 + await _service.GetAsync();
  }
}

С помощью этого кода достаточно легко создать тестовую реализацию интерфейса и передать ее тестируемой системе. На рис. 5 показано, как протестировать три основных случая заглушки: асинхронный успех, асинхронная неудача и синхронный успех. Асинхронные успех и неудача — два главных сценария в тестировании асинхронного кода, но также важно проверить синхронный случай. Это связано с тем, что оператор await по-разному ведет себя, если его ожидаемый объект уже завершен. В коде на рис. 5 инфраструктура имитаций Moq используется для генерации реализаций заглушек.

Рис. 5. Реализации заглушек для асинхронного кода

[TestMethod]
public async Task RetrieveValue_SynchronousSuccess_Adds42()
{
  var service = new Mock<IMyService>();
  service.Setup(x => x.GetAsync()).Returns(() => Task.FromResult(5));
  // Или service.Setup(x => x.GetAsync()).ReturnsAsync(5);
  var system = new SystemUnderTest(service.Object);
  var result = await system.RetrieveValueAsync();
  Assert.AreEqual(47, result);
}
[TestMethod]
public async Task RetrieveValue_AsynchronousSuccess_Adds42()
{
  var service = new Mock<IMyService>();
  service.Setup(x => x.GetAsync()).Returns(async () =>
  {
    await Task.Yield();
    return 5;
  });
  var system = new SystemUnderTest(service.Object);
  var result = await system.RetrieveValueAsync();
  Assert.AreEqual(47, result);
}
[TestMethod]
public async Task RetrieveValue_AsynchronousFailure_Throws()
{
  var service = new Mock<IMyService>();
  service.Setup(x => x.GetAsync()).Returns(async () =>
  {
    await Task.Yield();
    throw new Exception();
  });
  var system = new SystemUnderTest(service.Object);
  await AssertEx.ThrowsAsync(system.RetrieveValueAsync);
}

Инфраструктуры имитации могут предоставлять и какую-то поддержку асинхронному модульному тестированию. Задумайтесь, какое поведение по умолчанию должно быть у метода, если никакого поведения не указано. Некоторые инфраструктуры имитации (вроде Microsoft Stubs) по умолчанию генерируют исключение, а другие (например, Moq) возвращают значение по умолчанию. Когда асинхронный метод возвращает Task<T>, наивное поведение по умолчанию — возврат default(Task<T>), иначе говоря, null-задачи, которая вызовет NullReferenceException.

Это поведение нежелательно. Более разумное поведение по умолчанию для асинхронных методов — возврат Task.FromResult(default(T)), т. е. задачи, завершенной со значением T по умолчанию. Это позволяет тестируемой системе использовать возвращаемую задачу. Moq реализовала этот стиль поведения по умолчанию для асинхронных методов в Moq версии 4.2. Насколько мне известно, на момент написания этой статьи это единственная библиотека имитации, использующая дружественные к async настройки по умолчанию.

Заключение

Ключевые слова async и await существуют с момента появления Visual Studio 2012 — достаточно давно для выработки некоторых рекомендаций. Инфраструктуры модульного тестирования и вспомогательные компоненты вроде библиотек имитации постепенно объединяются в единую поддержку, дружественную к асинхронности. Сегодня асинхронное модульное тестирование уже является реальностью, и станет еще лучше в будущем. Если вы еще не сделали этого, сейчас самое время обновить ваши инфраструктуры модульного тестирования и библиотеки имитации, чтобы получить максимально возможную на данный момент поддержку асинхронности.

Инфраструктуры модульного тестирования уходят от модульных тестов async void и движутся в направлении модульных тестов async Task. Если у вас есть какие-либо модульные тесты async void, советую переработать их сегодня же в модульные тесты async Task.

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

Должное асинхронное модульное тестирование — важная часть работы с асинхронным кодом, и я рад, что в эти инфраструктуры и библиотеки вводится поддержка async. Если сравнить с тем, что было всего несколько лет назад, разработка и тестирование асинхронного кода стали в наши дни гораздо легче!


Ссылка: http://www.oszone.net/28050/