Тестируем связанные классы
Видео

В реальных проектах, сколь бы малы они ни были, мы всегда имеем дело не с отдельными классами, а с цепочками и гроздьями взаимосвязанных классов. Глубина этой системы может достигать и пяти, и семи, а в особо тяжелых случаях и 10+ уровней. Тестировать такое на первый взгляд, кажется, крайне затруднительно. И именно такие ситуации зачастую закладывают первые динамитные шашки прямо в фундамент тестирующего кода...

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

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

Рассмотрим простую ситуацию с двумя классами.

Имеем интерфейс

public interface IFileReader
{
   string Read( string filePath);
}

Класс реализующий этот интерфейс

public class FileReader
{
   public string Read(string filePath)
   {
      return File.ReadAllText(filePath);
   }
}

И зависящий от него

public class StringDecorator
{
   private readonly IFileReader _reader;
   public StringDecorator(IFileReader reader)
   {
      _reader = reader;
   }
   
   public string Get(string filePath)
   {
      var text = _reader.Read(filePath);
      return text.ToUpper();
   }
}

Наша задача - протестировать класс StringDecorator. И на выручку здесь к нам приходит библиотека Moq, из "джентельмеского набора". В базовом варианте (а иные мы пока и не рассматриваем) можем сказать, что ее предназначение - создавать поддельные реализации интерфейсов прямо на лету, с возможностью тонкой настройки поведения.

Дабы не лить лишнюю воду, рассмотрим все на примере.

[TestFixture]
public class StringDecoratorTests
{
   private <MockIFileReader> _reader;
   private StringDecorator _decorator;
   
   [SetUp]
   public void SetUp()
   {
      _reader =  new Mock<IFileReader>(MockBehavior.Strict);
      _decorator =  new StringDecorator(_reader.Object);
   }
   
   [Theory, AutoData]
   public void Get_Success(string filePath)
   {
      var text = "aaa";
      var expected = "AAA";
      _reader.Setup(e => e.Read(filePath)).Returns(text);
      //
      var result = _decorator.Get(filePath);
      //
      Assert.True(result == expected);
   }
}

*Пример реализован с использованием nUnit, переписывать то же самое на xUnit не вижу никакого смысла - достаточно убрать атрибут TestFixture над классом и код можно использовать в xUnit-проекте.

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

Происходящее внутри более-менее очевидно: мы создаем подделку реализации IFileReader с помощью библиотеки Moq и используем эту подделку при создании экземпляра, тестируемого нами StringDecorator'а. Обратите внимание на MockBehavior.Strict, используйте этот подход везде и всюду - этой настройкой мы указываем движку выбрасывать исключение всякий раз, когда тестируемый код пытается обратиться к не настроенным методам зависимостей. Разберем эту мысль при изучении настройки поведения для метода Read(). text и expected в комментариях не нуждаются - мы разбирали этот момент в более ранней статье.

Вся магия заключена в

_reader.Setup(e => e.Read(filePath)).Returns(text);

Здесь мы настраиваем поведение нашего поддельного IFileReader, указывая, что метод Read получив на вход filePath должен отдать text.

Теперь когда наш _decorator будет обращаться к _reader.Read(), он будет вызывать именно нашу подделку и получать именно то, что мы указали в настройке.

Важный момент: именно для этого принципиально использовать указанную запись MockBehavior.Strict. Если мы по каким-то причинам не настроим поведение метода Read(), то движок немедленно выбросит исключение при попытке обратиться к нему и потребует настроить подделку. Если оставить конструктор пустым, то никакого исключения выброшено не будет, метод Read() всегда будет возвращать просто null, а разработчик клясть все на свете пытаясь понять почему не проходит тест)

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