Автор: Sergey Teplyakov

В комментариях к предыдущей заметке был задан такой вопрос:

Вот есть, сккажем, класс, который читает какой-нибудь файл и потом его анализирует. Как такое тестировать?

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

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

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

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

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

1. Объектно-функциональный подход

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

С первого взгляда кажется, что наиболее сложной частью модуля является парсинг данных, поэтому для него я бы сразу выделил отдельный класс CustomDataParser, который бы затем использовал из CustomDataFileParser: class CustomDataParser
{
// Здесь будет сосредоточена большая часть логики модуля
public CustomData Parse(string
str) { }
// Формально, метод нарушает Interface Segregation Principle,
// но подобный метод может быть позеным, если именно этот
// вариант будет наиболее часто используемым клиентами класса
public IEnumerable<,CustomData>, ParseAll(IEnumerable<,string
>, seq)
{
return seq.Select(Parse),
}
}
public class CustomDataFileParser : IDisposable
{
private readonly CustomDataParser _parser = new CustomDataParser(),
public CustomDataFileParser(string fileName)
{ }
public IEnumerable<,CustomData>, Parse()
{
return ReadFileLineByLine().Select(s =>, _parser.Parse(s)),
}
private IEnumerable<,string>, ReadFileLineByLine()
{ }
}

В самом классе CustomDataFileParser нет толком никакой логики, да и та представляет собой работу с файлом, которую следует проверять интеграционными тестами. Поэтому я бы не стал выделять логику чтения файла, и не стал бы выделять интерфейс парсинга до тех пор, пока не появится необходимость менять парсер во время исполнения.

ПРИМЕЧАНИЕ
Функциональность этого решения весьма условна. Мы можем рассматривать класс CustomDataFileParser как функцию преобразования строки с именем файла в последовательность CustomData. При этом суммарное поведение этой функции строится на двух вспомогательных функциях: функции чтения данных из файла (fun string ->, sequence of string) и функции разбора строки (fun string ->, CustomData). В результате, мы могли бы реализовать всю требуемую функциональность одним LINQ запросом с парой вспомогательных функций.

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

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

image

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

Pros:

  • Тестируемость,
  • Небольшое количество абстракций,

Cons:

  • Не pure ООП решение,
2. DIP головного мозга

Мы можем пойти стандартным путем для получения тестируемого дизайна и выделить сразу же 3 (!) стратегии: (1) стратегию чтения строк из файла (IFileReader), (2) стратегию парсинга строки (ICustomDataParser) и (3) стратегию парсинга строк из файла (ICustomDataFileParser):

image

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

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

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

Pros:

  • решение отвечает всем ОО канонам,

Cons:

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

3. Метод шаблона

Если мы хотим лишь протестировать определенную функциональность класса, то вместо выделения интерфейса мы можем воспользоваться паттерном под названием Extract and Override (см. главу 3 книги Роя Ошерова The Art of Unit Testing):

Его идея заключается в том, что в тестируемом классе метод, работающий с внешними ресурсами мы делаем виртуальным. В результате, мы используем паттерн Метод Шаблона, переменным шагом которого является работа с внешними ресурсами, который мы затем сможем подделать в тесте:// Класс не может быть sealed!
public class CustomDataFileParser
{
private readonly string _fileName,
public CustomDataFileParser(string fileName)
{
_fileName = fileName,
}

// Используем метод шаблона:
// сам алгоритм является неизменным, при этом он содержит
// шаги, имзеняемые наследниками
public IEnumerable<,CustomData
>, GetCustomData()
{
return ReadFileLineByLine().Select(s =>, Parse(s)),
}
private CustomData Parse(string str)
{ }
// Этот метод мы переопределим для тестирования граничных условий
protected virtual IEnumerable<,string
>, ReadFileLineByLine()
{
// Читаем данные из файла!
}
}

Затем в тестах мы переопределим метод ReadFileLineByLine и подсунуть тестовые данные. Многие изоляционные фреймворки позволяют мокать (в данном случае стабать) виртуальные методы, поэтому создавать наследников для каждого теста не придется:[Test]
public void
TestParsing()
{
// Arrange
var mock = new Mock<,CustomDataFileParser
>,(),
mock.Protected()
// Да, имя защищенного метода указывается в виде строки
.Setup<,IEnumerable<,string>,>,('ReadFileLineByLine'
)
// Возвращаем данные для проверки граничных условий
.Returns(new List<,string>, { 'SampleString with Id = 42'
}),
// Act
var
parsedData = mock.Object.GetCustomData().ToList(),
// Assert
Assert.That(parsedData[0].Id, Is.EqualTo(42)),
}

Альтернативным подходом может быть вариация паттерна Метод шаблона, на основе делегатов, а не наследования. Мы можем сделать конструктор, который принимает переменный шаг метода шаблона (реализацию метода ReadFileLIneByLine) в виде Func<,IEnumerable<,string>,>,.

Pros:

  • относительно легковесная техника,
  • отсутствие дополнительных абстракций,

Cons:

  • прогибаем дизайн под тестирование,
  • с помощью стабов не всегда удобно проверить все граничные условия класса,
  • решение половинчатое, поскольку абстракция парсера все равно была бы полезной.
4. Воспользоваться TypeMock Isolator или Microsoft Fakes

Некоторые изоляционные фреймворки, такие как TypeMock и Shims из Microsoft Fakes, позволяют подделывать любые методы, включая статические и невиртуальные.

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

5. Интеграционное тестирование

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

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

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

Pros:

  • полезное дополнение к юнит-тестам,
  • может быть полезным, если бизнес-пользователи работают напрямую с файлами и могут прислать десятки примеров,

Cons:

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

UPDATE: Sergey Fetisov в комментарии предложил воспользоваться TextReader-ом для абстрагирования от конкретного источника данных, а также несколько усложнить задачу, предположив, что одной записи может соответствовать более одной строки в исходном потоке. Я предлагаю рассмотреть эти изменения.

image

Во-первых, Сергей предложил передавать парсеру TextReader, но я бы этого не делал. Я бы оставил парсер без изменения, поскольку проще думать о парсинге строк, а не об извлечении строк из TextReader-а с последующим парсингом (один класс одна задача!).

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

В-третьих, я добавил дополнительный конструктор классу CustomDataFileParser, принимающий TextReader (что позволит написать юнит-тесты для этого класса) и поскольку из-за этого пришлось бы открывать файл в конструкторе, то я сделал этот класс Disposable.

Обратите внимание, что наши требования изменились, но открытый интерфейс модуля (ICustomDataProvider и CustomDataProvider) не поменялся! Поменялась реализация модуля, она усложнилась, но наши клиенты от этого не пострадали.

Помните, что инкапсуляция существует не только на уровне классов, но и на уровне модулей. Использование фасадных классов часто помогает изменять детали реализации модуля не затрагивая его клиентов.

Заключение

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

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

Помогла статья? Оцените её!
0 из 5. Общее количество голосов - 0
 

You have no rights to post comments

Дмитрий Крикунов

Публикую статьи, обучающие курсы и новости по программированию: алгоритмам, языкам (С++, Java), параллельному программированию, паттернам и библиотекам (Qt, boost).