Статьи

Golden Master Testing: сложный вид рефакторинга

Робот из металлических частей на темном фоне шероховатый и металлический каркас

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

Рефакторинг шаблонов может быть затруднен, когда их тестовое покрытие мрачно, что в большинстве случаев. И это правильно! Зачем беспокоиться о тестировании чего-то, что будет регулярно меняться и не должно содержать никакой логики?

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

Пример из реального мира

Треки — это инструмент управления временем, основанный на книге Дэвида Аллена 2002 года « Как все сделать» .

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

Это дает нам возможность изучить технику Golden Master с использованием реального кода, а не крошечного синтетического примера, который заставляет читателя задуматься, как перенести урок в реальный производственный код.

Изучение треков

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

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

StatsController иллюстрирует это. Файл имеет длину более 450 строк. Он содержит 37 методов, только 17 из которых являются действиями контроллера.

Действия бывают двух видов:

  1. Ванильные HTML-страницы
  2. текстовые коллекции пар key=value

Пары key=value используются для отображения диаграмм в ответах HTML. Шаблоны для них выполняют некоторые вычисления, устанавливают некоторые локальные переменные и отображают целый ряд переменных экземпляра, которые были вычислены в контроллере.

Также они дублируются.

То есть они не идеально дублированы, это было бы слишком просто. Они похожи, но не идентичны .

В этом упражнении мы возьмем два очень actions_visible_running_time_data.html.erb шаблона — actions_visible_running_time_data.html.erb и actions_running_time_data.html.erb — и actions_running_time_data.html.erb все различия на один уровень вверх в контроллер. Наконец, мы удалим одно из представлений.

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

Ну да, это так.

Кроме того, он содержит даже больше дублирования, чем шаблоны.

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

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

Стена на спине

И так это началось…

Ой, подожди. Это не так. Мы должны убедиться, что у нас есть хорошие тесты.

Выполнение rake test занимает около 20 минут. Это слишком медленно, чтобы быть полезным для рефакторинга. Мы можем сузить это до только тестов, которые охватывают конечные точки данных диаграммы.

Самый простой способ выяснить, к каким тестам это относится — raise 'hell' из шаблонов erb мы ищем для рефакторинга:

 <%- raise "hell" -%> 

Примерно через 20 минут мы можем подтвердить, что три теста взорвались. Все три теста находятся в тестах StatsController , которые можно запускать изолированно:

 $ ruby -Itest test/controllers/stats_controller_test.rb 

Это занимает 5:17 секунд. Это не быстро, но подойдет.

Насколько хороши тесты? Давайте посмотрим, на что они жалуются, если мы удалим все содержимое двух файлов erb .

 24 runs, 126 assertions, 0 failures, 0 errors, 0 skips 

Нам понадобятся лучшие тесты.

Блаженное невежество

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

Было бы менее утомительно и подвержено ошибкам просто предполагать, что то, что происходит сейчас, это именно то, что должно происходить. Если бы мы могли просто зафиксировать полный вывод key=value для конечной точки диаграммы, ее можно скопировать / вставить в тест и использовать в качестве подтверждения.

Нам даже не придется понимать, что происходит!

Этот тип теста грязный, хрупкий и очень, очень удобный. Майкл Фезерс называет это тестом характеристик в своей книге « Эффективная работа с устаревшим кодом» .

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

Характеризуя взгляды

Тест должен:

  1. Захватите весь ответ HTML, который генерируется, когда мы вызываем действие контроллера.
  2. Сравните это с ответом, который мы ранее определили как «хороший» (т.е. Золотой Мастер ).
  3. Если они одинаковы, тест проходит. В противном случае это не удастся.

Хотя мы могли бы написать тест, утверждать, что ответом является «LOLUNICORNS», наблюдать за ним, а затем копировать / вставлять весь ответ из сообщения об ошибке, мы будем использовать шаблон проверочных тестов Llewellyn Falco, чтобы полуавтоматизировать процесс ,

Это выглядит так:

  1. Всегда фиксируйте ответ, как received.html .
  2. Сравните это с approved.html .
  3. Ошибка, если received отличается от approved .

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

Это дает нам способ обработать самое первое правило Golden Master: запустите тест, увидите, что он провалился (потому что approved , пусто), переместите полученный файл для подтверждения.

Вуаля , тест сейчас проходит.

Настроить

Нам нужно сделать немного настройки. Во-первых, приятно иметь возможность запускать эти тесты полностью независимо от любого другого теста, поэтому создайте для них новый файл в test/functional/lockdown_test.rb .

 require File.expand_path(File.dirname(__FILE__) + '/../test_helper') class LockdownTest < ActionController::TestCase # tell Rails which controller we're using tests StatsController def test_output_does_not_change # do some setup # call the endpoint # write the response to a file # compare the two files and make an assertion end end 

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

 def with_golden_master(key) FileUtils.mkdir_p('.lockdown') FileUtils.touch(".lockdown/#{key}-approved.txt") login_as(:admin_user) Timecop.freeze(Time.utc(2014, 6, 5, 4, 3, 2, 1)) do yield end received = @response.body File.open(".lockdown/#{key}-received.txt", 'w') do |f| f.puts received end approved = File.read(".lockdown/#{key}-approved.txt") unless approved == received assert false, approval_message(key) end end def approval_message(key) <<-MSG FAIL: The output changed. Eyeball the differences with: diff .lockdown/#{key}-received.txt .lockdown/#{key}-approved.txt If you like the #{key}-received.txt output, then cp .lockdown/#{key}-received.txt .lockdown/#{key}-approved.txt MSG end 

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

Затем фактически вызовите действия контроллера:

 def test_visible_running_chart_stays_the_same with_golden_master('vrt') do get :actions_visible_running_time_data end end def test_all_running_chart_stays_the_same with_golden_master('art') do get :actions_running_time_data end end 

Запустите тесты:

 $ ruby -Itest test/functional/lockdown_test.rb 

Скопируйте received файл поверх approved файла и повторите тест, чтобы убедиться, что он пройден.

Насколько хороши эти тесты?

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

Рефакторинг (наконец-то!)

Мы сделаем это по одному. Вот представление времени выполнения действий :

 <%- url_labels = Array.new(@count){ |i| url_for(:controller => 'stats', :action => 'show_selected_actions_from_chart', :index => i, :id=> "art") } url_labels[@count]=url_for(:controller => 'stats', :action => 'show_selected_actions_from_chart', :index => @count, :id=> "art_end") time_labels = Array.new(@count){ |i| "#{i}-#{i+1}" } time_labels[0] = "< 1" time_labels[@count] = "> #{@count}" -%> &title=<%= t('stats.running_time_all') %>,{font-size:16},& &y_legend=<%= t('stats.running_time_all_legend.actions') %>,10,0x736AFF& &y2_legend=<%= t('stats.running_time_all_legend.percentage') %>,10,0xFF0000& &x_legend=<%= t('stats.running_time_all_legend.running_time') %>,11,0x736AFF& &y_ticks=5,10,5& &filled_bar=50,0x9933CC,0x8010A0& &values=<%= @actions_running_time_array.join(",") -%>& &links=<%= url_labels.join(",") %>& &line_2=2,0xFF0000& &values_2=<%= @cumulative_percent_done.join(",") %>& &x_labels=<%= time_labels.join(",") %> & &y_min=0& <% # add one to @max for people who have no actions completed yet. # OpenFlashChart cannot handle y_max=0 -%> &y_max=<%=1+@max_actions+@max_actions/10-%>& &x_label_style=9,,2,2& &show_y2=true& &y2_lines=2& &y2_min=0& &y2_max=100& 

Во-первых, поскольку значения должны быть разделены между представлением и контроллером, включите url_labels в @url_labels и посмотрите, как проходят тесты. Сделайте то же самое для time_labels .

Когда мы перемещаем @url_labels в контроллер, тест не @url_labels .

 # approved /stats/show_selected_actions_from_chart/avrt?index=0 # received http://test.host/stats/show_selected_actions_from_chart/avrt?index=0 

Контроллер добавил http://test.host к сгенерированным URL. Мы можем заставить его возвращать только путь, передав ему параметр :only_path => true :

 url_for(:controller => 'stats', :action => 'show_selected_actions_from_chart', :index => i, :id=> "avrt", :only_path => true) 

Перемещение time_labels в контроллер работает без time_labels .

Следующие изменения еще проще. Для каждого интерполированного значения убедитесь, что контроллер устанавливает переменную экземпляра для окончательного значения:

 # Normalize all the variable names @title = t('stats.running_time_all') @y_legend = t('stats.running_time_legend.actions') @y2_legend = t('stats.running_time_legend.percentage') @x_legend = t('stats.running_time_legend.weeks') @values = @actions_running_time_array.join(",") @links = @url_labels.join(",") @values_2 = @cumulative_percent_done.join(",") @x_labels = @time_labels.join(",") # add one to @max for people who have no actions completed yet. # OpenFlashChart cannot handle y_max=0 @y_max = 1+@max_actions+@max_actions/10 

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

 render :actions_running_time_data, :layout => false 

Удалите неиспользованный шаблон и отпразднуйте.

Тогда возьмите лопату, немного пахнущих солей и приготовьтесь атаковать этого контролера. Это, однако, тема для другого поста (скоро, не волнуйтесь.)