Тестирование с помощью SpecFlow прочно вошло в мою жизнь, в список необходимых технологий для «хорошего проекта». Более того, несмотря на ориентированность SpecFlow на behaviour тесты, я пришел к мысли, что и integration и даже unit тесты могут получить преимущества этого подхода. Конечно, в написании таких тестов уже не будут участвовать люди из BA и QA, а только сами разработчики. Разумеется, что для небольших тестов это привносит немалый оверхэд. Но насколько же приятнее читать человеческое описание теста, нежели голый код.


В качестве примера приведу тест, переработанный с обычного вида тестов в MSTest на тест в SpecFlow
исходный тест
        [TestMethod]
        public void CreatePluralName_SucceedsOnSamples()
        {
            // setup
            var target = new NameCreator();
            var pluralSamples = new Dictionary<string, string>
              {
                  { "ballista", "ballistae" },
                  { "class", "classes"},
                  { "box", "boxes" },
                  { "byte", "bytes" },
                  { "bolt", "bolts" },
                  { "fish", "fishes" },
                  { "guy", "guys" },
                  { "ply", "plies" }
              };  

            foreach (var sample in pluralSamples)
            {
                // act
                var result = target.CreatePluralName(sample.Key);

                // verify
                Assert.AreEqual(sample.Value, result);
            }
        }


тест в SpecFlow
Feature: PluralNameCreation
	In order to assign names to Collection type of Navigation Properties	
	I want to convert a singular name to a plural name

@PluralName
Scenario Outline: Create a plural name
	Given I have a 'Name' defined as '<name>'
	When I convert 'Name' to plural 'Result'
	Then 'Result' should be equal to '<result>'

Examples:
| name		| result	|
| ballista	| ballistae	|
| class		| classes	|
| box		| boxes		|
| byte		| bytes		|
| bolt		| bolts		|
| fish		| fishes	|
| guy		| guys		|
| ply		| plies		|



Классический подход


Пример приведенный выше не относится к тому альтернативному подходу, о котором я хочу рассказать в этой заметке, относится он к классическому. В этом самом классическом подходе «входные» данные для теста специально создаются в самом тесте. Эта фраза уже может служить подсказкой, в чём же состоит «альтернативность».
Еще один, чуть более сложный пример классического создания данных для теста, с которым потом можно будет сравнить альтернативу:
Given that I have a policy created in year 2006
  And policy has a coverage with type 'Dependent' and over 70 people covered

Таким строчкам, которые я далее буду называть шагами, соответствуют следующие строчки с кодом:
policy = new Policy { Created = new DateTime(2006, 1, 2), Coverages = new List<Coverage>() };
policy.Coverages.Add(new Coverage { Type = coverageType, HeadCount = headCount + 1 });

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

Альтернатива


Сразу хочу упомянуть, что этот подход был найден и применен не мной, а моим коллегой. На хабре и на гитхабе он зарегистрирован как gerichhome. Я же взялся это описать и опубликовать. Ну и, может быть, по традиции хабра, появится комментарий, более полезный чем статья, окажется, что не зря писал и тратил время.
В некоторых случаях, как в случае нашего проекта, для отображения страницы портала нужно немалое количество данных. А для тестирования некоторой конкретной фичи нужна лишь небольшая порция. И, для того, чтобы вся остальная странице не падала от отсутствия данных, придется написать немалое количество кода. И, что еще хуже, скорее всего придется написать какое то количество SpecFlow шагов. Таким образом получится, что хочешь-не хочешь, а тестировать приходится как бы всю страницу, а не необходимую в в данный момент ее часть.
И для того, чтобы обойти это, данные можно не создавать, а искать среди имеющихся. Данные могут находиться либо в тестовой базе данных, либо в мок-файлах, собранных и сериализованных на срезе некоторого API. Разумеется, этот подход больше подходит под случай, когда мы уже имеем много функциональности, по крайней мере ту часть, которая позволит этими данными манипулировать. Чтобы, если для теста нужного набора данных нет, можно было сначала пройтись по сценарию теста «руками», сделать слепок данных, и потом уже автоматизировать. Удобно этот подход использовать, когда есть желание и/или необходимость покрыть существующий код тестами, потом рефакторить/переписывать/расширять и не бояться, что функциональность поломается.

Как и прежде, для теста нужен объект Policy, созданный в 2006 году и имеющий Coverage с типом Dependent и покрываемым количеством людей больше семидесяти. Любой из полисов, хранящихся в базе данных, содержит множество других сущностей, в нашем проекте модель занимала более 20 таблиц. В рамках демонстрации я не стал использовать полную модель, моя упрощенная модель включает лишь три сущности.
Для того, чтобы найти нужный полис, нужно каким то образом определить источник полисов в виде IEnumerable и поменять определения шагов на следующие:
policies = policies.Where(x => x.Created.Year == year);
policies = policies.Where(x => x.Coverages.Any(y => y.Type == coverageType && y.HeadCount > headCount));

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

Алгоритм поиска


Итак, полис найден, мы открыли портал, проверили, что в нужной секции на UI отображено имя полиса, теперь нужно проверить, что другая секция отображает нужное нам количество депендентов. И тут возникает вопрос, а как в этом шаге узнать, какой именно Coverage позволил нашему полису пройти по этому условию? Какой из, например, пяти брать, чтобы сравнивать его HeadCount с числом на UI?
Для этого пришлось бы повтороить это условие в шаге «Then», а дублирование кода — это очевидно плохо. Кроме того, дублировать условия придется еще и в шагах SpecFlow, что совсем неприемлемо.
Лирическое отступление — у нас на одном из проектов уже было нечто похожее, был sql-запрос(для простоты пусть он будет пришедшим из конфигов), который ищет людей, возвращая список SSN. У этих людей, по сценарию, должен был быть ребенок достигший 18 лет. И бизнес-люди долго обсуждали, чуть ли не ругаясь, не могли понять, почему мы не можем декомпозировать этот запрос, чтобы для конкретного человека найти тех детей, которые подошли под условие. Не могли понять, зачем нам нужен второй запрос. А так как есть представление светлого будущего, в котором именно BA будут писать текст теста, то объяснять зачем нужно дублирование в шагах значительно сложнее, чем это дублирование устранить, и это первая задача, которая решается алгоритмом поиска.
Кроме того, при обычном поиске, приведенном в предыдущем пункте, второй шаг нельзя разбить на два шага SpecFlow. Это вторая решаемая алгоритмом задача. Речь далее будет идти именно об этом алгоритме и его реализации.
Алгоритм схематично представлен на следующей картинке:

Поиск работает достаточно просто. Исходная коллекция корневых сущностей, в данном случае полисов, представляется в виде IEnumerable<IResolutionContext<Policy>>. В него попадают лишь те полисы, которые удовлетворяют собственным условиям, и имеют удовлетворяющие условиям коллекции дочерних сущностей. Для того, чтобы эти дочерние сущности обозначить, необходимо зарегистрировать т.н. provider с лямбдой типа Func<T1, IEnumerable<T2>>.
Дочерние коллекции могут также иметь условия, самое простое из них это Exists. С таким условием коллекция будет считаться валидной, если в ней есть хотя бы один элемент, удовлетворяющий собственным условиям.
Собственные условия, они же фильтры, представляют из себя лямбды типа Func<T1, bool> в простом случае.
На картинке представлен случай, где найдены два полиса подходящих под все условия, у первого полиса есть некоторое количество объектов Coverage, из которых подходят под условия 4, и также некоторое количество объектов Tax, из которых подошло два. Так же для второго полиса нашлось 3 подходящих Coverage и 4 Tax.
И, хотя это кажется достаточно очевидным, стоит обозначить, что кроме полисов, не подошедших под собственные условия, в список не попали также те полисы у которых не нашлось подходящих объектов Coverage или не нашлось подходящих объектов Tax.
Красными же стрелками обозначено дерево взаимодействий конкретных элементов. У конкретного элемента Coverage, есть лишь одна связь наверх, он «знает» лишь о породившем его конкретном элементе Policy, и «не знает» ни о коллекции Coverages в которой он сам находится, ни, тем более, о коллекции Policies, для элемента не может существовать коллекции родительских элементов, даже если физически они независимы, а зависимы лишь логическими связями, определенными регистрацией. Пример «расхождения» физической и логической связи я покажу ниже.

Отсюда и далее идут описание и примеры использования реализации, которую я разработал и опубликовал в NuGet(ссылка в конце). Коллега же создал свою реализацию алгоритма поиска, которая отличается в основном тем, что вытягивает сеть регистраций в цепочку, то есть дерево с единственной ветвью. Его реализация имеет некоторые свои фичи, которых нет у меня и свои недостатки. Еще его реализация имеет некоторые лишние зависимости, которые слегка мешают вынести решение в отдельный модуль. Кроме этого, толи по политическим причинам толи по личным, он не особо стремится свои решения публиковать, что несколько затруднило бы использование его в других проектах. У меня же было свободное время и желание воплотить какой-нибудь интересный алгоритм. С чем я и подошел к написанию этой статьи.

Регистрация сущностей и фильтров


Для достижения максимальной гранулярности шаги разбиваются следующим образом.
Given policy A is taken from policiesSource   #1
  And policy A is created in year 2007        #2
  And for policy A exists a coverage A        #3
  And coverage A has type 'Dependent'         #4
  And coverage A has over 70 people covered   #5

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

Для того, чтобы зарегистрировать такие источники и фильтры, используется следующий синтаксис
context.Register().Provide<Policy>(key, x => PoliciesSource.Policies); #1
context.For<Entities.Policy>(key).Filter(policy => policy.Created.Year == year); #2
context.Register()                                         #3
    .DependsOn<Policy>(policyKey)
    .Provide<Coverage>(coverageKey, policy => policy.Coverages)
    .Exists();
context.For<Coverage>(key).Filter(coverage => coverage.Type == type);  #4
context.For<Coverage>(key).Filter(coverage => coverage.HeadCount > headCount);  #5

Используемый тут context — это (TestingContext context), инжектированный в классы, содержащие определения. Все строчки в теcте SpecFlow помечены номерами лишь для того, чтоб указать соответсвие с определениями, порядок расположения этих строк может быть любым. Это может быть полезным при использовании фичи «Background». Достигается такая свобода регистраций за счет того, что дерево провайдеров строится не во время собственно регистрации, а при первом получения результата.

Получение результатов поиска


var policy = context.Value<Policy>(policyKey);
var policies = context.All<Policy>(policyKey);
var coverages = context.All<Coverage>(coverageKey);

Первая строчка возвращает первый полис, который подходит подо все условия, т.е. создан в 2007 году, и имеет как минимум один Coverage типа Dependent в котором есть 70 человек.
Вторая строчка возвращает все полисы, удовлетворяющие этим условиям.
Третяя строчка возвращает все подходящие объекты Coverage из подходящих полисов. То есть результат не содержит подходящие Сoverage из неподходящих полисов.
Метод «All» при этом возвращает IEnumerable<IResolutionContext<Policy>>, а не IEnumerable<Policy>. Чтобы получить последнее нужно посредством Select извлечь поле Value. Интерфейс IResolutionContext позволяет получать список подходящих дочерних сущностей для текущего родителя. Пример:
var policies = context.All<Policy>(policyKey);
var firstPolicyCoverages = policies.First().Get<Coverage>(coverageKey);


Комбинированые фильтры


В некоторых случаях необходимо сравнить поля двух сущностей. Пример такого фильтра:
  And coverage A covers less people than maximum dependendts specified in policy A

Условие, конечно, нереалистично, как и некоторые другие. Надеюсь никого это не смутит, пример есть пример.
Определение такого шага:
context
    .For<Coverage>(coverageKey)
    .With<Policy>(policyKey)
    .Filter((coverage, policy) => coverage.HeadCount < policy.MaximumDependents);

Фильтр «принадлежит» сущности Coverage, и в процессе исполнения ищет полис, проходя по красной стрелке(на первом рисунке) вверх.
Я ограничился тем, что в фильтре можно использовать две сущности, поскольку скорее всего условие использующее 3 сущности можно побить на части. Технического же ограничения нет, я могу добавить «тройной» фильтр по желанию.

Фильтры на коллекцию


Несколько фильтров на коллекцию уже заложены, и один из них — Exists, уже был использован ранее. Так же есть фильтры DoesNotExist и Each.
Означают они буквально следующее — родительская сущность, в данном случае полис, считается подходящей под условие, если есть хотя бы одна дочерняя сущность — Coverage подходящий под условие. Это для Exists. Для DoesNotExist — если нет ни одного Coverage подходящего под условия, и Each — если все Coverage этого полиса подходят под условия.
Кроме этого, можно задавать свои фильтры для коллекций. Например:
context.ForCollection<Coverage>(key)
       .Filter(coverages => coverages.Sum(x => x.Value.HeadCount) > 0);

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

Управление ветвлением


Когда в процессе участвуют лишь две сущности, то вопрос не встает о том, как они будут соотноситься друг с другом в структурном плане. Но этот вопрос возникнет, если сущности станет 3. Если третяя сущность физически будет дочерней от Coverage, то и логически будет так же, то есть цепочка вырастет на еще один элемент. Но если эта третяя сущность будет дочерней от полиса, то появляются два варианта структуры.
Первый вариант — сделать структуру такой же, как и физическую, то есть создать вторую ветвь дерева, как на первом рисунке. Делается это следующим способом:
context.Register()
       .DependsOn<Policy>(policyKey)
       .Provide<Tax>(taxKey, policy => policy.Taxes)
       .Exists();

Второй вариант, это вытянуть регистрации в логическую цепочку, зарегистрировав следующим образом:
context.Register()
       .DependsOn<Coverage>(coverageKey)
       .Resolves<Policy>(policyKey)
       .Provide<Tax>(taxKey, policy => policy.Taxes)
       .Exists();

Получившаяся структура выглядит следующим образом

Нужно это для того случая, когда нужен полный перебор между первой сущностью и второй. Хочется обратить внимание на то, что в строчке Taxes первый и второй прямоугольник относятся к одному и тому же полису, и имеют в источнике одни и те же объекты, в одинаковом количестве, но в результирующей выдаче может получиться разное количество объектов как раз именно из-за комбинированного фильтра, который в комбинации с первым Coverage пропускает одни объекты, а в комбинации со вторым — другие.
context.For<Tax>(taxKey)
       .With<Coverage>(coverageKey)
       .Filter((tax, coverage) => tax.Id == coverage.Id);

Такой фильтр в случае регистрации «цепочкой» найдет все пары Tax и Coverage с совпадающими Id. В то время как для случая регистрации двумя ветвями этот фильтр найдет лишь одну пару, где Id объекта Tax совпадает с Id первого попавшегося объекта Coverage. Причем, если нет Tax с таким Id, то коллекция Tax будет пуста, то есть совпадения не будут найдены.
Стоит также описать каким образом фильтр для Tax получит объект Coverage: Поиск сначала проходит по красной стрелке(первый рисунок) вверх, до ближайшего общего родителя. Затем он идет вниз по синей стрелке в сторону коллекции Coverages, принадлежажий этому родителю.
Причем если поиск вверх, по красной стрелке, может получить только единычный объект — родителя, то поиск вниз, по синей стрелке, может получить только коллекцию. Также предусмотрен поиск вниз на глубину более 1, работает он по принципу SelectMany.
И вот из этой коллекции в фильтре берется FirstOrDefault, причем, если результат null, то фильтр возвращает false не вызывая лямбду.

Сравнение двух коллекций


Дерево с двумя и более ветвями позволяет создавать фильтры в которых участвуют две коллекции. Фильтр, принадлежащий коллекции, может получить вторую коллекцию только из другой ветви дерева.
Текст SpecFlow для примера:
  And average payment per person in coverages B, specified in taxes B is over 10$

и соответствующее определение:
context
    .ForCollection<Coverage>(coverageKey)
    .WithCollection<Tax>(taxKey)
    .Filter((coverages, taxes) => taxes.Sum(x => x.Value.Amount) / coverages.Sum(x => x.Value.HeadCount) > average);


Еще один прием тестирования


Прием заключается в том, чтобы сначала подготовить полностью заполненный объект, проверить, что страница(подразумевается, что тестируем мы веб-приложение) отрабатывает happy-path сценарий успешно, а затем, используя тот же объект в каждом следующем тесте по одному что то ломать и проверять, что страница выдает соответсвующее предупреждение пользователю. Например пользователь, попадающий под happy-path должен иметь пароль, почту, адрес, права доступа и т.д. А для плохих тестов берется тот же пользователь и ломается ему пароль, для следующего теста нулится почта и т.д.
Такой же прием может быть использован и при поиске данных:
Background: 
   Given policy B is taken from policiesSource
      And for policy B exists a coverage B
      And for policy B exists a tax B

Scenario: No coverage with needed count and type
    Given condition 'CoverageExists' is broken

Scenario: No tax with needed amount and type
    Given condition 'TaxExists' is broken

Текст я привел не полностью, только значимые строчки. В примере в секции Background задаются все регистрации, необходимые для happy-path, а в конкретных «плохих» сценариях один из фильтров инвертируется. В happy-path тесте будет найден полис в котором есть подходящий Coverage и подходящий Tax. Для первого такого теста будет найден полис в котором нет подходящего Coverage, для второго, соответственно, полис, в котором нет подходящего Tax. Реализовано это посредством того, что любому фильтру можно назначить некий ключ, затем по этому ключу этот фильтр инвертируется.

Логгирование неудачного поиска


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

Ограничения


Часть ограничений я уже упоминал выше, перечислю все:
1. Комбинированные фильтры могут ссылаться только на одиночных предков, либо на коллекции находящиеся в других ветках. Ссылаться на коллекцию предка нельзя.
2.Ссылаться на дочернюю сущность также нельзя. Фильтр должен принадлежать дочерней сущности и ссылаться на предка, а не наоборот.
3. Циклические зависимости недопустимы, если фильтр для сущности из первой ветки ссылается на сущность из второй, то фильтры из второй ветки не могут ссылаться на сущности из первой.
При нарушении этих ограничений будет выброшен ResolutionException.

На этом всё. Проект доступен на github github.com/repinvv/TestingContext
Готовую сборку можно взять на NuGet www.nuget.org/packages/TestingContext

Комментарии (9)


  1. muradovm
    12.10.2015 11:03

    Вот уже как лет пять слежу за SpecFlow, даже пробовал не раз, но никак не пойму, как его использовать в реальном проекте. Как минимум высокая стоимость написания и поддержки теста по сравнению с NUnit каким-нибудь. Можете привести пример, где именно без SpecFlow не обойтись?


    1. VladVR
      12.10.2015 11:56

      А без него можно обойтись. У него высокий порог вхождения, немалый оверхэд. А плюсы становятся заметны не сразу.
      Переиспользование становятся заметным лишь когда написано некоторое критическое количество тестов. А читаемость становится наиболее заметна уже при поддержке, а не при написании. Тесты написаны «человеческим» языком, и для того, чтобы разобраться что именно проверяет тот или иной тест нужно существенно меньше времени. Кроме того полностью исчезает какая либо необходимость оставлять комментарии в коде теста.
      А использовать его можно точно также, я вроде как пример даже привел. Правда этот пример живет не в этом проекте, а в соседнем на гитхабе.


    1. Quilin
      12.10.2015 17:25

      SpecFlow, будучи транслированным на .NET Cucumber, это не альтернатива NUnit (его можно настроить генерировать код NUnit-тестов, например), а реализация BDD — Behaviour Driven Development.
      Тесты, написанные в этом стиле, во-первых, оборачиваются в человеко-читаемый язык (все эти Given-When-Then, Outline и прочая значительно облегчают чтение тестов для тех, кто не знаком с языком или не имеет возможности вникать в детали реализации). Скажем, если у вас под строчкой Given User 'U1' has Product 'P1' in product card скрывается пяток вызовов API (всякое же бывает) — плюс очевиден.
      Во-вторых, сам подход подразумевает деление тесткейса на шаги, которые в дальнейшем могут быть повторно вызваны. В «классическом» подходе к юнит-тестам это нередко считается bad practice — приватные методы в тестах.

      Что не нравится в SpecFlow, но что, кажется, будет решено в новой версии — это непрозрачность в порядке подключения hooks. Ну, скажем, если у вас есть два класса, в которых имеются методы с атрибутами типа Before/After-Feature/Scenario, им нельзя указать приоритет выполнения. И это грустно. Опять же, когда в том же NUnit можно использовать наследование в классах тестов, здесь это полностью исключено. И это нередко затрудняет процесс.


      1. muradovm
        12.10.2015 18:07

        BDD не отменяет возможность использования NUnit. Просто эти тесты будут интеграционными.
        Оборачивание 5ти вызовов API в человекочитаемый вид имеет не только плюсы, но и минусы. Например, любые изменения в API нужно будет руками править в тестах. Как часто человек, не знающий языка программирования заглядывает в тесты проекта? Стоит ли ради этого отказываться от нормальной поддержи тестов?


        1. Quilin
          13.10.2015 10:10

          BDD не отменяет возможность использования NUnit.

          Именно это я и сказал.

          Например, любые изменения в API нужно будет руками править в тестах

          Ну, во-первых, не любые, а во-вторых, при тестировании API вам так и так придется править тесты при существенном изменении тестируемого объекта.

          BDD не альтернатива юнит-тестированию, никоим образом. Я лично считаю, что в проекте хорошо держать и те, и другие. Даже некоторые интеграционные тесты (например, хитрожопые запросы к базе данных) не обязательно писать в BDD-стиле. Просто «нормальная» поддержка тестов показывает стабильность работы приложения для разработчиков, а BDD — для всех. Это однозначно хорошо.

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

          Зависит от проекта. У меня, например, owner заглядывает регулярно =).


        1. VladVR
          13.10.2015 16:33

          -> Как часто человек, не знающий языка программирования заглядывает в тесты проекта?
          именно поэтому и не заглядывает, разве нет?


          1. muradovm
            13.10.2015 16:39

            Я про то же. Зачем нужен этот человекочитаемый тест программисту.
            Хотя выше писали обратное — такие тесты смотрят.


    1. LSD
      12.10.2015 19:18

      У нас на кукумбере аналитики пишут юзкейсы. А потом по этим тюзкейсам мы гоняем интеграционные аксептанс тесты.


  1. forcewake
    12.10.2015 14:15

    В качестве реального use-case могу предложить такой вариант развития событий:

    • Новый для вас проект с большим слоем-бизнес логики
    • Поступает feature-request от business owner'a
    • Для создания общего словаря и более углубленного понимания того, что вам нужно сделать все требования оформляются в качестве таких вот Given-When-Then тестов


    Пример такого теста
    @ignore
    Feature: UserAccess	
    
    @web
    Scenario: Try to get access to Some_Part_Of_The_Application
      Given I open 'Application_Name' application as 'User'.
      When I navigate to 'Some_Part_Of_The_Application'.
      Then Menu navigation causes exception.