Книга: Конкурентность в C#. Асинхронное, параллельное и многопоточное программирование. 2-е межд. изд.
Назад: Глава 6. Основы System.Reactive
Дальше: Глава 8. Взаимодействие

Глава 7. Тестирование

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

Рекомендую писать хотя бы несколько модульных тестов. Начните с кода, в котором вы уверены меньше всего. По моему опыту, модульные тесты обладают двумя основными преимуществами:

• Лучшее понимание кода. Вы знаете, что часть приложения работает, но понятия не имеете, как? Эта мысль остается где-то на заднем плане, но потом приходит крайне странное сообщение об ошибке. Написание модульных тестов для тех частей кода, которые вам кажутся сложными, — прекрасный способ разобраться в том, как они работают. После написания модульных тестов, описывающих его поведение, код перестает быть загадочным; у вас появляется набор модульных тестов, описывающих его поведение и зависимости от других частей кода.

• Уверенность при внесении изменений. Рано или поздно вы получите запрос на реализацию новой функции, для которого придется изменить пугающий код, и уже не удастся сделать вид, что его не существует (я знаю, каково это; со мной такое тоже случалось). Лучше действовать на опережение: напишите модульные тесты для неприятного кода, пока не пришел запрос на новую функциональность. После того как тесты будут готовы, у вас появится система раннего предупреждения, которая немедленно оповестит вас об изменениях, нарушающих существующее поведение. А если вы собираетесь включить новый код в проект, модульные тесты придадут больше уверенности в том, что изменения в коде не нарушат существующего поведения.

Оба преимущества относятся не только к вашему коду, но и к коду других разработчиков. Уверен, что есть и другие преимущества. Сокращает ли модульное тестирование частоту ошибок? Скорее всего. Сокращает ли модульное тестирование общее время работы над проектом? Возможно. Но преимущества, описанные мною, являются бесспорными; я наблюдаю их каждый раз, когда пишу модульные тесты. В общем, так я пропагандирую модульное тестирование.

Все рецепты, содержащиеся в этой главе, относятся к тестированию. Многие разработчики (даже те, которые обычно пишут модульные тесты) шарахаются от тестирования конкурентного кода, так как считают, что это слишком сложно. Но как покажут эти рецепты, модульное тестирование конкурентного кода далеко не так сложно, как они полагают. Разработчики современных инструментов и библиотек (таких, как async и System.Reactive) уделили значительное внимание тестированию, и это проявляется на практике. Рекомендую использовать эти рецепты для написания модульных тестов, особенно если вы недавно имеете дело с конкурентностью (т. е. новый конкурентный код кажется слишком сложным или пугающим).

7.1. Модульное тестирование async-методов

Задача

Имеется async-метод, для которого необходимо провести модульное тестирование.

Решение

Многие современные фреймворки модульного тестирования — включая MSTest, NUnit и xUnit —  поддерживают методы модульного тестирования asyncTask. В MSTest поддержка этих тестов появилась в Visual Studio 2012. Если вы используете другой фреймворк модульного тестирования, возможно, вам придется перейти на последнюю версию.

Пример async-модульного теста в MSTest:

[TestMethod]

public async Task MyMethodAsync_ReturnsFalse()

{

  var objectUnderTest = ...;

  bool result = await objectUnderTest.MyMethodAsync();

  Assert.IsFalse(result);

}

Фреймворк модульного тестирования замечает, что метод имеет возвращаемый тип Task, и ожидает завершения задачи перед тем, как сделать отметку о прохождении или отказе теста.

Если ваш фреймворк модульного тестирования не поддерживает модульные тесты async Task, то ему придется помочь с ожиданием тестируемой асинхронной операции. Один из вариантов — использовать GetAwaiter().GetResult() для синхронного блокирования по задаче; если после этого использовать GetAwaiter().GetResult() вместо Wait(), это позволит избежать обертки AggregateException, когда в задаче произойдет исключение. Однако я предпочитаю использовать тип AsyncContext из NuGet-пакета Nito.AsyncEx:

[TestMethod]

public void MyMethodAsync_ReturnsFalse()

{

  AsyncContext.Run(async () =>

  {

    var objectUnderTest = ...;

    bool result = await objectUnderTest.MyMethodAsync();

    Assert.IsFalse(result);

  });

}

AsyncContext.Run ожидает завершения всех асинхронных методов.

Пояснение

Имитация (mocking) асинхронных зависимостей на первый взгляд кажется немного неуклюжей. Всегда желательно хотя бы проверить, как ваши методы реагируют на синхронный успех (имитация с Task.FromResult), синхронные ошибки (имитация с Task.FromException) и асинхронный успех (имитация с Task.Yield и возвращаемым значением). Task.FromResult и Task.FromException рассматриваются в рецепте 2.2. Task.Yield может использоваться для принудительного применения асинхронного поведения и задействуется прежде всего при модульном тестировании:

interface IMyInterface

{

  Task<int> SomethingAsync();

}

 

class SynchronousSuccess : IMyInterface

{

  public Task<int> SomethingAsync()

  {

    return Task.FromResult(13);

  }

}

 

class SynchronousError : IMyInterface

{

  public Task<int> SomethingAsync()

  {

    return Task.FromException<int>(new InvalidOperationException());

  }

}

 

class AsynchronousSuccess : IMyInterface

{

  public async Task<int> SomethingAsync()

  {

    await Task.Yield(); // Принудительно включить асинхронное поведение.

    return 13;

  }

}

При тестировании асинхронного кода взаимоблокировки и состояния гонки могут проявляться чаще, чем при тестировании синхронного кода. Я считаю полезным назначение тайм-аута на уровне тестов; в Visual Studio можно добавить в решение файл тестовых настроек, в котором можно задавать тайм-ауты для отдельных тестов. Значение по умолчанию достаточно велико; обычно я использую двухсекундный тайм-аут уровня тестов.

lemur.tiff

Тип AsyncContext находится в пакете Nito.AsyncEx.

Дополнительная информация

В рецепте 7.2 рассматриваются асинхронные методы модульного тестирования, которые должны провалиться.

7.2. Асинхронные методы модульного тестирования, которые не должны проходить

Задача

Требуется написать модульный тест, который проверяет конкретный отказ метода asyncTask.

Решение

Если вы занимаетесь разработкой для настольных компьютеров или серверов, MSTest поддерживает тестирование на отказ с помощью обычного класса ExpectedExceptionAttribute:

// Использовать это решение не рекомендуется; см. ниже.

[TestMethod]

[ExpectedException(typeof(DivideByZeroException))]

public async Task Divide_WhenDenominatorIsZero_ThrowsDivideByZero()

{

  await MyClass.DivideAsync(4, 0);

}

Тем не менее это не лучшее решение: ExpectedException обычно является признаком плохого дизайна. Ожидаемое исключение может быть выдано любым из методов, вызванных вашим методом модульного тестирования. Более качественный код проверяет, что исключение было выдано конк­ретным фрагментом кода, а не модульным тестом в целом.

Многие современные фреймворки модульного тестирования включают Assert.ThrowsAsync<TException>  в той или иной форме. Например, ThrowsAsync из xUnit можно использовать так:

[Fact]

public async Task Divide_WhenDenominatorIsZero_ThrowsDivideByZero()

{

  await Assert.ThrowsAsync<DivideByZeroException>(async () =>

  {

    await MyClass.DivideAsync(4, 0);

  });

}

scorp.tiff

Не забудьте использовать await с задачей, возвращенной ThrowsAsync! Ключевое слово await распространяет все обнаруженные нарушения тестовых условий. Если вы забудете await и проигнорируете предупреждение компилятора, ваш модульный тест всегда будет проходить успешно независимо от поведения метода.

К сожалению, некоторые фреймворки модульного тестирования не содержат эквивалентных async-совместимых конструкций ThrowsAsync. Если вы окажетесь в такой ситуации, создайте собственный аналог:

/// <summary>

/// Гарантирует, что асинхронный делегат выдает исключение.

/// </summary>

/// <typeparam name="TException">

/// Тип ожидаемого исключения.

/// </typeparam>

/// <param name="action">Асинхронный делегат для тестирования.</param>

/// <param name="allowDerivedTypes">

/// Должны ли приниматься производные типы.

/// </param>

public static async Task<TException> ThrowsAsync<TException>(Func<Task>

   action,

    bool allowDerivedTypes = true)

    where TException : Exception

{

  try

  {

    await action();

    var name = typeof(Exception).Name;

    Assert.Fail($"Delegate did not throw expected exception {name}.");

    return null;

  }

  catch (Exception ex)

  {

    if (allowDerivedTypes && !(ex is TException))

      Assert.Fail($"Delegate threw exception of type

        {ex.GetType().Name}" +

          $", but {typeof(TException).Name} or a derived type was

             expected.");

    if (!allowDerivedTypes && ex.GetType() != typeof(TException))

      Assert.Fail($"Delegate threw exception of type

        {ex.GetType().Name}" +

          $", but {typeof(TException).Name} was expected.");

    return (TException)ex;

  }

}

Метод можно использовать точно так же, как и любой другой метод Assert.ThrowsAsync<TException>. Не забудьте использовать await с возвращаемым значением!

Пояснение

Тестирование обработки ошибок не менее важно, чем тестирование успешных сценариев. Пожалуй, оно даже важнее, потому что успешные сценарии всегда проверяются перед выпуском программного продукта. Если приложение ведет себя странно, это происходит из-за непредвиденной ошибочной ситуации.

Но я рекомендую разработчикам воздерживаться от ExpectedException. Лучше протестировать выдачу исключения в конкретной точке вместо того, чтобы тестировать исключение в любой момент во время теста. Вместо ExpectedException используйте либо ThrowsAsync (или его аналог в вашем фреймворке модульного тестирования), либо его реализацию, как в последнем примере кода.

Дополнительная информация

В рецепте 7.1 рассматриваются основы модульного тестирования асинхронных методов.

7.3. Модульное тестирование методов async void

Задача

Имеется метод async void, для которого необходимо написать модульные тесты.

Решение

Стоп.

Такой ситуации нужно избегать всеми силами. Если метод asyncvoid можно преобразовать в метод asyncTask — сделайте это.

Если ваш метод обязан быть методом asynvoid (например, для соответствия сигнатуре метода интерфейса), рассмотрите возможность написания двух методов: метода asyncTask, содержащего всю логику, и обертки async void, которая просто вызывает метод asyncTask и ожидает результата. Метод asyncvoid удовлетворяет требованиям архитектуры, тогда как метод asyncTask (со всей логикой) пригоден для тестирования.

Если изменить метод невозможно и вы вынуждены заниматься модульным тестированием метода async void, это тоже возможно. Используйте класс AsyncContext из библиотеки Nito.AsyncEx:

// Не рекомендуется; см. далее в этом разделе.

[TestMethod]

public void MyMethodAsync_DoesNotThrow()

{

  AsyncContext.Run(() =>

  {

    var objectUnderTest = new Sut(); // ...;

    objectUnderTest.MyVoidMethodAsync();

  });

}

Тип AsyncContext ожидает завершения всех асинхронных операций (включая методы asyncvoid) и распространяет выданные ими исключения.

lemur.tiff

Тип AsyncContext находится в пакете Nito.AsyncEx.

Пояснение

Одно из важнейших правил async-кода — по возможности избегать async void. Настоятельно рекомендую провести рефакторинг кода, а не использовать AsyncContext для модульного тестирования методов asyncvoid.

Дополнительная информация

В рецепте 7.1 рассматривается модульное тестирование методов async Task.

7.4. Модульное тестирование сетей потоков данных

Задача

В приложении существует сеть потока данных. Требуется убедиться в правильности ее работы.

Решение

Сети потоков данных независимы; они имеют собственный срок жизни и асинхронны по своей природе. Таким образом, самый простой подход к их тестированию — асинхронные модульные тесты. Следующий модульный тест проверяет нестандартный блок потока данных из рецепта 5.6:

[TestMethod]

public async Task MyCustomBlock_AddsOneToDataItems()

{

  var myCustomBlock = CreateMyCustomBlock();

  myCustomBlock.Post(3);

  myCustomBlock.Post(13);

  myCustomBlock.Complete();

  Assert.AreEqual(4, myCustomBlock.Receive());

  Assert.AreEqual(14, myCustomBlock.Receive());

  await myCustomBlock.Completion;

}

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

[TestMethod]

public async Task MyCustomBlock_Fault_DiscardsDataAndFaults()

{

  var myCustomBlock = CreateMyCustomBlock();

  myCustomBlock.Post(3);

  myCustomBlock.Post(13);

  (myCustomBlock as IDataflowBlock).Fault(new

     InvalidOperationException());

  try

  {

    await myCustomBlock.Completion;

  }

  catch (AggregateException ex)

  {

    AssertExceptionIs<InvalidOperationException>(

        ex.Flatten().InnerException, false);

  }

}

 

public static void AssertExceptionIs<TException>(Exception ex,

    bool allowDerivedTypes = true)

{

  if (allowDerivedTypes && !(ex is TException))

    Assert.Fail($"Exception is of type {ex.GetType().Name}, but " +

        $"{typeof(TException).Name} or a derived type was expected.");

  if (!allowDerivedTypes && ex.GetType() != typeof(TException))

    Assert.Fail($"Exception is of type {ex.GetType().Name}, but " +

        $"{typeof(TException).Name} was expected.");

}

Пояснение

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

Дополнительная информация

В рецепте 7.1 рассматривается модульное тестирование async-методов.

7.5. Модульное тестирование наблюдаемых объектов System.Reactive

Задача

Часть вашей программы использует IObservable<T>. Требуется организовать модульное тестирование этой части.

Решение

System.Reactive содержит операторы, генерирующие последовательности (например, Return), а также другие операторы, которые могут преобразовать реактивную последовательность в обычную коллекцию или элемент (например, SingleAsync). Такие операторы, как Return, могут использоваться для создания заглушек для наблюдаемых зависимостей, а такие операторы, как SingleAsync, — для тестирования вывода.

Следующий код получает службу HTTP в качестве зависимости и применяет тайм-аут к вызову HTTP:

public interface IHttpService

{

  IObservable<string> GetString(string url);

}

 

public class MyTimeoutClass

{

  private readonly IHttpService _httpService;

 

  public MyTimeoutClass(IHttpService )

  {

    _httpService = ;

  }

 

  public IObservable<string> GetStringWithTimeout(string url)

  {

    return _httpService.GetString(url)

        .Timeout(TimeSpan.FromSeconds(1));

  }

}

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

Оператор Return возвращает холодную последовательность (cold sequence), состоящую из одного элемента; он может использоваться для построения простых заглушек. Оператор SingleAsync возвращает объект Task<T>, завершаемый при поступлении следующего события. SingleAsync может использоваться в простых модульных тестах следующего вида:

class SuccessHttpServiceStub : IHttpService

{

  public IObservable<string> GetString(string url)

  {

    return Observable.Return("stub");

  }

}

 

[TestMethod]

public async Task MyTimeoutClass_SuccessfulGet_ReturnsResult()

{

  var stub = new SuccessHttpServiceStub();

  var my = new MyTimeoutClass(stub);

  var result = await my.GetStringWithTimeout("/")

      .SingleAsync();

  Assert.AreEqual("stub", result);

}

В коде заглушек играет важную роль оператор Throw, который возвращает наблюдаемый объект, заканчивающийся с ошибкой. С помощью этого оператора  можно писать модульные тесты и для ошибочных случаев. В следующем примере используется вспомогательный метод ThrowsAsync из рецепта 7.2:

private class FailureHttpServiceStub : IHttpService

{

  public IObservable<string> GetString(string url)

  {

    return Observable.Throw<string>(new HttpRequestException());

  }

}

[TestMethod]

public async Task MyTimeoutClass_FailedGet_PropagatesFailure()

{

  var stub = new FailureHttpServiceStub();

  var my = new MyTimeoutClass(stub);

 

  await ThrowsAsync<HttpRequestException>(async () =>

  {

    await my.GetStringWithTimeout("/")

        .SingleAsync();

  });

}

Пояснение

Return и Throw хорошо подходят для создания наблюдаемых заглушек, а SingleAsync предоставляет простые средства тестирования наблюдаемых объектов с асинхронными модульными тестами. Эта комбинация неплохо справляется с простыми наблюдаемыми объектами, но когда вы начинаете работать со временем, их возможностей оказывается недостаточно. Например, если вы хотите протестировать функциональность тайм-аута MyTimeoutClass, модульным тестам придется ожидать нужный промежуток времени. Однако такое решение нельзя назвать качественным: оно снижает надежность модульных тестов за счет введения состояния гонки и плохо масштабируется с добавлением новых модульных тестов. В рецепте 7.6 рассматривается специальный механизм, с помощью которого System.Reactive позволяет создавать заглушки для самого времени.

Дополнительная информация

В разделе 7.1 рассматривается модульное тестирование async-методов, которое имеет много общего с модульными тестами, ожидающими SingleAsync.

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

7.6. Модульное тестирование наблюдаемых объектов System.Reactive с использованием имитации планирования

Задача

Имеется наблюдаемый объект, зависящий от времени. Требуется написать модульный тест, который не зависел бы от времени. К числу наблюдаемых объектов, зависящих от времени, относятся те, которые используют тайм-аут, окна/буферизацию и регулировку/выборку. Нужно провести модульное тестирование таких объектов, но так, чтобы модульные тесты выполнялись за приемлемое время.

Решение

Конечно, в модульные тесты можно включить задержку, но у такого решения есть два недостатка: 1) выполнение модульных тестов занимает больше времени; 2) возникает состояние гонки, потому что все модульные тесты выполняются одновременно, а последовательность выполнения становится непредсказуемой.

Библиотека System.Reactive (Rx) проектировалась с учетом потребностей тестирования; более того, сама библиотека Rx прошла тщательное модульное тестирование. Чтобы сделать возможным тщательное модульное тестирование, в Rx была введена концепция планировщика, и каждый оператор Rx, работающий со временем, реализуется с использованием этого абстрактного планировщика.

Чтобы ваши наблюдаемые объекты можно было тестировать, необходимо дать возможность вызывающей стороне задать планировщика. Например, можно взять класс MyTimeoutClass из рецепта 7.5 и добавить планировщика:

public interface IHttpService

{

  IObservable<string> GetString(string url);

}

 

public class MyTimeoutClass

{

  private readonly IHttpService _httpService;

 

  public MyTimeoutClass(IHttpService )

  {

    _httpService = ;

  }

 

  public IObservable<string> GetStringWithTimeout(string url,

      IScheduler scheduler = null)

  {

    return _httpService.GetString(url)

        .Timeout(TimeSpan.FromSeconds(1), scheduler ??

           Scheduler.Default);

  }

}

Затем вы можете изменить заглушку службы HTTP, чтобы она также поддерживала планирование, и ввести переменную задержку:

private class SuccessHttpServiceStub : IHttpService

{

  public IScheduler Scheduler { get; set; }

  public TimeSpan Delay { get; set; }

 

  public IObservable<string> GetString(string url)

  {

    return Observable.Return("stub")

        .Delay(Delay, Scheduler);

  }

}

Теперь можно двигаться вперед и использовать TestScheduler — тип, включенный в библиотеку System.Reactive. TestScheduler предоставляет мощные средства управления (виртуальным) временем.

lemur.tiff

TestScheduler находится в пакете отдельно от остальных частей System.Reactive; необходимо установить пакет NuGet Microsoft.Reactive.Testing.

[TestMethod]

public void MyTimeoutClass_SuccessfulGetShortDelay_ReturnsResult()

{

  var scheduler = new TestScheduler();

  var stub = new SuccessHttpServiceStub

  {

    Scheduler = scheduler,

    Delay = TimeSpan.FromSeconds(0.5),

  };

  var my = new MyTimeoutClass(stub);

  string result = null;

  my.GetStringWithTimeout("/", scheduler)

      .Subscribe(r => { result = r; });

 

  scheduler.Start();

 

  Assert.AreEqual("stub", result);

}

Этот код моделирует сетевую задержку продолжительностью 0,5 секунды. Важно отметить, что выполнение этого модульного теста не занимает 0,5 секунды; на моей машине он выполняется приблизительно за 70 миллисекунд. Полусекундная задержка существует только в виртуальном времени. Другое заметное отличие заключается в том, что этот модульный тест не является асинхронным; так как вы используете TestScheduler, все тесты могут завершаться немедленно.

После того как в коде будут использоваться тестовые планировщики, протестировать ситуации тайм-аута будет несложно:

[TestMethod]

public void MyTimeoutClass_SuccessfulGetLongDelay_

   ThrowsTimeoutException()

{

  var scheduler = new TestScheduler();

  var stub = new SuccessHttpServiceStub

  {

    Scheduler = scheduler,

    Delay = TimeSpan.FromSeconds(1.5),

  };

  var my = new MyTimeoutClass(stub);

  Exception result = null;

 

  my.GetStringWithTimeout("/", scheduler)

      .Subscribe(_ => Assert.Fail("Received value"), ex => { result

          = ex; });

 

  scheduler.Start();

 

  Assert.IsInstanceOfType(result, typeof(TimeoutException));

}

И снова выполнение теста не занимает 1 секунду (или 1,5 секунды); тест выполняется немедленно с использованием виртуального времени.

Пояснение

В этом рецепте вы едва соприкоснулись с планировщиками System.Reactive и виртуальным временем. Рекомендую приступать к написанию модульных тестов тогда, когда вы начнете писать код System.Reactive; со временем ваш код будет становиться все более сложным, но вы будете уверены в том, что Microsoft.Reactive.Testing с ним справится.

TestScheduler также содержит методы AdvanceTo и AdvanceBy для частичных перемещений в виртуальном времени. Эти методы могут быть полезны в некоторых ситуациях, но вы должны стремиться к тому, чтобы каждый модульный тест проверял что-то одно. Вообще, для тестирования тайм-аута можно написать один модульный тест, который частично продвигает вперед TestScheduler и убеждается в том, что тайм-аут не произошел слишком рано, а потом выводит TestScheduler за время тайм-аута и убеждается в том, что на этот раз тайм-аут произошел. Тем не менее я предпочитаю разделять модульные тесты настолько, насколько это возможно; например, один модульный тест проверяет, что тайм-аут не произошел слишком рано, а другой модульный тест — что он произошел позднее.

Дополнительная информация

В рецепте 7.5 рассматриваются основы модульного тестирования наблюдаемых последовательностей.

Назад: Глава 6. Основы System.Reactive
Дальше: Глава 8. Взаимодействие