Субботний хакатон или как написать фреймворк, который тестирует сам себя

У меня очень интересная работа, и одна из её особенностей это возможность постоянного развития в профессиональном плане. Бывают у нас субботние посиделки, когда люди делятся на пары, причем не кто с кем привык, а в произвольном порядке, и занимаются парным программированием. Дома такого добиться практически невозможно. Скорость прокачки в эти моменты зашкаливает, за 4-5 часов такого веселья можно узнать больше чем за месяц.

В этот раз нам досталось писать фреймворк для тестирования, который бы тестировал сам себя. Когда слышишь такое задание, некоторые шаблоны в голове, как минимум, начинают ломаться. Далее расскажу, последовательно, что и как приходило в голову и воплощалось в код. На все провсе ушло 3 часа, код не претендует на невероятную красоту и лаконичность, но материала нового было предостаточно. Для тех кому не терпится — сразу ссылка на репозиторий в git. (SelfTestingFramework)

Родоначальник этого задания находится здесь и был написан за 1,5 часа в самолете.

Формат
Тут все просто, сразу понятно, что это гем. Кстати, уже начинал писать тут, как сделать свой гем с помощью бандлера, но так и не продолжил серию. Начало такое же

bundle gem self_testing_framework

Структура папок и файлов сформирована, дальше по накатанной, инициализируем репозиторий и пушим на github.

С чего начать?

Наверное самый больной вопрос в любой сфере деятельности. В голове, на удивление, сразу появилось несколько идей: нужен бинарник для запуска как в rpsec или запускать через turn как в minitest. Последний, к слову, можно и просто запускать, передав имя файла в команду ruby. На этом варианте в итоге мы и остановились.

А потом неожиданно вспомнили, что мы, вообще-то, работаем по TDD и пишем ни много ни мало фреймворк для тестирования, поэтому правильнее начать с написания тестов!

Поехали!

Создаем папки test/self_testing_framework/, внутри первый и единственный файл с тестами testcase_test.rb. Также положим test_helper.rb в папку test/ в лучших традициях тестирования. Теперь немного покодим:

test/self_testing_framework/testcase_test.rb:

def test_assert_true
  assert true
end

В репозитории можно посмотреть полный код всего, что получилось, тут буду просто приводить мысли и некоторые отрывки. На данный момент у нас нет ни класса TestCase, ни метода assert, ни «запускатора» тестов. Ну тогда пойдем и создадим lib/self_testing_framework/test_case.rb и в нем опишем метод assert и run. Метод run у нас будет на уровне класса, а assert сделаем доступным в инстанцированном классе. Напомню, что все наши тесты (целый 1) унаследованы от TestCase. В последствии мы добавили before и after, сделав их пустыми, дабы наследники могли их спокойно переопределять.

lib/self_testing_framework/test_case.rb:

def assert(arg, fail_message = nil)
  fail_message ||= "Assertion Fails"
  raise AssertFalse.new(fail_message) unless arg
end

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

lib/self_testing_framework.rb:

class AssertFalse < RuntimeError; end
autoload "TestCase", "self_testing_framework/test_case"

Добавили непосредственно в главном файле, откуда виден весь интерфейс гема. Почему унаследовано от RuntimeError, и вообще как правильно работать с исключениями можно узнать из статьи Иерархия ошибок в Ruby из великолепного сборника статей от Kaize. Ну и раз уж зашли в этот головной файл, добавим наш TestCase в пути автолоада.

Сказ о великом «запускаторе»

Но как-то надоело немножко просто так кодить и файлики создавать, хочется уже что-нибудь запустить и посмотреть, что оно вообще живо. Тут встает вопрос: а как? Чуть выше уже описывал варианты с бинарной утилитой для запуска, а также варианты с turn и просто с Ruby. Будем запускать вот так:

ruby -Itest test/self_testing_framework/testcase_test.rb

ОК, добавили папку test в load_path натравили скрипт на файл testcase_test.rb, а дальше то что? Там ничего не запускается, просто класс и все. Пошел research с поиском всевозможных вариантов, в итоге пришли к функции at_exit. Функция, принимающая на вход блок, который затем конвертируется в Proc объект и исполняется, вызывается она после окончания работы программы. Т. е. сразу, после того, как наш файл testcase_test.rb с классом, тестирующим функциональность фреймворка, будет загружен и исполнен, вызовется функция at_exit, в которой мы и будем запускать наши тесты.

lib/self_testing_framework.rb"

at_exit do
  runner = SelfTestingFramework::Runner.new(SelfTestingFramework::TestCase.descendants)
  runner.execute
end

Тут стоит обратить внимание на метод descendants, вызванный у класса TestCase и на инициализацию Runner. descendants выдаст нам список всех наследников конкретного класса. Из коробки в Ruby нет такого метода, а появляется он в ActiveSupport, подробнее про это можно прочитать тут.
Чтобы этот метод появился в нашем прекрасном фреймворке необходимо сделать 2 простых вещи:

  1. в gemspec добавить зависимость
    gem.add_runtime_dependency «active_support»
  2. сделать require в основном модуле
    require «active_support/core_ext/class/subclasses»

Но откуда у нас появятся там потомки? При запуске конкретного теста:

ruby -Itest test/self_testing_framework/testcase_test.rb

происходит добавление в load_path папки test/ и класс, описанный в файле testcase_test.rb загружается в память, а поскольку наследуется от TestCase, то он попадет в массив descendants. Т. о. в конструктор Runner'а попадут все классы потомки TestCase с тестами, которые на момент окончания работы программы будут описаны. Идем далее и напишем наш «великий» запускатор.

Что вообще нужно? Во-первых, мы закинули в конструктор все классы с тестами и вызвали некий метод execute, поэтому, видимо, у нас будет конструктор, который принимает объект classes и складывает в инстанс переменную.

lib/self_testing_framework/runner.rb:

def initialize(classes)
  @classes = classes
end

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

lib/self_testing_framework/runner.rb:

def execute
  reporter = Reporter.new
  test_results = TestResult.new
  @classes.each do |klass|
	klass.new.methods.grep(/test_/).each do |test_method|
	  begin
		klass.run test_method
		test_results.passed(klass.name, test_method)
	  rescue SelfTestingFramework::AssertFalse => e
		test_results.errored(klass.name, test_method, e.message)
	  end
	  reporter.report test_results.last_test
	end
  end
  reporter.total_report test_results
end

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

ruby -Itest test/self_testing_framework/testcase_test.rb .F

SelfTestingFramework::TestCaseTest#test_assert_false
  ERROR: Assertion Fails

——————————————————————
Tests: 2, Passed: 1, Errors: 1

И-и-и-ха! Вот собственно и все. У нас получился маленький простенький фреймворк для тестирования. Написан он был ровно за 3 часа мной и @mstolbov(профиль на github) в рамках субботнего хакатона.

P.S. Можете конечно покидаться чем-нибудь за кривость, но по мне это было крайне познавательно и весело.