что такое тестирование белого ящика это тестирование
говориМ о тестировании
простым языком
Виды тестирования по доступу к коду
Почему оба подхода до сих пор существуют? Разве тестирование с доступом к коду не эффективнее? Может существует золотая середина? Давайте разбираться.
В терминологии тестирования фразы «тестирование белого ящика» и «тестирование чёрного ящика» относятся к тому, имеет ли тестировщик доступ к исходному коду тестируемого ПО или нет.
Черный ящик
Разработка тестов методом черного ящика (black box test design technique) — процедура создания и/или выбора тестовых сценариев, основанная на анализе функциональной или нефункциональной спецификации компонента или системы без знания внутренней структуры. (ISTQB)
Тестируемая программа для тестировщика – как черный непрозрачный ящик, содержания которого он не видит.
Основной посыл такого тестирования в том, что мы не знаем, как устроена тестируемая система. Имеется ввиду изнутри. При таком тестировании тестировщик очень похож на обычного пользователя: тест анализ и исследование продукта он проводит опираясь на ТЗ (техническое задание), спецификации и прочую документацию, которая описывает этот продукт.
Получается, что идеи для тестирования идут от предполагаемых паттернов (pattern — образец) поведения пользователей. Поэтому такой подход еще называют поведенческим.
Пример. Заходим в приложение вызова такси и видим возможность привязать карту для автоматической оплаты. Начинаем думать как пользователь:
— Что, если привязать заблокированную карту?
— А если забыть/неверно указать срок действия карты?
— Долларовая карта привяжется?
— А может можно после вызова такси и подачи машины быстро отвязать ее до списания оплаты… Что тогда будет? Спишутся средства? Или водителю придет уведомление, что оплата изменилась с безналичной на наличную?
По сути, мы тестируем и строим предположения на основе того, что видим и рисуем себе в голове. То есть мы только предполагаем, что элементы должны работать таким образом и на основании этого подбираем тесты, но точно не уверены, что это именно так.
Либо открываем спецификацию и смотрим, как система должна работать. Потом запускаем продукт и сверяем его с тем, что указано в спецификации.
Таким образом, мы не имеем представления о структуре и внутреннем устройстве системы. Нужно концентрироваться на том, что программа делает, а не на том, как она это делает.
Белый ящик
Разработка тестов методом белого ящика (white-box test design technique): Процедура разработки или выбора тестовых сценариев на основании анализа внутренней структуры компонента или системы. (ISTQB)
Белый ящик является полной противоположностью черному ящику. При тестировании черного ящика, нам необходимо запускать программу и смотреть, что она делает. А в белом этого не требуется. Достаточно смотреть на код программы.
Основной посыл этого типа тестирования — нам известны все детали реализации тестируемой программы.
Тестирование методом белого ящика (прозрачного, открытого, стеклянного ящика, основанное на коде или структурное тестирование) – метод тестирования программного обеспечения, который предполагает, что внутренняя структура/устройство/реализация системы известны тестировщику.
Мы выбираем входные значения, основываясь на знании кода, который будет их обрабатывать. Так же мы знаем, каким должен быть результат этой обработки. Знание всех особенностей тестируемой программы и ее реализации – обязательны для этой техники. Тестирование белого ящика – углубление во внутреннее устройство системы, за пределы ее внешних интерфейсов.
Профессиональные тестировщики, которые тестируют методами белого ящика, имеют большую экспертизу в программировании, так как должны уметь читать код и находить в нем проблемы.
Серый ящик. Отдельный вид или миф?
Его основной посыл в том, что нам известны только некоторые особенности реализации тестируемой системы.
То есть, внутреннее устройство программы нам известно лишь частично. Предполагается, например, доступ к внутренней структуре и алгоритмам работы ПО для написания максимально эффективных тест-кейсов, но само тестирование проводится с помощью техники черного ящика, то есть, с позиции пользователя.
Эту технику тестирования также называют методом полупрозрачного ящика: что-то мы видим, а что-то – нет.
Кто-то говорит, что этот вид тестирования — это симбиоз белого и черного ящика. Кто-то противопоставляет его белому и черному, опираясь на то, что внутренняя структура тестируемого объекта изначально известна частично и выясняется по мере исследования.
ISTQB относит тестирование методами белого и черного ящика к методам проектирования тестов. Поэтому, ни о каком «среднем» или «промежуточном» методе в этом случае конечно и речи быть не может. Мы либо разрабатываем тесты, зная код, либо не зная его. То есть в классификации ISTQB такого вида тестирования не существует.
Думаю, что на собеседовании это явно стоит упомянуть.
Почему все ящики эффективны?
Методы белого и черного ящика не являются конкурирующими или взаимоисключающими. Наоборот, они гармонично дополняют друг друга, компенсируя имеющиеся недостатки.
Параллельное использование черного и белого ящиков увеличивает покрытие возможных сценариев:
То есть у каждого из методов тестирования свои неоспоримые плюсы, которые помогают выпустить качественный продукт.
Тестирование белого ящика
Разработка программ высокого качества подразумевает, что программа и её части подвергаются тестированию. Классическое модульное (unit) тестирование подразумевает разбиение большой программы на маленькие блоки, удобные для тестов. Либо, если разработка тестов происходит параллельно с разработкой кода или тесты разрабатываются до программы (TDD — test driven development), то программа изначально разрабатыватся небольшими блоками, подходящими под требования тестов.
Одной из разновидностей модульного тестирования можно считать propery-based testing (такой подход реализован, например, в библиотеках QuickCheck, ScalaCheck). Этот подход основан на нахождении универсальных свойств, которые должны быть справедливы для любых входных данных. Например, сериализация с последующей десериализацией должна давать такой же объект. Или, повторная сортировка не должна менять порядок элементов в списке. Для проверки таких универсальных свойств в вышеупомянутых библиотеках поддерживается механизм генерации случайных входных данных. Особенно хорошо такой подход работает для программ, основанных на математических законах, которые служат универсальными свойствами, справедливыми для широкого класса программ. Есть даже библиотека готовых математических свойств — discipline — позволяющая проверить выполнение этих свойств в новых программах (хороший пример повторного использования тестов).
Иногда оказывается, что необходимо протестировать сложную программу, не имея возможности разобрать её на независимо проверяемые части. В таком случае тестируемая программа представляет собой черный белый ящик (белый — потому что мы имеем возможность изучать внутреннее устройство программы).
Под катом описаны несколько подходов к тестированию сложных программ с одним входом с разной степенью сложности (вовлеченности) и разной степенью покрытия.
*В этой статье мы предполагаем, что тестируемую программу можно представить в виде чистой функции без внутреннего состояния. (Некоторые соображения, приведённые далее, можно применять и в том случае, если внутреннее состояние присутствует, но есть возможность сброса этого состояния к фиксированному значению.)
Тестовый стенд (test bench)
Прежде всего, так как тестируется всего одна функция, код вызова которой всегда одинаков, то у нас нет необходимости создавать отдельные unit test’ы. Все такие тесты были бы одинаковыми с точностью до входных данных и проверок. Вполне достаточно в цикле передавать исходные данные ( input ) и проверять результаты ( expectedOutput ). Чтобы в случае обнаружения ошибки можно было идентифицировать проблемный набор тестовых данных, все тестовые данные надо снабдить меткой ( label ). Таким образом, один набор тестовых данных можно представить в виде тройки:
Результат одного прогона можно представить в виде TestCaseResult :
Чтобы упростить прогон всех тестовых данных через тестируемую программу, можно использовать вспомогательную функцию, которая будет вызывать программу для каждого входного значения:
Эта вспомогательная функция вернёт проблемные данные и результаты, которые отличаются от ожидаемых.
Для удобства можно отформатировать результаты тестирования
и выводить отчёт только в случае ошибок:
Подготовка входных данных
В простейшем случае можно вручную создать тестовые данные для проверки программы, записать их напрямую в тестовом коде, и использовать, как продемонстрировано выше. Часто оказывается, что интересные случаи тестовых данных имеют много общего и могут быть представлены как некоторый базовый экземпляр, с небольшими изменениями.
При работе со вложенными неизменяемыми структурами данных большим подспорьем являются линзы, например, из библиотеки Monocle:
Линзы позволяют элегантно «модифицировать» глубоко вложенные части структур данных: Каждая линза представляет собой getter и setter для одного свойства. Линзы можно соединять и получать линзы, «фокусирующиеся» на следующем уровне.
Использование DSL для представления изменений
Далее будем рассматривать формирование тестовых данных путём внесения изменений в некоторый исходный входной объект. Обычно для получения нужного нам тестового объекта требуется внести несколько изменений. При этом весьма полезно в текстовое описание TestCase’а включить перечень изменений:
Тогда мы всегда будем знать, для каких тестовых данных выполняется тестирование.
Чтобы текстовый перечень изменений не расходился с фактическими изменениями, необходимо следовать принципу «единой версии правды». (Если одна и та же информация требуется/используется в нескольких точках, то следует иметь единственный первичный источник уникальной информации, а во все остальные точки использования информация должны распространяться автоматически, с необходимыми преобразованиями. Если этот принцип нарушать, и копировать информацию вручную, то неизбежно расхождение версий информации в разных точках. То есть в описании тестовых данных мы увидем одно, а в тестовые данных — другое. Например, копируя изменение field2 = «456» и корректируя его в field3 = «789» мы можем случайно забыть исправить описание. В итоге описание будет отражать только два изменения из трёх.)
В нашем случае первичным источником информации являются сами изменения, вернее, исходный код программы, которая вносит изменения. Нам хотелось бы вывести из них текст, описывающий изменения. Навскидку, в качестве первого варианта, можно предложить использовать макрос, который будет захватывать исходный код изменений, и использовать исходный код в качестве документации. Это, по-видимому, хороший и относительно несложный способ задокументировать фактические изменения и он вполне может применяться в некоторых случаях. К сожалению, если мы представляем изменения в виде простого текста, мы теряем возможность выполнять осмысленные трансформации перечня изменений. Например, обнаруживать и устранять дублирующиеся или перекрывающиеся изменения, оформлять перечень изменений удобным для конечного пользователя способом.
Чтобы иметь возможность оперировать изменениями, необходимо иметь их структурированную модель. Модель должна быть достаточно выразительной, чтобы описывать все интересующие нас изменения. Частью этой модели, например, будет адресация полей объектов, константы, операции присваивания.
Модель изменений должна позволять решать следующие задачи:
Другим способом формирования экземпляров модели изменений может служить специализированный язык (DSL), создающий объекты моделей изменения с помощью набора extension-методов и вспомогательных операторов. Ну а в простейших случаях экземпляры модели изменений можно создавать непосредственно, через конструкторы.
Язык изменений представляет собой довольно сложную конструкцию, включающую несколько компонентов, которые также, в свою очередь, нетривиальны.
Приведём пример программы, записанной с использованием DSL:
Для представления изменений необходим набор классов того же плана, что и вышеприведённый класс SetProperty :
Интерпретатор языка изменений представляет собой обычный рекурсивный вычислитель выражений, основанный на PatternMatching’е. Что-то наподобие:
Для непосредственного оперирования свойствами объектов необходимо для каждого свойства, используемого в модели изменений, задать getter и setter. Этого можно достичь, заполнив отображение ( Map ) между онтологическими свойствами и соответствующими им линзами.
Такой подход в целом работает, и действительно позволяет описывать изменения один раз, однако постепенно появляется потребность представлять всё более сложные изменения и модель изменений несколько разрастается. Например, если необходимо изменить какое-то свойство с использованием значения другого свойства того же объекта (например, field1 = field2 + 1 ), то возникает необходимость в поддержки переменных на уровне DSL. А если изменение свойства нетривиально, то на уровне DSL потребуется поддержка арифметических выражений и функций.
Тестирование ветвей
Тестируемый код может быть линейным, и тогда нам по большому счёту достаточно одного набора тестовых данных, чтобы понять, работает ли он. В случае наличия ветвления ( if-then-else ), необходимо запускать белый ящик как минимум дважды с разными входными данными, чтобы были исполнены обе ветки. Количество наборов входных данных, достаточных для покрытия всех ветвей, по-видимому, численно равно цикломатической сложности кода с ветвлениями.
Как сформировать все наборы входных данных? Так как мы имеем дело с белым ящиком, то мы можем вычленить условия ветвления и дважды модифицировать входной объект так, чтобы в одном случае выполнялась одна ветвь, в другом случае — другая. Рассмотрим пример:
Имея такое условие, мы можем сформировать два тестовых случая:
(В случае, если один из тестовых сценариев невозможно создать, то можно считать, что обнаружен мертвый код, и условие вместе с соответствующей веткой можно спокойно удалить.)
Если в нескольких ветвлениях проверяются независимые свойства объекта, то можно довольно просто сформировать исчерпывающий набор измененных тестовых объектов, который полностью покрывает все возможные комбинации.
Рассмотрим подробнее механизм, позволяющий сформировать все возможные перечни изменений, обеспечивающие полное покрытие всех ветвлений. Для того, чтобы использовать перечень изменений при тестировании, нам надо все изменения объединить в один объект, который мы подадим на вход тестируемого кода, то есть требуется поддержка композиции. Для этого можно либо воспользоваться вышеприведённым DSL для моделирования изменений, и тогда достаточно простого списка изменений, либо представить одно изменение в виде функции модификации T => T :
тогда цепочка изменений будет представлять собой просто композицию функций:
или, для списка изменений:
Чтобы компактно записать все изменения, соответствующие всем возможным ветвлениям, можно использовать DSL следующего уровня абстракции, моделирующий структуру тестируемого белого ящика:
Здесь коллекция tests содержит агрегированные изменения, соответствующие всем возможным комбинациям ветвей. Параметр типа String будет содержать все названия условий и все описания изменений, из которых сформирована агрегированная функция изменений. А второй элемент пары типа T => T — как раз агрегированная функция изменений, полученная в результате композиции отдельных изменений.
Чтобы получить изменённые объекты, надо все агрегированные функции изменений применить к baseline-объекту:
В результате мы получим коллекцию пар, причем строка будет описывать применённые изменения, а второй элемент пары будет объектом, в котором все эти изменения объединены.
Исходя из структуры модели тестируемого кода в форме дерева, перечни изменений будут представлять собой пути от корня к листам этого дерева. Тем самым значительная часть изменений будет дублироваться. Можно избавиться от этого дублирования, используя вариант DSL, при котором изменения непосредственно применяются к baseline-объекту по мере продвижения по ветвям. В этом случае будет производиться несколько меньше лишних вычислений.
Автоматическое формирование тестовых данных
Так как мы имеем дело с белым ящиком, то можем видеть все ветвления. Это даёт возможность построения модели логики, содержащейся в белом ящике, и использования модели для генерации тестовых данных. В случае, если тестируемый код написан на Scala, можно, например, использовать scalameta для чтения кода, с последующем преобразованием в модель логики. Опять же, как и в рассмотренном ранее вопросе моделирования логики изменений, для нас затруднительно моделирование всех возможностей универсального языка. Далее будем предполагать, что тестируемый код реализован с использованием ограниченного подмножества языка, либо на другом языке или DSL, который изначально ограничен. Это позволяет сосредоточиться на тех аспектах языка, которые представляют для нас интерес.
Рассмотрим пример кода, содержащего единственное ветвление:
Подобным образом можно генерировать данные, подходящие под ограничения, порождаемые простыми условными операторами с константами (больше/меньше константы, входит во множество, начинается с константы). Такие условия нетрудно обратить. Даже если в тестируемом коде вызываются несложные функции, то мы можем заменить их вызов на их определение (inline) и всё-таки осуществить обращение условных выражений.
Трудно обратимые функции
Иначе обстоит дело в том случае, когда в условии используется функция, которую затруднительно обратить. Например, если используется хэш-функция, то автоматически генерировать пример, дающий требуемое значение хэш-кода, по-видимому, не получится.
В таком случае можно добавить во входной объект дополнительный параметр, представляющий результат вычисления функции, заменить вызов функции на обращение к этому параметру, и обновлять этот параметр, невзирая на нарушение функциональной связи:
Дополнительный параметр позволяет обеспечить выполнение кода внутри ветки, но, очевидно, может привести к фактически некорректным результатам. То есть тестируемая программа будет выдавать результаты, которые никогда не могут наблюдаться в реальности. Тем не менее, проверка части кода, которая иначе нам недоступна, всё равно полезна и может рассматриваться как разновидность модульного тестирования. Ведь и при модульном тестировании подфункция вызывается с такими аргументами, которые, возможно, никогда не будут использоваться в программе.
При таких манипуляциях мы заменяем (подменяем) объект тестирования. Тем не менее, в каком-то смысле новая построенная программа включает логику прежней программы. Действительно, если в качестве значений новых искуственных параметров взять результаты вычисления функций, которые мы заменили на параметры, то программа выдаст те же самые результаты. По-видимому, тестирование изменённой программы по-прежнему может представлять интерес. Надо лишь помнить, при каких условиях изменённая программа будет вести себя также, как исходная.
Зависимые условия
Если при формировании уточняющих множеств мы обнаружим, что одно из подмножеств пусто, то это означает, что условие всегда будет принимать фиксированное значение true или false вне зависимости от входных значений. Поэтому соответствующая ветка, которая никогда не вызывается, является «мертвым кодом» и может быть удалена из кода вместе с условием.
Связанные параметры
Рассмотрим случай, когда условие ветвления основано на двух полях объекта, также связанных условиями:
Символьное выполнение
Для того, чтобы собрать все условия, порождающие результат по какой-либо из ветвей, можно воспользоваться «cимвольным выполнением» (Symbolic Execution, Символьное выполнение программ), суть которого заключается в следующем. Входные данные принимаются равными некоторым символьным значениям ( field1 = field1_initial_value ). Затем над символьными значениями производятся все манипуляции, описанные в тестируемом коде. Все манипуляции выполняются в символьном же виде:
Накопленные в символьном виде ограничения можно использовать либо для формирования генератора, порождающего значения, удовлетворяющие этим ограничениям, либо для проверки случайных значений, формируемых менее точным генератором. В любом случае появляется возможность генерировать случайные данные, приводящие к исполнению заранее известного пути (и, возможно, к известному результату).
Тестирование циклов и рекурсивных функций
До сих пор мы обходили вопрос циклов стороной. Связано это с тем, что в цикле меняется состояние, то есть цикл обязательно использует изменяемую переменную. Мы же обозначили границы нашего рассмотрения чистыми функциями, что подразумевает использование только неизменяемых структур данных. Также при наличии циклов существует риск формирования таких условий, при которых результат не будет получен за разумное время.
Известно, что любой цикл можно заменить рекурсией. Это может быть непросто для сложных циклов. Но, допустим, что в нашем случае такая операция была произведена. Тем самым, в тестируемом коде будут встречаться рекурсивные вызовы, а мы можем продолжать наши рассуждения, сохраняя исходное предположение о рассмотрении только чистых функций. Как мы могли бы протестировать такой белый ящик, учитывая тот факт, что рекурсивные вызовы, так же, как и циклы, могут не завершиться за разумное время?
Воспользуемся такой конструкцией как Y-комбинатор («комбинатор неподвижной точки», stackoverflow:What is a Y-combinator? (2-ой ответ), habr: Получение Y-комбинатора в 7 простых шагов). Комбинатор позволяет реализовать рекурсию в языках, которые рекурсию в чистом виде не поддерживают. (Сам комбинатор является рекурсивным, поэтому должен быть реализован на языке, поддерживающем рекурсию.) Работает он следующим образом. Из рекурсивной функции удаляются все рекурсивные вызовы и заменяются на вызовы функции, которая передаётся в качестве дополнительного аргумента. Такая переработанная функция уже не будет являться рекурсивной, а служит только «заготовкой» для получения целевой функции. Y-комбинатор превращает такую «заготовку рекурсивной функции» в полноценную рекурсивную функцию (передавая в качестве аргумента собственное продолжение).
В случае общей рекурсии рекурсивный вызов возвращает результат, который затем используется. В этом случае вышеприведённый подход напрямую не работает. Можно попробовать применить подход, аналогичный тому, что мы использовали для вызовов трудно обратимых функций. А именно, заменим каждый рекурсивный вызов на новый параметр. Значение этих параметров можно будет генерировать как обычно, исходя из условий ветвлений, в которых эти параметры используются. Как и в случае с заменой вызовов функций на параметры, результаты, которые мы будем получать, могут отличаться от результатов, которые мы можем получить в действительности. Совпадение будет достигаться в том случае, если значение параметра совпадает со значением рекурсивной функции. Такой подход позволяет нам протестировать шаги, выполняемые после рекурсивного вызова.
Смысл тестирования белого ящика
При определённом усердии можно добиться того, что тесты, написанные вручную или сгенерированные автоматически, будут покрывать все ветви тестируемого кода, то есть обеспечат 100% покрытие. Тем самым мы сможем с уверенностью сказать, что белый ящик делает то, что он делает. Хм. Секундочку. А в чём, собственно, смысл такого тестирования, спросит внимательный читатель? Ведь для любого содержимого белого ящика будут построены тесты, которые только лишь подтверждают, что белый ящик работает каким-то определённым образом.
В некоторых случаях такой набор тестов всё же может иметь смысл:
Следует иметь в виду некоторые особенности тестирования, основанного на реализации, в отличие от тестирования на основе спецификации. Во-первых, если изначальная реализация не поддерживала некоторую функциональность, которую можно было бы ожидать, основываясь на спецификации, то наши тесты не заметят её отсутствия. Во-вторых, если такая функциональность присутствовала, но работала иначе, чем указано в спецификации (то есть, с ошибками), то наши тесты не просто этих ошибок не обнаружат, а напротив, ошибки будут «кодифицированы» в тестах. И если последующие/альтернативные реализации попробуют исправить ошибки, то такие тесты не позволят этого просто так сделать.
Заключение
Тестирование белого ящика смещает акцент с вопроса «что должен делать код» на «что фактически делает код». Иными словами, вместо использования более высокого уровня абстракции, формирования тестов на основе спецификации, используется точно тот же уровень абстракции, что и при реализации кода. Мы можем получить хорошие результаты в плане покрытия кода, но при этом такое тестирование имеет смысл в ограниченном наборе случаев.
Если вы столкнулись с таким случаем, в котором тестирование белого ящика оправдано, то соображения, приведённые выше, могут пригодиться. Во-первых, основные усилия имеет смысл сосредоточить на формировании тестовых наборов данных, так как вход у белого ящика один (вызов функции), а протестировать хотелось бы все ветви. Во-вторых, по-видимому, имеет смысл построить модель тестируемого кода. Для этого может использоваться специализированный DSL, достаточно выразительный, чтобы представлять тестируемую логику. В-третьих, пользуясь моделью тестируемой логики можно попробовать автоматически сформировать тестовые данные, покрывающие все ветви. В-четвертых, тестируемый код может быть подвергнут автоматическим преобразованиям, которые делают его более удобным для тестирования (исключение вызовов труднообратимых функций, переход от циклов к рекурсии, исключение рекурсивных вызовов). При использовании этих подходов можно получить хорошие результаты в плане покрытия кода.
Таким образом, в благоприятных условиях и при реализации некоторых из вышеприведённых подходов, появляется возможность автоматической генерации содержательных тестов. Возможно, заинтересованные читатели предложат и другие области, где могло бы применяться тестирование белого ящика или какие-либо из рассмотренных подходов.
Благодарности
Хотелось бы поблагодарить @mneychev за терпение и неоценимую помощь при подготовке статьи.