Новости‎ > ‎

Рандомный генератор тестовых случаев «Continuous Database Tests»

Отправлено 31 мая 2011 г., 6:32 пользователем Uladzimir Kryvenka   [ обновлено 31 мая 2011 г., 8:53 ]

Автор статьи: Павел Жур;

Источник: Статья "Рандомный генератор тестовых случаев «Continuous Database Tests»", ресурс Bugs Catcher

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

  • Проект: наша ситуация;
  • Тестовый фреймворк;
  • Второй сценарий использования;
  • Третий и четвертый сценарии использования;
  • Реализация;
  • Фишки;
  • Пару слов о результате.

1. Проект: наша ситуация

Наш проект — это система управления проектами Birdview Projects. Архитектура довольно обычная: UI (HTML, JavaScript), бизнес-логика (C#), база данных (MS SQL). Нюанс состоит в том, что в базе данных, помимо исходных данных, хранятся еще некоторые вычисляемые на лету значения, которые нужно поддерживать в актуальном состоянии. Значений этих много, и поддержка их написана прямо в базе данных, триггерами. Данный факт делает систему более производительной.

Чтобы было ясно, о чем идет речь

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

А вот вычисляемые таблицы и поля:

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

Пересчеты осложняются тем, что:

  • Учитывают поддержку удаления и восстановления любой сущности;
  • Должны работать верно при необычных значениях в некоторых местах: например, у задачи мог быть отрицательный, положительный, нулевой и NULL бюджет. Всё это имело бизнес-смысл, но делало ручное тестирование еще более затруднительным.

Наша ситуация

Минус нашей ситуации состоял в том, что правильность пересчетов было невозможно протестировать вручную: сложные бизнес-правила, век на то, чтобы воспроизвести все случаи и идеальная голова, чтобы заметить все ошибки. Зато был плюс: после вычисляемых значений логика была достаточно простой, а сами значения почти всегда подчинялись однозначным правилам, т.е. их легко можно было проверить. Плюс мы использовали очень просто: написали хранимую процедуру CheckPermissionsIntegrity, которая проверяла, всё ли с базой в порядке. А обернув все модифицирующие запросы к базе данных в транзакции, в конце которых запускали CheckPermissionsIntegrity, и которые откатывали в случае ошибки, мы избавились от одного минуса — идеальную голову больше можно было не иметь: если база данных приходила в невалидное состояние, выскакивала ошибка. Оставался серьезный минус: как воспроизвести все случаи, когда пересчеты работают неверно. Решено было все это дело хорошенько заавтоматизировать.

2. Тестовый фреймворк

Итак, оставалось решить вопрос: как гарантировать, что пересчеты всегда работают верно? О юнит-тестах не могло быть и речи: слишком много случаев. Здесь важно отметить две вещи:

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

Тогда нами было сделано одно предположение – любой баг в пересчетах можно воспроизвести на небольших объемах данных. Скажем, до 10 проектов, до 30 задач, до 20 пользователей, до 5 компаний, до 20 сообщений. Основная идея была такая: мы можем сгенерировать все возможные комбинации и мы знаем множество всех возможных действий. Если из каждой комбинации мы попробуем совершить каждое возможное для неё действие и не получим ошибок, значит система не имеет дефектов. Если мы получим ошибки, у нас тут же будут шаги для воспроизведения. Но с этой идеей была одна сложность: сгенерировать все возможные комбинации непросто. Тогда мы подумали, что поскольку мы все равно должны будем написать вызовы каждого возможного действия и определять для него все возможные наборы параметров, то уже только этих вызовов достаточно, чтобы сгенерировать все возможные ситуации, в которые база данных может прийти.

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

  • Написать на каждое возможное действие два метода: тот, который это действие вызывает с заданными параметрами (Action) и тот, который определяет множество всех возможных параметров, с которыми в данный момент валидно вызвать это действие (ActionEnterInfo);
  • Написать сценарий выполнения, который дергает эти методы и следит за тем, чтобы база данных оставалась маленькой;
  • Написать фреймворк, который предоставляет среду и связывает первое с другим, а заодно логирует это всё и автоматически умеет делать бэкапы, если возникают ошибки.

3. Второй сценарий использования

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

  • Тестируем поверхностно;
  • Тестируем углубленно отрицательные, положительные и нулевые значения (множество расширялось);
  • Тестируем углубленно даты (множество расширялось, выбирая специальным образом хитрые варианты дат).

4. Третий и четвертый сценарии использования

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

5. Реализация

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

  • метод, помеченный атрибутом Action, который принимает столько параметров, сколько ему надо, и выполняет это действие;
  • метод, помеченный атрибутом ActionEnterInfo. Если с первым всё понятно, то про второй нужно рассказать подробнее.

ActionEnterInfo-метод

Это метод, который помогает генерировать множество всех возможных комбинаций параметров для первого (Action) метода. Каждой такой комбинации он ставит в соответствие некий вектор: это сделано, чтобы можно было перебрать все возможные комбинации или же выбрать, например, десять случайных. Размерность этих векторов не ограничена, ее метод выбирает сам.

Итак, этот метод инкапсулирует логику работы с входными параметрами. Логика эта, прежде чем вернуть результат, обращается к базе данных и читает из нее то, что ей нужно, чтобы гарантировать, что каждая комбинация параметров, которая будет возвращена, корректна для данного состояния базы данных. Это касается как очевидных вещей, например, идентификаторов существующих сущностей, так и бизнес-правил. Например, поскольку человек, не имеющий доступа к проекту, не может быть назначен на задачу этого проекта, то наш метод, AssignActionEnterInfo никогда не вернет такую пару (userId, taskId) для этого действия. Кроме того, метод умеет возвращать как сразу множество всех наборов целиком, так и одну комбинацию по соответствующему вектору. Он также предоставляет данные о диапазоне векторов (а именно, диапазонах значений каждой координаты). Происходит это вот как: он создает и возвращает объект специального класса, в котором заполняет обязательные поля и, возможно, некоторые опциональные. Поля этого класса перечислены ниже. Чтобы понять, что там написано, нужно знать, что Func< A, B, C, …, X > – это делегат функции, принимающей параметры типов A, B, C… соответственно, и возвращающей параметр типа X.

  • int Dimension – размерность векторов;
  • Func < KeyValuePair[] > GetAllParameters – делегат функции, возвращающей множество всех возможных комбинаций и соответствующие им векторы;
  • Func< int[], object[] > GetParameterByVector – делегат функции, которая принимает вектор и возвращает комбинацию;
  • Func< int, int[], ParameterRangeInfo > GetParametersRange – делегат функции, которая возвращает информацию о диапазоне конкретной координаты, получая индекс этой координаты и значения предыдущих координат (диапазон может от них зависеть). Диапазон ParameterRangeInfo может быть задан множеством или отрезком;
  • ParameterDeterminismInfo[] ParameterDeterminism – массив перечислений ParameterDeterminism со следующим списком значений: Deterministic, NonDeterministicNoInfluenceOnResult, NonDeterministicLowInfluenceOnResult, NonDeterministicHighInfluenceOnResult. Этот массив содержит столько элементов, сколько параметров принимает Action-метод, который выполняет операцию. Каждый элемент показывает, детерминировано ли значение соответствующего параметра от вектора (Deterministic/NonDeterministic) и если да, то насколько существенно это значение влияет на состояние базы данных. Этот массив несложно заполнить и он используется в основном для поддержки.
  • Func – метод, который может возвратить информацию о том, стоит ли при выборе случайного вектора делать вероятностное распределение равномерным или неравномерным. Он может задавать веса для каждого значения координаты вектора, изменив равномерность. Например, это использовалось для контроля операции удаления задач: задачи в системе иерархические и те задачи, у которых меньше детей, хотелось бы удалять чаще, чем те, которые близки к корню.
  • Этот метод, ActionEnterInfo, может также принимать какие-то параметры, которые будут каким-то образом влиять на то множество, которое он задаст. Это используется в тех случаях, про которые я упоминал выше: углубленное тестирование целых чисел (положительных, отрицательных, нулевых и NULLевых) и дат.

    Scenario-метод

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

    Многопоточный Scenario-метод

    Многопоточная версия этого метода возращает массив енумераций (IEnumerable[]).

    6. Фишки

    Кроме того, была написана еще и такая вот функциональность:

    • Все действия писались в лог, реализованный сторонней базой данных (там всего три таблицы). Написав SQL-запрос к логу можно легко собрать любуо статистику;
    • Есть возможность попросить фреймворк делать бэкапы при ошибках. Т.е. при возникновении ошибки он откатывает транзакцию и делает бэкап;
    • Есть возможность попросить фреймворк совершить еще раз (replay) любое действие из лога, которое вызвало ошибку. Он разворачивает ближайший бэкап и пробует совершить это действие еще раз;
    • Все Action-методы (методы действий) могут помечаться специальным аттрибутом, указав в скорбках любое перечисление (enum). Так сценарии получили возможность выбирать действия, не зная ничего о конкретных действиях, а просто по какому-то значению, которое разработчик выбирает, исходя из своих необходимостей. Например, у меня все delete-методы были помечены, как delete-методы, а все task-методы – как task-методы.

    7. Пару слов о результате

    Что ж, статья подходит к концу и я благодарю всех, кто до этого момента дочитал :)

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

    Благодарю Сашу Лавриновича: многие мысли здесь — его.

    Благодарю Вову Кривенко: эта статья появилась скорее благодаря ему, чем благодаря мне (я бы в жизни ее не написал, если бы он не напомнил мне раз 100).

    Благодарю наш проект: Birdview Projects.

    Комментарии: