среда, 8 мая 2013 г.

Как тестировать закрытые методы?

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

Q: - Как тестировать закрытые методы?
A: - Напрямую – никак!

Ну а теперь давайте поговорим об этом более подробно.

Черный ящик vs Белый ящик

clip_image002

Существует две распространенных стратегии тестирование: по принципу «Белого ящика» и по принципу «Черного ящика».

В случае «белого ящика» автор теста знает внутреннюю структуру тестируемого кода («видит» его насквозь) и подбирает тестовые данные таким образом, чтобы покрыть все ветвления, условия, выполнить все операторы и т.д. В случае «черного ящика» тесты пишутся без учета внутренней структуры тестируемого кода, а лишь через призму открытых входов/выходов.

Обычно эти два вида тестирования используются на разных уровнях: «черный ящик» используется для высокоуровневого тестирования (acceptance и system testing), а «белый ящик» - для более низкоуровневого тестирования (unit и integration testing).

Поскольку юнит-тесты используют подход «Белого ящика», то можно подумать, что во время тестирования нам нужно сосредоточиться на содержимом закрытых методах и проверить их в юнит тестах. Однако юнит-тест – это один из видов клиентов класса, а клиенты не должны уж очень сильно интересоваться внутренностями и подробностями реализации.

“Серый ящик”

Чем привлекателен подход на основе «белого ящика»? Он привлекателен тем, что мы таким образом обеспечиваем более высокую степень покрытия кода тестами, что дает нам больше уверенности в том, что код ведет себя так, как ожидается.

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

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

Если посмотреть на разработку через тестирование (a.k.a. TDD), то там мы получим обратную картину: вначале мы пишем тесты, покрывающие граничные условия, в результате которых в реализации у нас появляются условные операторы.

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

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

class Range
{
   
public int LowerBound { get; private set
; }
   
public int UpperBound { get; private set
; }
 
 
   public Range(int lowerBound, int upperBound)
    {
       
if
(lowerBound > upperBound)
           
throw new ArgumentException(
               
"Lower bound should be less or equal to upper bound"
);

        LowerBound = lowerBound;
        UpperBound = upperBound;
    }
}

Затем мы можем написать простые тесты, для проверки валидности диапазона:

[TestCase(0, 1, Result = true)]
[
TestCase(1, 0, Result = false)]
public bool TestRangeValidity(int lowerBound, int
upperBound)
{
   
try
    {
       
var range = new Range
(lowerBound, upperBound);
       
return true
;
    }
   
catch (ArgumentException
)
    {
       
return false;
    }
}

Но для класса Range существуют и другие граничные условия (особенно если для границ интервала использовать double, а не int), которые могут быть не выражены в коде класса Range вовсе, но которые стоит проверить в тестах. Так, например, я бы добавил тест для проверки пустого интервала [TestCase(0, 0, Result = true)], и хотя такой тест не увеличивает покрытия, информация об этом граничном условии может быть полезной сама по себе, как для меня сейчас, так и для другого разработчика в будущем.

Фред Брукс в своей книге “The Design of Design” писал, что «иногда главная проблема заключается в том, чтобы понять, в чем же заключается проблема». Внутренняя структура кода может быть отличным источником для понимания граничных условий существующего кода, но для их определения нам все равно придется включать свой мозг и думать, какие у данного класса входы и выходы, и как он должен вести себя при вызове метода в определенных условиях или при переходе из одного состояния в другое.

Так как насчет тестирования закрытых методов?

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

1. Логика в закрытом методе – это граничные условия класса, доступные через открытый интерфейс

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

2. Инварианты класса могут быть нарушены внутри закрытых методов

Инварианты класса – т.е. условия, истинные на протяжении всего времени жизни объекта могут быть нарушены в момент вызова закрытого метода. Другими словами, закрытые методы не автономны и могут работать с частично-невалидным объектом; а поскольку сделать частично-невалидный объект из тестов очень сложно, то и проверить граничные условия закрытого метода будет не просто (ведь тест – это клиент, а клиенты не должны иметь доступ к «частично-невалидным» объектам).

Так, если инвариантом класса Range является условие: LowerBound <= UpperBound, то вполне вероятно, что в одном из закрытых методов класса Range оно не будет выполняться:

public void Change(int lowerBound, int upperBound)
{
   
if
(lowerBound > upperBound)
       
throw new ArgumentException(
"Lower bound should be less or equal to upper bound"
);

    ChangeLowerBound(lowerBound);
    ChangeUpperBound(upperBound);
}

private void ChangeLowerBound(int
lowerBound)
{
    LowerBound = lowerBound;
}

private void ChangeUpperBound(int
upperBound)
{
   
// Вначале этого метода инвариант класса может быть нарушен
    // и сейчас LowerBound <= UpperBound может быть false!
    UpperBound = upperBound;
}

Метод ChangeUpperBound не автономен, поэтому он не может быть проверен изолированно юнит тестом.

3. Сложность тестирования закрытого метода говорит о скрытой абстракции

Предположим, мы добавили еще один конструктор класса Range, принимающий string и добавили закрытый метод Parse:

public Range(string range)
{
    Parse(range);
}

private void Parse(string
range)
{
   
// Анализируем входную строку; предполагаемый формат:
    // (lowerBound, upperBound)
}

Если мы хотим протестировать закрытый метод Parse класса Range, и нам не удобно это делать через открытый интерфейс, то это говорит о необходимости выделения еще одного внутреннего (internal) класса, например, RangeParser, который и будет выполнять всю «грязную» работу.

4. Нарушение инкапсуляции и хрупкость тестов

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

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

ПРИМЕЧАНИЕ
В Visual Studio 2005-2010 была возможность тестировать закрытые методы с помощью Private Accessors. Но поскольку эта практика не является рекомендуемой, эта возможность была удалена из Visual Studio 2012!

Заключение

Для того, чтобы тесты приносили больше пользы, чем вреда, их хрупкость должна быть сведена к разумному минимуму. Детали реализации должны оставаться деталями реализации, а тестирование закрытых методов (с помощью Private Accessors или через Reflection) – это путь в противоположном направлении.

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

Ссылки по теме

15 комментариев:

  1. Полностью согласна с автором. Недавно попадалась статья, как тестировать закрытые методы класса с помощью рефлексии на php. Удивляет, почему люди не могут на мгновение остановиться и спросить себя "А всё ли я делаю правильно? Слишком сложно ведь получается!"

    ОтветитьУдалить
  2. Только взглянув на заголовок, я уже понял в каком направлении пойдёт статья. Это всё правда, но согласитесь что бывают приватные методы, которые достаточно автономны чтобы их покрыть юнит-тестами. А в публичном доступе они встречаются в разных местах как строительные блоки больших методов.
    Вообще, когда задают вопрос "Как тестировать закрытые методы?" - имеют ввиду именно такие случаи.
    И вот как поступать с такими методами. Делать их публичными? Но это приводит к отмене инкапсуляции. Перенести тесты в сам класс, где-нибудь "внизу". Вариант, но тоже некошерно по ряду причин. Вопрос остаётся открытым.

    Хотя стоит заметить, что я встречал такие случаи на практике нечасто. Но когда, блин, встречаю, то не знаю что делать(

    ОтветитьУдалить
  3. @Andrey: а в чем минус выделить логику в отдельный класс и из старого приватного метода дернуть метод нового класса?

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

    ОтветитьУдалить
  4. Я понимаю что можно(а иногда даже нужно) вынести часть "приватной" логики/поведения в отдельный класс. И если эта часть формируется в более-менее внятную абстракцию, то стараюсь делать таким образом.
    Но опять же, остаются случаи, когда нормально вынести не выходит, а пару юнит-тестов хочется.

    ОтветитьУдалить
  5. У меня такое бывает, но лишь как временное явление, связанное с недопониманием задачи или чем-то подобным.

    Через время (путем развития системы или путем очередного мозгового штурма) адекватное решение находится.

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

    ОтветитьУдалить
  6. Получается, что если хочется написать тесты на закрытый метод, то делать его публичным некошерно, зато вынести в отдельный класс - правильно? Мало того, что метод станет публичным, но еще и будет на одну сущность больше.
    Я искренне не понимаю.

    ОтветитьУдалить
  7. @Dzmitry: посмотрите предыдущий комментарий, плз.

    И да, делать закрытый метод открытым некошерно, поскольку *в общем случае* он может работать с невалидным объектом или делать состояние объекта невалидным. Да и не хочу я видеть в IntelliSense методы, которые кто-то сделал открытыми только для тестирования.

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

    Тут же главный момент в том, что стремление протестировать закрытый метод - это code или design smell, который говорит о другой проблеме: текущий класс/метод делает слишком многое.

    ОтветитьУдалить
  8. Да, с помощью TypeMock или с помомощью Shims из Microsoft Fakes можно протестировать все, что угодно.

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

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

    ОтветитьУдалить
  9. > Тестировать закрытые методы
    А зачем?

    ОтветитьУдалить
    Ответы
    1. Судя по количеству статей, в которых объясняется как их тестировать, вопрос для некоторых разработчиков таки является актуальным.

      Удалить
  10. Да, например у меня такая ситуация. Поддерживаем legacy систему. Она огромная, недокументирована (что просто ужасно), без единого теста, даже нет четких соглашений по названиям. И вот, моя задача сейчас стоит в написании отчетов, логика их не тривиальная. Т.е. надо сравнить не просто поля, а сравнивать их кортежами (т.е. несколько свойств образуют собой уникальность), и уже написал ряд отчетов, и постоянно выявляется какая-то информация, которая была пропущена, невалидные значения и т.д. Хочу вместе с аналитиком составить набор входящих значений, сделать для них тест, ожидаемый результат и уже по этому тесту писать реализацию. Так вот, вся логика сравнения кортежей своя, для этого есть набор внутренних методов для сравнений, вот их я и хочу протестировать, т.к. логика каждого в отдельности весьма сложная. А так как больше нигде это не используется, едва ли вариант плодить лишнюю сущность кажется правильным.

    ОтветитьУдалить
    Ответы
    1. Ну, логика сравнения кортежей кажется самодостаточной сама по себе, так что я бы выделял внутренний класс для этого.

      Удалить
  11. Блин, честно, удивил. Никогда не тестировал закрытые методы. Я иногда использовал шимы но там для статических классов. В основном с датами или легаси, которое нельзя было переделать. Но вот тестировать закрытые методы... Кстати, может запилишь статью по поводу твоего видения статических классов. Когда использовать, для чего, как взаимодействовать при тестировании. Мне лично статические классы это боль при DI и тестировании. Иногда не нужна логика зашитая в стат. классе, а подменить ее можно только через извращение...

    ОтветитьУдалить
    Ответы
    1. Женя, я только н понял, чем именно я тебя удивил?:)))

      По поводу статических классов: можно ли рассматривать этот вопрос в более общем случае - тестируемость статических методов? Ведь они бывают как в статических, так и не в статических классах?

      Удалить