Статьи

Тестирование мутации с мутантом

mutant_logo

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

Так кто же тестирует? Вопрос может показаться несерьезным, но ответ прост, Мутант знает!

Мутант является тестером мутаций . Чтобы оценить, что он делает, давайте представим, что выполняем свою работу вручную. Назначьте человека в своей команде диверсантом, его задача — выбрать кусок полностью протестированного кода и преднамеренно внести дефекты. Если это можно сделать, не вызывая тревогу, другими словами, не вызывая сбой теста, то тест пропускается. Затем первоначальный автор должен добавить тестовый пример для обнаружения саботажа.

Делайте это достаточно долго, и вам будет очень трудно найти код, который все еще можно свободно подделать. Сравните это с традиционными инструментами «покрытия линии». Означает ли 100% покрытие линии, что код не защищен от саботажа? Конечно, нет! Фактически, можно написать тесты, которые выполняют каждую строку кода без единого полезного утверждения о них. Веселье наш диверсант будет иметь!

Mutant автоматизирует этот процесс, он изменяет ваш код многими небольшими способами, создавая толпы мутантов. Если этот странный код вызывает сбой теста, мутант считается убитым. Только если в конце строки не останется ни одного мутанта, вы достигнете 100% охвата мутациями.

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

AssetPackager.new('foo/bar.html').write_to('baz') 

Результатом является файл baz.html и каталог baz_assets содержащий все таблицы стилей, сценарии и изображения. При обнаружении ссылки, как

 <link rel="stylesheet" src="http://example.com/style.css" /> 

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

 <link rel="stylesheet" src="baz_assets/48d6215903dff56238e52e8891380c8f.css" /> 

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

В качестве первого шага мы напишем метод, который может обрабатывать различные типы URI, которые мы хотим обработать. HTTP и HTTPS URI необходимо извлекать как таковые, относительные URI, а также URI, использующие схему file:// , будут искать в локальной файловой системе.

Это реализация:

 module AssetPackager class Processor attr_reader :cwd # @param cwd [Pathname] The working directory for resolving relative paths def initialize(cwd) @cwd = cwd end def retrieve_asset(uri) uri = URI(uri) case when %w[http https].include?(uri.scheme) || uri.scheme.nil? && uri.host Net::HTTP.get(uri) when uri.scheme.nil? || uri.scheme == 'file' File.read(cwd.join(uri.path)) end end end end 

И первая версия наших тестов. Для локальных URI мы укажем на файл фикстуры. Для удаленных URI мы Net::HTTP.get вызов Net::HTTP.get .

 describe AssetPackager::Processor do let(:cwd) { AssetPackager::ROOT } let(:processor) { AssetPackager::Processor.new(cwd) } describe '#retrieve_asset' do subject(:asset) { processor.retrieve_asset(uri) } shared_examples 'local files' do |uri| it 'should load the file from the local file system' do expect(processor.retrieve_asset(uri)).to eq 'section { color: blue; }' end end shared_examples 'remote URIs' do |uri| it 'should retrieve the file through Net::HTTP' do expect(Net::HTTP).to receive(:get).with(URI(uri)).and_return('abc') expect(processor.retrieve_asset(uri)).to eq 'abc' end end fixture_pathname = AssetPackager::ROOT.join 'spec/fixtures/section.css' include_examples 'local files', fixture_pathname.to_s include_examples 'local files', "file://#{fixture_pathname}" include_examples 'remote URIs', 'http://foo.bar/baz' include_examples 'remote URIs', 'https://foo.bar/baz' end end 

Согласно rpsec все хорошо и хорошо, и мы, безусловно, охватываем все строки retrieve_asset . Посмотрим, что скажет Мутант.

 mutant -I lib -r asset_packager --use rspec 'AssetPackager*' 

Это полный рот. Сначала скажите Mutant, как загружать наш тестируемый код, используя те же флаги -I , --include и -r , --require которые использует сам Ruby.

Затем укажите, какую «стратегию» использовать, чтобы «убить» мутантов. В настоящее время реализована только стратегия RSpec, что облегчает выбор. Наконец, передайте Мутанту один или несколько «паттернов». В этом случае скажите ему, чтобы он совершил свою магию в полном пространстве имен AssetPackager (обратите внимание на * ).

Мы также можем передать ему имя отдельного класса, модуля, метода класса ( Foo::Bar.the_method ) или метода экземпляра ( Foo::Bar#an_instance_method ).

Основываясь на паттерне , Мутант будет искать предметы, которые можно утащить в лабораторию, и перегруппировать их гены. В настоящее время Mutant может обрабатывать методы экземпляра и класса. Конструкции attr_accessor такие как attr_accessor или DSL уровня класса, не поддерживаются, хотя есть разговоры об обработке определенных DSL через плагины.

 AssetPackager::Processor#initialize ........ (08/08) 100% - 0.45s AssetPackager::Processor#retrieve_asset ...................F...........F................. (47/49) 95% - 3.49s evil:AssetPackager::Processor#retrieve_asset @@ -1,10 +1,10 @@ def retrieve_asset(uri) uri = URI(uri) case - when ["http", "https"].include?(uri.scheme) || (uri.scheme.nil? && uri.host) + when ["http", "https"].include?(uri.scheme) Net::HTTP.get(uri) when uri.scheme.nil? || (uri.scheme == "file") File.read(cwd.join(uri.path)) end end evil:AssetPackager::Processor#retrieve_asset @@ -1,10 +1,10 @@ def retrieve_asset(uri) uri = URI(uri) case when ["http", "https"].include?(uri.scheme) || (uri.scheme.nil? &&amp; uri.host) Net::HTTP.get(uri) when uri.scheme.nil? || (uri.scheme == "file") - File.read(cwd.join(uri.path)) + File.read(uri.path) end end (47/49) 95% - 3.49s Subjects: 2 Mutations: 57 Kills: 55 Alive: 2 Overhead: 29.31% Coverage: 96.49% Expected: 100.00% 

#initialize #retrieve_asset вывод Мутанта, он обнаружил две темы для работы: #initialize и #retrieve_asset . Для каждого выход выглядит очень похоже на любой старый тестовый бегун, с зелеными точками и красными буквами F, указывающими на успех или неудачу. В этом случае, однако, персонаж не соответствует ни одному успешному или неудачному тесту, а соответствует полному запуску набора тестов, выполненного с измененной версией субъекта.

Наш конструктор является достаточно простым методом, но Мутанту все же удалось найти 8 способов его изменить. Это включает в себя пропуск списка аргументов или назначение nil вместо значения. Однако ни одна из этих странных версий не прошла через нашу оборону. То же самое нельзя сказать о #retrieve_asset . Там было создано 49 мутантов, и в конце пробега двое остались в живых! Это означает, что в нашем коде поведение не определено нашими тестами, давайте исправим это, прежде чем мутанты вернутся, чтобы преследовать нас в производственных инцидентах.

Чтобы упростить жизнь, Rakefile вызов Mutant в Rakefile и скажите Mutant, что он потерпит неудачу, когда охват мутациями ниже 100%. Таким образом, мы можем запустить rake mutant из нашего CI, чтобы убедиться, что все остается полностью закрытым.

 desc 'Run mutation tests on the full AssetPackager namespace' task :mutant do result = Mutant::CLI.run(%w[-Ilib -rasset_packager --use rspec --score 100 AssetPackager*]) fail unless result == Mutant::CLI::EXIT_SUCCESS end 

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

 - when ["http", "https"].include?(uri.scheme) || (uri.scheme.nil? && uri.host) + when ["http", "https"].include?(uri.scheme) 

Здесь наш саботажный тестер мутаций удалил вторую половину условного выражения, которое должно распознавать URI вида //example.com/foo/bar . Это действительно был случай, который мы забыли охватить в наших тестах, но это легко исправить.

 include_examples 'remote URIs', '//foo.bar/baz' 

Второй дифференциал изначально оставляет нас в тупике.

 - File.read(cwd.join(uri.path)) + File.read(uri.path) 

Нам нужно иметь возможность разрешать как абсолютные ( /foo/bar/style.css ), так и относительные ( assets/stuff.js ) локальные файлы. Относительные пути мы ищем, начиная с «текущего рабочего каталога» или cwd , экземпляра Pathname . Для абсолютных путей join просто пройдет через абсолютный путь. Этот код должен охватывать оба случая, и мы рассматриваем оба в наших тестах, но, согласно мутанту, удаление вызова cwd.join не имеет значения. Тест для относительного пути не работает должным образом.

При ближайшем рассмотрении путь, используемый в нашем тесте в качестве «рабочего каталога», является тем же местом, откуда мы запускаем тесты. В мутированной версии File.read получает относительный путь и разрешает его для нас. Чтобы обеспечить правильное разрешение пути, нам нужно изменить тест для работы с другим каталогом.

 describe 'with a relative path' do let(:cwd) { super().join('spec/fixtures') } let(:uri) { fixture_pathname.relative_path_from(cwd).to_s } include_examples 'local files' end 

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

Тестирование на мутации не является новым, на самом деле оно существует с семидесятых годов, и экспериментальный гем под названием Heckle был первым, кто предложил тестирование мутаций в Ruby. Однако у Гекла были некоторые существенные недостатки. Он никогда не поддерживал весь возможный синтаксис Ruby, а последний выпуск выпущен в 2009 году, что делает новые версии Ruby полностью закрытыми.

Это привело к тому, что Маркус Ширп, часть команды ROM (ранее — DataMapper), начал работать над Mutant . Амбициозная попытка написать надежный, готовый к производству тестер мутаций. Mutant все еще до версии 1.0, но уже успешно используется в различных проектах с открытым исходным кодом и коммерческих проектах.

Это не маленький подвиг, чтобы получить такой инструмент, как Мутант. Проблема, замеченная в первые дни, состояла в том, что, изменяя синтаксическое дерево, Mutant мог генерировать код, который не является синтаксически допустимым Ruby, такой как следующее:

 def foo(a = 1, b, c = 2) # second optional argument deleted 

Эти проблемы, похоже, все уже решены. Под капотом Mutant работают превосходные гемы Parser и Unparser , которые были проверены на соответствие Rubyspec, базе кода Rails и многим другим.

Мутант в настоящее время доступен для МРТ и Рубинуса. Планируется поддержка JRuby, но она застопорилась из-за того, что JRuby не поддерживает системный вызов fork . Поддержка Ruby 2.1.0 нестабильна.

Если ваша версия Ruby поддерживает это, вам нужно включить Mutant в ваш рабочий процесс. Это может спасти жизнь вашего приложения.