Субботний хакатон или как написать фреймворк, который тестирует сам себя
У меня очень интересная работа, и одна из её особенностей это возможность постоянного развития в профессиональном плане. Бывают у нас субботние посиделки, когда люди делятся на пары, причем не кто с кем привык, а в произвольном порядке, и занимаются парным программированием. Дома такого добиться практически невозможно. Скорость прокачки в эти моменты зашкаливает, за 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 простых вещи:
- в gemspec добавить зависимость
gem.add_runtime_dependency «active_support»
- сделать
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. Можете конечно покидаться чем-нибудь за кривость, но по мне это было крайне познавательно и весело.