Pull to refresh

SpecFlow и альтернативный подход к тестированию

Reading time 11 min
Views 28K
Тестирование с помощью 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 insurance created in year 2006
  And insurance has an assignment with type 'Dependent' and over 70 people covered

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

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

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


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

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

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

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


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

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

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

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


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

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

Для того, чтобы зарегистрировать такие источники и фильтры, используется следующий синтаксис
            context.Register()
                   .Items(key, () => InsurancesSource.Insurances); #1
            context.Register()
                   .For<Insurance>(key)
                   .IsTrue(insurance => insurance.Created.Year == year); #2
            context.Register()
                   .For<Insurance>(insuranceKey)
                   .Exists(assignmentKey, insurance => insurance.Assignments); #3
            context.Register()
                .For<Assignment>(key)
                .IsTrue(assignment => assignment.Type == type);  #4
            context.Register()
                   .For<Assignment>(key)
                   .IsTrue(assignment => assignment.HeadCount >= headCount);  #5

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

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


var insurance = context.Value<Insurance>(insuranceKey);
var insurances = context.All<Insurance>(insuranceKey);
var assignments = context.All<Assignment>(assignmentKey);

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

Важно тут упомянуть что, для двух любых пар T1-key1 и T2-key2 справедливо следующее условие — Если с контекста T1-key1, допустим это будет переменная
 IResolutionContext<T1> c1 
, получить коллекцию контекстов T2-key2
IEnumerable<IResolutionContext<T2>> cs2 = c1.Get<T2>(key2)
, то с любого из элементов этой коллекции можно сделать обратный вызов к T1-key1 и полученная коллекция будет содержать исходный элемент.
cs2.All(x => x.Get<T1>(key1).Contains(c1)) == true

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

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


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

Условие, конечно, нереалистично, как и некоторые другие. Надеюсь никого это не смутит, пример есть пример.
Определение такого шага:
context
    .For<Assignment>(assignmentKey)
    .For<Insurance>(insuranceKey)
    .IsTrue((assignment, insurance) => assignment.HeadCount < insurance.MaximumDependents);

Фильтр назначается на самую дальнюю от корня дерева сущности, в данном случае это Assignment, и в процессе исполнения ищет страховку, проходя по красной стрелке(на первом рисунке) вверх.
Если в фильтре участвуют две сущности одного типа, с разными ключами, то между ними автоматически применяется фильтр неравенства.
то есть в случае .For<Assignment>(«a») .For<Assignment>(«b») в лямбду (а1, а2) => никогда не попадет одна и та же сущность в оба аргумента.

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

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


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

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

Второй пример предполагает сравнение двух коллекций.
Текст SpecFlow для примера:
  And average payment per person in assignments B, specified in taxes B is over 10$

и соответствующее определение:
context
    .ForAll<Assignment>(assignmentKey)
    .ForAll<Tax>(taxKey)
    .IsTrue((assignments, taxes) => taxes.Sum(x => x.Amount) / assignments.Sum(x => x.HeadCount) > average);


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


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

Scenario: No assignment with needed count and type
    Given there is no suitable assignment B

Scenario: No tax with needed amount and type
    Given there is no suitable tax B

Текст я привел не полностью, только значимые строчки. В примере в секции Background задаются все регистрации, необходимые для happy-path, а в конкретных «плохих» сценариях один из фильтров инвертируется. В happy-path тесте будет найдена страховка в которой есть подходящий Assignment и подходящий Tax. Для первого «плохого» теста будет найдена страховка в которой нет подходящего Assignment, для второго, соответственно, страховка, в которой нет подходящего Tax. Такую инверсию можно включить следующим образом:
context.InvertCollectionValidity<Assignment>(key);

Кроме этого, любому фильтру можно назначить некий ключ, затем по этому ключу этот фильтр инвертировать.

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


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

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

Articles