Следует ли тестировать внутреннюю реализацию или только тестировать публичное поведение?

Учитывая программное обеспечение, где...

  • Система состоит из нескольких подсистем
  • Каждая подсистема состоит из нескольких компонентов
  • Каждый компонент реализован с использованием множества классов

... Мне нравится писать автоматизированные тесты каждой подсистемы или компонента.

Я не пишу тест для каждого внутреннего класса компонента (за исключением того, что каждый класс вносит вклад в открытую функциональность компонента и поэтому тестируется / тестируется извне через открытый API компонента).

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

Я думаю, что эта политика контрастирует с таким документом, как Refactoring Test Code, в котором говорится что-то вроде...

  • "... модульное тестирование..."
  • "... тестовый класс для каждого класса в системе..."
  • "... соотношение тестового кода / производственного кода... в идеале считается равным 1: 1..."

... со всем, что, я полагаю, я не согласен (или, по крайней мере, не практикую).

Мой вопрос: если вы не согласны с моей политикой, объясните почему? В каких случаях этого уровня тестирования недостаточно?

В итоге:

  • Публичные интерфейсы тестируются (и повторно тестируются) и редко меняются (они добавляются, но редко изменяются)
  • Внутренние API скрыты за общедоступными API и могут быть изменены без переписывания тестовых примеров, которые тестируют общедоступные API.

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

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

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

Сноска: под "компонентом" я подразумеваю что-то вроде "одна DLL" или "одна сборка"... что-то достаточно большое, чтобы его можно было увидеть на архитектуре или диаграмме развертывания системы, часто реализуемой с использованием десятков или 100 классов, и с открытым API, который состоит только из 1 или нескольких интерфейсов... что-то, что может быть назначено одной команде разработчиков (где другой компонент назначен другой команде), и которое, следовательно, согласно закону Конвея будет иметь относительно стабильный публичный API.


Сноска. В статье " Объектно-ориентированное тестирование: миф и реальность" говорится:

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

Реальность: ОО структура имеет значение, часть II. Многие исследования показали, что тестовые наборы "черного ящика", которые разработчики считают крайне мучительными, выполняют только от одной трети до половины утверждений (не говоря уже о путях или состояниях) в тестируемой реализации. Для этого есть три причины. Во-первых, выбранные входы или состояния обычно используют нормальные пути, но не форсируют все возможные пути / состояния. Во-вторых, одно только испытание черного ящика не может выявить сюрпризов. Предположим, что мы протестировали все указанные поведения тестируемой системы. Чтобы быть уверенным, что не существует неопределенного поведения, мы должны знать, не выполнялись ли какие-либо части системы тестовым набором черного ящика. Единственный способ получить эту информацию - использовать инструментальные средства кода. В-третьих, зачастую сложно осуществлять обработку исключений и ошибок без проверки исходного кода.

Я должен добавить, что я выполняю функциональное тестирование whitebox: я вижу код (в реализации) и пишу функциональные тесты (которые управляют публичным API) для проверки различных ветвей кода (подробности реализации функции).

15 ответов

Решение

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

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

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

Модульный тест здесь, чтобы проверить, что 10 строк кода, которые вы только что написали, делают то, что должны. Это дает вам большую уверенность в вашем коде.

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

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

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

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

Другими словами, правильно проверять только публичное поведение, если вы заметили, что у каждого публично видимого класса есть публичное поведение.

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

  • Внутренний модульный тест:
    От разработчиков ожидается создание модульных тестов для всего кода, который они пишут (читай: каждый метод). Модульные тесты должны охватывать положительные тестовые условия (работает ли мой метод?) И отрицательные тестовые условия (выдает ли метод ArgumentNullException, если один из моих обязательных аргументов равен нулю?). Обычно мы включаем эти тесты в процесс сборки, используя такой инструмент, как CruiseControl.net
  • Тест системы / тест сборки:
    Иногда этот шаг называется чем-то другим, но это когда мы начинаем тестировать общедоступную функциональность. Как только вы узнаете, что все ваши отдельные устройства функционируют должным образом, вы захотите узнать, что ваши внешние функции также работают так, как вы думаете. Это форма функциональной проверки, поскольку цель состоит в том, чтобы определить, работает ли вся система так, как она должна работать. Обратите внимание, что это не включает в себя точки интеграции. Для тестирования системы вы должны использовать макеты интерфейсов вместо реальных, чтобы вы могли контролировать вывод и строить тестовые сценарии вокруг него.
  • Тест системной интеграции:
    На этом этапе процесса вы хотите подключить точки интеграции к системе. Например, если вы используете систему обработки кредитных карт, на этом этапе вы захотите включить действующую систему, чтобы убедиться, что она все еще работает. Вы хотите выполнить тестирование, аналогичное тестированию системы / сборки.
  • Тест функциональной проверки:
    Функциональная проверка - это пользователи, работающие в системе или использующие API, чтобы убедиться, что она работает должным образом. Если вы создали систему выставления счетов, это тот этап, на котором вы будете выполнять тестовые сценарии от начала до конца, чтобы убедиться, что все работает так, как вы ее разработали. Это, очевидно, критический этап в процессе, поскольку он говорит вам, выполнили ли вы свою работу.
  • Сертификационный тест:
    Здесь вы ставите реальных пользователей перед системой и позволяете им попробовать. В идеале вы уже протестировали свой пользовательский интерфейс в какой-то момент со своими заинтересованными сторонами, но на этом этапе вы узнаете, нравится ли вашему продукту ваша целевая аудитория. Возможно, вы слышали, что другие поставщики называют это "кандидатом на релиз". Если на этом этапе все идет хорошо, вы знаете, что хорошо переходить в производство. Сертификационные тесты всегда должны проводиться в той же среде, которую вы будете использовать для производства (или, по крайней мере, в идентичной среде).

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

Конечно, модульные тесты также могут быть неправильными, но если вы разрабатываете свои тестовые примеры из своей функциональной / технической спецификации (у вас есть, верно?;)), У вас не должно быть особых проблем.

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

В рамках ограниченной практики TDD я видел, как это помогает мне очищать модульные тесты для каждого логического условия, создаваемого кодом. Я не совсем уверен, что мои общедоступные интерфейсы раскрывают 100% логических функций моего частного кода. Использование TDD кажется дополнительным для этого показателя, но все еще есть скрытые функции, которые не разрешены общедоступными API.

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

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

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

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

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

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

Дайте мне знать, если вы разработали какой-либо хороший процесс, который работает для вас.. так как ваш первый пост..

С наилучшими пожеланиями

Я согласен с большинством сообщений здесь, но я бы добавил это:

Первостепенное значение имеет проверка открытых интерфейсов, затем защищенных, а затем частных.

Обычно открытые и защищенные интерфейсы представляют собой совокупность сочетаний частных и защищенных интерфейсов.

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

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

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

Раньше я думал точно так же, как демонстрирует CrisW в этом вопросе - что тестирование на более высоких уровнях было бы лучше, но после получения некоторого опыта мои мысли смягчаются до чего-то между этим и "у каждого класса должен быть тестовый класс". У каждого юнита должны быть тесты, но я решил определить свои юниты немного иначе, чем когда-то. Это могут быть "компоненты", о которых говорит CrisW, но очень часто это всего лишь один класс.

Кроме того, функциональные тесты могут быть достаточно хорошими, чтобы доказать, что ваша система делает то, что должна делать, но если вы хотите управлять своим дизайном с помощью примеров / тестов (TDD/BDD), тесты нижнего рычага являются естественным следствием. Вы можете выбросить эти низкоуровневые тесты, когда закончите реализацию, но это будет пустая трата времени - тесты являются положительным побочным эффектом. Если вы решите провести радикальный рефакторинг, аннулирующий ваши низкоуровневые тесты, вы их выбрасываете и один раз пишете новые.

Разделение цели тестирования / проверки вашего программного обеспечения и использование тестов / примеров для управления вашим проектом / реализацией может многое прояснить.

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

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

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

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

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

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

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

[Ответ на мой собственный вопрос]

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

  • Аксиома: каждый программист должен тестировать свой собственный код

  • Поэтому: если программист пишет и поставляет один "модуль", то он должен был также проверить этот модуль, вполне возможно, написав "модульный тест"

  • Следствие: если один программист пишет целый пакет, то программисту достаточно написать функциональные тесты всего пакета (нет необходимости писать "модульные" тесты модулей внутри пакета, поскольку эти модули являются деталями реализации, для которых другие программисты не имеют прямого доступа / воздействия).

Аналогичным образом, практика создания "фиктивных" компонентов, с которыми вы можете проверить:

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

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

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

Я лично проверяю защищенные части тоже, потому что они "общедоступны" для унаследованных типов...

Аксиома: каждый программист должен тестировать свой собственный код

Я не думаю, что это универсально правда.

В криптографии есть известная поговорка: "Легко создать шифр, настолько безопасный, что вы не знаете, как его взломать самостоятельно".

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

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

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

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

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

Я согласен, что охват кода в идеале должен быть 100%. Это не обязательно означает, что 60 строк кода будут иметь 60 строк тестового кода, но каждый путь выполнения проверяется. Единственное, что раздражает больше, чем ошибка - это ошибка, которая еще не запущена.

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

Другие вопросы по тегам