Автор: Sergey Teplyakov

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

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) это путь в противоположном направлении.

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

Ссылки по теме
  • Тестируемый дизайн vs. Хороший дизайн
  • My Take on Unit Testing Private Methods

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

You have no rights to post comments

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

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