четверг, 24 февраля 2011 г.

Регрессионные тесты

Продолжаю предыдущую статью.

Всё та же несложная консольная программа, управляющая этим блогом.

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

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

Юниттесты - не универсальное решение

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

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

Здесь присутствуют несколько проблем:

  • Юниттесты должны исполнятся в виртуальном окружении.

    Т.е. не могут обращатся к сети, например. Работа с файловой системой допускается, но не очень желательна.

    Мое приложение простое: у него нет базы данных (её заменяет yaml файл), системы распределенных сообщений и других занятных образцов инженерной мысли.

  • Юниттест всегда работает в одиночку.

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

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

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

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

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

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

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

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

Ручное тестирование

Оно великолепно! Нет, кроме шуток!

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

  • Это - долго. Я печатаю довольно быстро - но не настолько же!
  • Это - утомительно. При повторении одних и тех же действий очень быстро устаю.
  • Самый минимальный прогон - два десятка команд. Более или менее полный - около сотни. И это - очень простая система!
  • Наконец, я в такие моменты ощущаю себя обезьяной. Определение "мартышкин труд" очень хорошо подходит к ручному тестированию.

В результате:

  • Ручное тестирование никогда не выполняется в полном объеме - задачка явно превышает человеческие возможности.
  • В силу предельной однообразности действий внимание притупляется, что тоже отрицательно сказывается на качестве.
  • Даже при скурпулёзном следовании бумажному тестовому плану (кто их пишет?) - процесс занимает непозволительно много времени.

Коллега Kogrom подсказывает:

Регрессионные тесты не ограничиваются только юнит-тестами.Есть ещё и регрессионные функциональные тесты, которые требуют больше ресурсов и времени, но зато более близки к ручному тестированию.

Постановка задачи: требуется регрессионный функциональный тест

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

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

Решение

Технически задачка выглядит простой. Требуется запускать программу через subprocess.Popen, смотреть на код завершения и сравнивать перехваченные stdout и stderr с ожидаемыми.

Первый же вариант делал всё, что требуется.

Когда я написал код проверки пяти вызовов - устал. Copy-Paste очень сильно помогает в такой работе, но текста получается очень много.

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

Автоматизация - налицо, но назвать удобной эту систему можно, только сильно покривив душой.

Второй вариант, который я вам показываю, выглядит гораздо симпатичней.

Начало работы:

def run():
    folder = os.path.dirname(os.path.abspath(sys.argv[0]))
    regr_folder = os.path.join(folder, 'regr-data')
    blog_cmd = 'blog'

    exe = Executor(blog_cmd, regr_folder)
    exe.rmtree()
    exe.mkdir('.')

    project_dir = exe.full_name('sample_blog')

Теперь кусок теста. Пытаемся создать проект, проверяя успешность попытки. В случае любых затруднений - выбрасывается исключение:

    CREATE_PROJECT = Q("INFO Create project {0!q}")
    ALREADY_IN_PROJECT = Q("ERROR Already a project: {0!q}")

    print 'init project'
    out = exe.go('init sample_blog')
    CREATE_PROJECT(project_dir) == out

    print 'init the same project'
    out = exe.go('init sample_blog', retcode=255)
    ALREADY_IN_PROJECT(project_dir) == out

Простые проверки неплохо работают, перегруженный оператор Q.__eq__ выбрасывает исключение, если результат сравнения - ложь.

Добавляем файл статьи и генерируем для него html:

    print "****** SIMPLE HTML *******"

    ADD = Q("INFO Add {name!q} -> {file!q}")

    HTML = Q("INFO Generate html for {name!q}")
    HTML_WARNING_NO_TEMPLATE = HTML + '\n' + Q("""
        WARNING User settings has no template specified.
        Use markdown output as html.
        """)

    TXT = 'Text of sample article'
    INNER_HTML = '<p>' + TXT + '</p>'

    print "add post"
    with exe.cd('sample_blog'):
        exe.write('article.rst', TXT)
        rst_fname = exe.full_name('article.rst')
        out = exe.go('add article.rst')
    ADD(name='article', file='article.rst') == out

    print "generate html without template"
    with exe.cd('sample_blog'):
        out = exe.go('html article.rst')
        Q(INNER_HTML) == exe.read('article.html')
    HTML_WARNING_NO_TEMPLATE(name='article', file=rst_fname) == out

Для простых случаев всё отлично. Но одним сравнением дело обходится не всегда. Нужны регулярные выражения.

Получаем разнообразную информацию о добавленном файле:

    POST_SHOW = Q("""\
        INFO Post {changed}{name!q}
            title: {title}
            link: {link}
            slug: {slug}
            labels: {labels}
            postid: {postid}
            localstamp: (?P<localstamp>.+?)$
    """)

    print 'show post article'
    with exe.cd('sample_blog'):
        out = exe.go('post article')
    ret = POST_SHOW(name='article',
              title='',
              changed='[*]', # screen * regexp spec symbol
              link='',
              slug='article',
              labels='a',
              postid='').match(out)
    localstamp = ret['localstamp']

Вместо проверки на == выполняем поиск по регулярному выражению .match. Найденные регуляркой группы можно запомнить для дальнейшего использования, проверить на соответствие и так далее.

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

  • Executor (exe) - работает как пользовательская консоль. Создаёт папки и файлы, запускает команды.

  • Q, что значит query - сравнивает свой шаблон с аргументом (__eq__), умеет делать поиск по регулярному выражению (.match), работать с результатом успешного поиска по регулярке (она же regexp).

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

    Именно потому я использую нечто вроде CREATE_PROJECT(project_dir) == out для проверок.

Executor

Тривиальная обертка вокруг нескольких функций os и subprocess.

 class Executor(object):
     def __init__(self, blog_cmd, regr_folder):
         self.blog_cmd = blog_cmd
         self.regr_folder = os.path.abspath(regr_folder)
         self.cwd = [regr_folder]
         self.out = None
         self.retcode = None

     @contextlib.contextmanager
     def cd(self, arg):
         self.cwd.append(os.path.abspath(os.path.join(self.cwd[-1], arg)))
         yield
         self.cwd.pop()

     def get_cwd(self):
         return self.cwd[-1]

     def mkdir(self, arg):
         os.makedirs(os.path.join(self.cwd[-1], arg))

     def rmtree(self):
         folder = self.cwd[-1]
         if os.path.exists(folder):
             shutil.rmtree(folder)

     def write(self, fname, text):
         with open(self.full_name(fname), 'wb') as f:
             f.write(text)

     def read(self, fname):
         with open(self.full_name(fname), 'rb') as f:
             return f.read()

     def full_name(self, fname):
         return os.path.join(self.cwd[-1], fname)

     def go(self, args, retcode=0):
         """run blog with args, return stdout merged with stderr"""
         args_list = shlex.split(args)
         proc = subprocess.Popen([self.blog_cmd] + args_list,
                                 cwd=self.get_cwd(),
                                 stdout=subprocess.PIPE,
                                 stderr=subprocess.STDOUT)
         self.out, err = proc.communicate()
         self.retcode = proc.returncode
         if retcode is not None:
             if retcode != self.retcode:
                 raise RuntimeError("RETCODE %s, EXPECTED %s\n%s" %
                                    (self.retcode,
                                     retcode,
                                     self.out)
                                    )
         return self.out

Откровенно говоря, всё просто как пять копеек.

cd, mkdir, read, write говорят сами за себя.

go запускает тестируемую программу с нужными аргументами, проверяет ее код завершения и возвращает объединенный вывод стандартных потоков.

shlex.split используется для правильного разбиения полученной командной строки на составные части.

Query

Проверяет шаблон на соответствие строке. Проверки бывают двух типов: на равенство и на регулярное выражение. Если проверка не проходит - выбрасывается исключение.

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

Для большего удобства Q наследуется от Template (смотрите "Форматирование строк") и использует NamespaceFormatter, возвращающий None для каждого неизвестного параметра.

 class Q(Template):
     """Query, used for result check"""
     NS = NamespaceFormatter(collections.defaultdict(lambda: None))

     def format(__self, *__args, **__kwargs):
         return Q(__self.NS.vformat(__self, __args, __kwargs))

     def eq(self, other, strip=True):
         if strip:
             other = other.strip()
         if str(self) != str(other):
             raise RuntimeError('Not Equals\n%s\n----------- != ------------\n%s'
                                % (self, other))

     def __eq__(self, other):
         return self.eq(other)

     def __ne__(self, other):
         return not self.eq(other)

     def match(self, test, strip=True):
         if strip:
             test = test.strip()
         match = re.match(self, test, re.M)
         if match is None:
             raise RuntimeError("%s\n doesn't match pattern\n%s" % (test, self))
         return Match(match)

     def ifind(self, test, strip=True):
         if strip:
             test = test.strip()
         return (Match(m) for m in re.finditer(self, test, re.M))

 class Match(object):
     def __init__(self, match):
         self.match = match

     def check(self, **kwargs):
         groups = self.match.groupdict()
         for name, val in kwargs:
             group = groups(name)
             Q(val) == group

     def __getitem__(self, key):
         return self.match.groupdict()[key]

Примеры наглядно демонстрируют применение класса Q, что-то дополнительно объяснять вроде бы и не нужно.

Результат работы

Вот и всё.

У меня есть очень длинный сценарий регрессионного теста:

  • Изделие работает, и делает своё дело очень хорошо.
  • Добавлять новые тесты легко и приятно.
  • Что не менее важно - сценарий легко читать.
  • Запуск регрессионного теста позволяет с большой степенью уверенности говорить о том, что работа программы не нарушена.
  • На создание инфраструктуры я потратил от силы час. Конечно, написание самих тестов (и, главное, устранение выявившихся в процессе ошибок в программе) заняло куда больше времени - но оно того стоило.
  • Теперь я запускаю эти тесты перед каждым commit, вместе с юниттестами, ожидаю завершения - и таки делаю фиксацию с чистым сердцем или, что чаще бывает, продолжаю править баги.

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

Заключение

Возникают несколько вопросов.

Почему бы не отказаться от юнит-тестирования и делать только регрессионные тесты?

Ответ сложный.

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

  • У юниттестов есть своя ниша, в которой они хороши. Помните, что я писал о библиотеках в начале этой статьи?

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

    И это может быть очень медленно.

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

Если бы регрессионные тесты выполнялись мгновенно - большая часть вопросов бы отпала. К сожалению, это не так.

Я рассмотрел пример тестов для консольного приложения. Что делать в других случаях?

Да то же самое!

Для Веба есть Mechanize, twill, Selenium. Можно виртуально ходить по ссылкам, не запуская настоящего браузера. Предоставьте эту честь роботу!

Для GUI тоже есть способ. Проще всего воспользоваться средсвами используемой вами GUI библиотеки. Запустите приложение в том же процессе и в idle цикле эмулируйте действия пользователя. Ввводите текст и посылайте сообщения о том, что нужная кнопка нажата. Заодно библиотека GUI даст возможность узнать содержимое текстовых полей и так далее. Это удобно!

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