Ч 1. Гл 9. Ограничения классов эквивалентности

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

-----------------------------------------

Есть относительно сложная структура данных. Это дерево, которое является визуальным представлением другого ("теневого") массива информации.

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

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

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

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

-----------------------------------------

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

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


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

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

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

-----------------------------------------

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

При этом, программист случайно подписывал визуальные объекты на события два раза. Отписывал же - только один. В результате чего, объекты не удалялись (язык со сборщиком мусора), и, кроме этого, эти объекты ещё и получали уведомления о событиях.

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

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

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

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

-----------------------------------------

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


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

У автора был случай, когда из более чем десятка различных сочетаний опций сохранения не работала только одна комбинация.
Причём впечатление было, что код сохранения в этой комбинации никак не изменялся. То есть код сохранения изменялся, но не для этой конкретной опции. А не работала только и именно она.
Найти ошибку просто сверив одну ревизию системы контроля версий с другой было затруднительно, хотя они были рядом. Хорошо ещё, что автоматически запущенные автоматизированные тесты быстро нашли ошибку и не дали уйти в разработке программы слишком далеко. Но всё равно изменения были уже слишком сложными.
Локализовать ошибку было очень тяжело даже несмотря на то, что правки казались не очень большими (сделанными менее чем за день).

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


-----------------------------------------

Ещё один забавный случай произошёл со мной на летней практике, когда я учился в ВУЗе.
Нужно было протестировать микроконтроллер, который был подключён к компьютеру через интерфейс RS-232 (COM-порт с переходником).
Сам микроконтроллер, да и компьютер, были довольно быстродействующими, и 16 миллионов вариантов протестировали бы очень быстро.
Радостно начав писать довольной сложный по логике тест, через две недели я понял, что сам интерфейс взаимодействия такой медленный, что 16 миллионов варинатов просто не осилит. Компьютер генерировал тестовые варианты быстро, микроконтроллер быстро их отрабатывал, однако порт работал так медленно, что тестирование могло бы занять более месяца.
В итоге, данную программу пришлось переписывать так, чтобы сам микроконтроллер генерировал соответствующие тестовые задания и сам же их проверял. Точная визуализация и проверка теста на компьютере делалась невозможной, так как он не мог получить от контроллера столько данных.

-----------------------------------------

Рассмотрим ещё один теоретический случай. Напишем на любом C-подобном языке следующий код:

for (sbyte i = start; i < n; i += 2);

То есть этот цикл ничего не делает, просто значения i пробегают с шагом 2 значения от start до n, не включая n.

Если вы не знакомы с данным синтаксисом, я поясню.
Например, для start = 0 и n = 3.
i = 0. Дальше следует проверка, i < n, то есть 0 < 3. Это верно. Значит входим в тело цикла (оно пустое, состоит из точки с запятой).
Тело цикла завершается командой i += 2, то есть i = 0 + 2.
i = 2. 2 < 3. Это верно. Входим в тело цикла. i = 2 + 2.
i = 4. 4 < 3. Условие цикла ложно. Цикл завершается с i = 4, но для i = 4 тело цикла уже не выполняется.


Давайте посмотрим, какие здесь есть классы эквивалентности в этом цикле.
Очевидно, есть переменные start и n.
Если подходить без учёта того, как они используются, то классы эквивалентности можно разбить на [меньше нуля, ноль, больше нуля].
Конечно, лучше брать все особые точки.
То есть для любой переменной классы эквивалентности будут [минимальное значение; минус единица; ноль; единица; максимальное значение].

Представим себе, для упрощения, что у нас используются целочисленные переменные со знаком длинной в один байт (8 битов) от -128 до +127.
Тогда мы должны протестировать произведение разбиения классов эквивалентности.

Давате напишем программу на C#, которая тестирует указанный цикл.

using System;

namespace Test
{
    class Program
    {
        static void Main(string[] args)
        {
            sbyte[] eq = {sbyte.MinValue, -1, 0, +1, sbyte.MaxValue};

            for (var a = 0; a < eq.Length; a++)
            {
                for (var b = 0; b < eq.Length; b++)
                {
                try
                {
                cycle(eq[a], eq[b]);
                }
                catch
                {
                Console.WriteLine("ERROR for " + eq[a] + ", " + eq[b]);
                }
                }
            }

            Console.WriteLine("ended");
            Console.ReadKey();
        }

        static void cycle(sbyte start, sbyte n)
        {
            int k = 0;
            for (sbyte i = start; i < n; i += 2)
            {
                k++;
                if (k > 128)
                throw new Exception();
            }
        }
    }
}


Для упрощения тестирования, я вставил контрольный счётчик, защищающий от зацикливания. Так как тестировать этот цикл можно только на зацикливание.

Как видим, всё хорошо, на этот раз мы нашли ошибку: цикл зациклился на значениях -128, 127 и 0, 127.
Как видим, нам обязательно надо было брать особые точки в виде максимальных и минимальных значений переменных, иначе цикл бы прошёл.

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

Смотрите. Это константа 2. И это разница i - n. Точнее, так как i мы не задаём, это разница start - n.
Разница start - n также может приобретать значения [минимальное значение; минус единица; ноль; единица; максимальное значение]. Кроме этого, она может принимать значение переполнения в ту или в другую сторону. Причём переполнение это может быть со знаком (флаг OF процессора) или без знака (флаг CF процессора).

Давайте определим новые классы эквивалентности и для этой разницы.
int  [] eq2 = {0, 1, 127, 128, 255, 256};

Наша программа будет выглядеть так.

using System;

namespace Test
{
    class Program
    {
        static void Main(string[] args)
        {
            sbyte[] eq  = {sbyte.MinValue, -1, 0, +1, sbyte.MaxValue};
            int  [] eq2 = {0, 1, 127, 128, 255, 256};

            for (var a = 0; a < eq.Length; a++)
            {
                for (var b = 0; b < eq.Length; b++)
                {
                try
                {
                cycle(eq[a], eq[b]);
                }
                catch
                {
                Console.WriteLine("ERROR for " + eq[a] + ", " + eq[b]);
                }
                }
               
                for (var b = 0; b < eq2.Length; b++)
                {
                try
                {
                cycle(eq[a], (sbyte) (eq[a]+eq2[b]));
                }
                catch
                {
                Console.WriteLine("ERROR for " + eq[a] + ", " + (sbyte) (eq[a]+eq2[b]));
                }
                }

                for (var b = 0; b < eq2.Length; b++)
                {
                try
                {
                cycle((sbyte) (eq[a]+eq2[b]), eq[a]);
                }
                catch
                {
                Console.WriteLine("ERROR for " + (sbyte) (eq[a]+eq2[b]) + ", " + eq[a]);
                }
                }

                for (var b = 0; b < eq2.Length; b++)
                {
                try
                {
                cycle(eq[a], (sbyte) (eq[a]-eq2[b]));
                }
                catch
                {
                Console.WriteLine("ERROR for " + eq[a] + ", " + (sbyte) (eq[a]-eq2[b]));
                }
                }

                for (var b = 0; b < eq2.Length; b++)
                {
                try
                {
                cycle((sbyte) (eq[a]-eq2[b]), eq[a]);
                }
                catch
                {
                Console.WriteLine("ERROR for " + (sbyte) (eq[a]-eq2[b]) + ", " + eq[a]);
                }
                }
            }

            Console.WriteLine("ended");
            Console.ReadKey();
        }

        static void cycle(sbyte start, sbyte n)
        {
            int k = 0;
            for (sbyte i = start; i < n; i += 2)
            {
                k++;
                if (k > 128 || i < start)
                throw new Exception();
            }
        }
    }
}


Если мы посмотрим, то мы увидим, что такой выбор даёт больше ошибок. В принципе, в них уже нет никакого смысла в данном примере. Однако, рассмотрение такого класса эквивалентности дало нам возможность получить больше срабатываний. Значит, мы шли по правильному пути повышения вероятности срабатывания теста.
Ещё больше ошибок было бы, если бы мы учли, что разница между start и n должна перекрывать также шаг переменной i. То есть, грубо говоря, мы уже берём не start - n, а start - n + step и start - n - step.
То есть отличия должны быть от контрольных точек должны быть от 0 до 3.

Для этого перепишем массив int  [] eq2 = {0, 1, 2, 3, 127-3, 127-2, 127-1, 127, 128, 255-3, 255-2, 255-1, 255, 256};


Ну и, конечно, на всякий случай, мы должны были бы взять значения где-то по середине класса эквивалентности, например, -63 и +64. На значении (+64, 127), кстати, тоже получим срабатывание. Хотя здесь это уже не принципиально. Но иногда, брать точки по середине класса эквивалентности важно.


Кстати. Мы могли бы сразу взять в качестве контрольных точек не массив
sbyte[] eq  = {sbyte.MinValue, -63, -1, 0, +1, +64, sbyte.MaxValue};

а массив
sbyte[] eq  = {sbyte.MinValue, sbyte.MinValue+1, -63, -1, 0, +1, +64, sbyte.MaxValue-1, sbyte.MaxValue};

Так как, часто, стоит проверить не только точки границ диапазонов, но и точки, находящиеся рядом с ними.
Обратите внимание, что точки sbyte.MinValue-1 и sbyte.MaxValue+1 мы уже и так проверяем (это sbyte.MaxValue и -1).


-----------------------------------------
-----------------------------------------

Выводы здесь простые. Ищите побочные эффекты. Замеряйте производительность. Даже если она упала незаметно для тестировщика, количественный показатель может изменится в разы, а то и на порядок. Это позволит сразу же выявить проблему.
Разбивайте программу не только по классам эквивалентности, но и по функциональным единицам. Предполагайте в них ошибки, в том числе, самые дебильные ("не реализовано", например).
Пишите автоматизированные тесты. Не обязательно следовать методике TDD (Test driven development), но тесты помогают. Даже когда кажется, что тесты излишни, лучше протестировать все возможные сочетания вариантов настроек.
При проектировании стоит как можно больше снижать количество вариантов настроек, чтобы их возможно было бы протестировать во всех сочетаниях. Даже когда кажется, что всё просто, непротестированные варианты могут содержать ошибки. Упрощение программы, в данном случае, может повысить её надёжность.

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

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


Рецензии