Вы уже знаете о моей склонности приглашать разработчиков поговорить на интересные мне темы в группе пользователей, которую я возглавляю в Вермонте. Результатом стали мои статьи в этой рубрике по такой тематике, как Knockout.js и Breeze.js. Интересных мне тем еще много, например Command Query Responsibility Segregation (CQRS), которые я пока обдумываю. Но недавно Деннис Дуар (Dennis Doire), архитектор и тестер, рассказывал о SpecFlow и Selenium — двух инструментах для тестеров, занятых в сфере разработки, управляемой поведениями (behavior-driven development, BDD). И вновь я широко раскрыла глаза от удивления, а в голове стали роиться мысли насчет того, под каким предлогом можно было бы выпросить эти инструменты для экспериментов. Но на самом деле мое внимание привлекла сама BDD. Хотя я приверженец разработки, управляемой данными, мои времена проектирования приложений на основе баз данных остались далеко позади, и я стала больше интересоваться предметной областью.
BDD — это вариация разработки, управляемой тестами (test-driven development, TDD), в которой основное внимание уделяется историям пользователей и построению логики и тестов на основе этих историй. Вместо следования одному правилу вы следуете набору операций. Подход довольно всеобъемлющий, что мне нравится, поэтому такая перспектива очень интересует меня. Идея в том, что, хотя типичный модульный тест может гарантировать корректную работу одного события в объекте Customer, BDD фокусируется на более широкой концепции — на поведении, которое пользователь ожидает при работе с создаваемой для него системой. BDD часто применяется, чтобы определить критерии приемлемости (acceptance criteria) в процессе обсуждения с клиентами. Например, когда я сижу перед компьютером и заполняю форму New Customer, а затем щелкаю кнопку Save, система должна сохранить информацию о клиенте и вывести мне сообщение о том, что эта информация успешно сохранена.
Или, например, когда я активирую компонент Customer Management в программном обеспечении, он должен автоматически открывать информацию о последнем клиенте, с которым я работала в прошлый раз.
Из этих историй пользователей видно, что BDD может быть ориентированной на UI методикой проектирования автоматизированных тестов, но многие сценарии пишутся до того, как приступают к проектированию UI. А благодаря таким инструментам, как Selenium (docs.seleniumhq.org) и WatiN (watin.org), можно автоматизировать тесты в браузере. Но BDD — это не только описание взаимодействия пользователя с системой. Чтобы получить более широкое представление о BDD, почитайте дискуссию на InfoQ между некоторыми авторитетами в области BDD, TDD и Specification by Example по ссылке bit.ly/10jp6ve.
Я хочу отойти от вопросов, связанных с нажатием кнопок и всего прочего в таком духе, и немного переопределить истории пользователей. Я могу удалить из истории элементы, зависимые от UI, и сосредоточиться на той части процесса, которая никак не зависит от того, что показывается на экране. И конечно же, меня интересуют те истории, которые относятся к доступу к данным.
Создание логики для проверки корректности конкретного поведения может оказаться весьма утомительным занятием. Один из инструментов, которые Дуар продемонстрировал в своей презентации, был SpecFlow (specflow.org). Этот инструмент интегрируется с Visual Studio и позволяет определять истории пользователей (называемые сценариями) с применением простых правил. Затем он отчасти автоматизирует создание и выполнение методов (некоторых — с тестами, а некоторых — без них). Цель — удостовериться в соблюдении правил истории.
Я намерена провести вам через процедуру создания нескольких поведений, чтобы разжечь ваш аппетит, а если вас заинтересуют подробности, вы найдете список некоторых ресурсов в конце статьи.
Для начала вы должны установить SpecFlow в Visual Studio, что можно сделать из Visual Studio Extensions and Updates Manager. Поскольку суть BDD в том, чтобы начать разработку проекта с описания поведений, первым в вашем решении является проект теста, где вы описываете эти поведения. Остальная часть решения будет вытекать из этого описания.
Создайте новый проект, используя шаблон Unit Test Project. В вашем проекте потребуется ссылка на TechTalk.SpecFlow.dll, которую можно установить с помощью NuGet. После этого создайте папку Features внутри этого проекта.
Моя первая функция (feature) будет базироваться на истории пользователя, относящейся к добавлению нового клиента, поэтому в папке Features я создаю папку Add (рис. 1). В ней я определяю свой сценарий и прошу SpecFlow помочь мне в этом деле.
Рис. 1. Проект теста с папкой Features и подпапками Add
SpecFlow следует специфическому шаблону, который полагается на ключевые слова, помогающие описывать функцию, чье поведение вы определяете. Ключевые слова взяты из языка Gherkin (да, так называют мелкие маринованные огурцы), и все это исходит из утилиты Cucumber (cukes.info). Некоторые из ключевых слов — Given, And, When и Then, и вы можете использовать их при написании сценария. Вот пример простого сценария, который инкапсулируется в функции Adding a New Customer:
Given user has entered information about a customer
When she completes entering more information
Then that customer should be stored in the system
Вы могли бы уточнить этот сценарий, например:
Given a user has entered information about a customer
And she has provided a first name and a last name as required
When she completes entering more information
Then that customer should be stored in the system
Именно в последнем выражении я буду выполнять кое-какие операции сохранения данных. SpecFlow не волнует, как это будет делаться. Цель — писать сценарии для доказательства того, что результат является и остается успешным. Сценарий будет движущей силой для набора тестов, а тесты помогут конкретизировать логику вашей предметной области:
Given that you have used the proper keywords
When you trigger SpecFlow
Then a set of steps will be generated for you to populate with code
And a class file will be generated that will automate the execution of these steps on your behalf
Посмотрим, как это работает.
Щелкните правой кнопкой мыши папку Add, чтобы добавить новый элемент. Если вы установили SpecFlow, то можете найти три элемента, относящихся к SpecFlow, поиском по specflow. Выберите элемент SpecFlow Feature File и присвойте ему какое-нибудь имя. Я назвала этот элемент так: AddCustomer.feature.
Файл функции (feature file) начинается с выборки — вездесущей математической функции. Заметьте, что Feature описывается в начале, а в конце размещается Scenario (представляющий ключевой пример функции), описываемый с применением ключевых слов Given, And, When и Then. Надстройка SpecFlow обеспечивает выделение разного текста цветами, чтобы вы могли легко распознавать пошаговые условия (step terms) из собственных выражений.
Я заменю готовую функцию и создам свою:
Feature: Add Customer
Позволяет создавать и сохранять новых клиентов,
если для новых клиентов указываются имя и фамилия
Scenario: HappyPath
Given a user has entered information about a customer
And she has provided a first name and a last name as required
When she completes entering more information
Then that customer should be stored in the system
(Спасибо Дэвиду Старру [David Starr] за имя для Scenario! Я украла это название из его видеоролика на Pluralsight.)
А что будет, если необходимые данные не предоставлены? Я создам другой сценарий в этой функции для обработки такой возможности:
Scenario: Missing Required Data
Given a user has entered information about a customer
And she has not provided the first name and last name
When she completes entering more information
Then that user will be notified about the missing data
And the customer will not be stored into the system
Теперь все будет работать.
От истории пользователя к коду
К этому моменту вы увидели элемент Feature и цветовое выделение кода, осуществляемое SpecFlow. Заметьте, что с файлом функции связывается файл отделенного кода (codebehind file), который содержит некоторые пустые тесты, созданные на основе функций. Каждый из этих тестов будет выполнять этапы в вашем сценарии, но вы еще должны создать эти этапы. Сделать это можно несколькими способами. Вы могли бы запустить тесты, и SpecFlow вернул бы листинг кода для класса Steps в выводе теста, который вы можете скопировать и вставить в нужное место. В качестве альтернативы вы могли бы использовать один из инструментов в контекстном меню файла функции. Я опишу второй подход.
- Щелкните правой кнопкой мыши в окне текстового редактора файла функции. В контекстном меню вы увидите раздел, выделенный для задач SpecFlow.
- Щелкните Generate Step Definitions. Появится окно для проверки создаваемых этапов.
- Щелкните кнопку Copy methods to clipboard и используйте то, что предлагается по умолчанию.
- В папке AddCustomer своего проекта создайте новый файл класса с именем Steps.cs.
- Откройте этот файл и вставьте в определение класса содержимое буфера обмена.
- Добавьте в начало файла ссылку на какое-нибудь пространство имен, используя TechTalk.SpecFlow.
- Добавьте к классу аннотацию Binding.
Исходный код нового класса приведен на рис. 2.
Рис. 2. Файл Steps.cs.
[Binding]
public class Steps
{
[Given(@"a user has entered information about a customer")]
public void GivenAUserHasEnteredInformationAboutACustomer()
{
ScenarioContext.Current.Pending();
}
[Given(@"she has provided a first name and a last name as required")]
public void GivenSheHasProvidedAFirstNameAndALastNameAsRequired ()
{
ScenarioContext.Current.Pending();
}
[When(@"she completes entering more information")]
public void WhenSheCompletesEnteringMoreInformation()
{
ScenarioContext.Current.Pending();
}
[Then(@"that customer should be stored in the system")]
public void ThenThatCustomerShouldBeStoredInTheSystem()
{
ScenarioContext.Current.Pending();
}
[Given(@"she has not provided both the firstname and lastname")]
public void GivenSheHasNotProvidedBothTheFirstnameAndLastname()
{
ScenarioContext.Current.Pending();
}
[Then(@"that user will get a message")]
public void ThenThatUserWillGetAMessage()
{
ScenarioContext.Current.Pending();
}
[Then(@"the customer will not be stored into the system")]
public void ThenTheCustomerWillNotBeStoredIntoTheSystem()
{
ScenarioContext.Current.Pending();
}
}
Если вы изучите созданные мной два сценария, то заметите, что, хотя сгенерированные определения отчасти перекрываются (например, «a user has entered information about a customer»), сгенерированные методы не создают дубликаты этапов. Кроме того, видно, что SpecFlow будет использовать константы в атрибутах методов. Имена методов не имеют значения.
К этому моменту вы могли бы позволить SpecFlow запустить тесты, которые вызовут данные методы. Хотя SpecFlow поддерживает ряд инфраструктур модульного тестирования, я применяю MSTest, поэтому, если вы смотрите это решение в Visual Studio, вы увидите, что файл отделенного кода Feature определяет TestMethod для каждого сценария. Каждый TestMethod выполняет правильную комбинацию методов этапа в сочетании с TestMethod, выполняемым для сценария HappyPath.
Если бы я запустила это сейчас, щелкнув правой кнопкой мыши файл Feature и выбрав Run SpecFlow Scenarios, тест не дал бы определенных результатов, и вы увидели бы сообщение «One or more step definitions are not implemented yet» (одно или более определений этапов пока не реализованы). Это связано с тем, что каждый метод в файле Steps все еще вызывает Scenario.Current.Pending.
Итак, пришла пора наполнить методы нужным кодом. Мои сценарии подсказывают, что мне нужен тип Customer с требуемыми данными. Благодаря другой документации я знаю, что сейчас необходимы имя и фамилия клиента, поэтому в типе Customer понадобятся эти два свойства. Кроме того, мне нужен механизм сохранения этого объекта, а также место его хранения. Моим тестам безразлично, как и где он сохраняется, — главное, чтобы это делалось; поэтому я задействую хранилище, которое будет отвечать за получение и сохранение данных.
Я начну с добавления переменных _customer и _repository в свой класс Steps:
private Customer _customer;
private Repository _repository;
And then stub out a Customer class:
public class Customer
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}
Этого достаточно, чтобы добавить код в мои методы этапов. На рис. 3 показана логика, включенная в этапы, которые относятся к HappyPath. Сначала я создаю нового клиента, а затем предоставляю необходимые имя и фамилию. Больше ничего интересного, о чем стоило бы упомянуть, в этапе WhenSheCompletesEnteringMoreInformation я не делаю.
Рис. 3. Некоторые из методов этапов в SpecFlow
[Given(@"a user has entered information about a customer")]
public void GivenAUserHasEnteredInformationAboutACustomer()
{
_newCustomer = new Customer();
}
[Given(@"she has provided a first name and a last name as required")]
public void GivenSheHasProvidedTheRequiredData()
{
_newCustomer.FirstName = "Julie";
_newCustomer.LastName = "Lerman";
}
[When(@"she completes entering more information")]
public void WhenSheCompletesEnteringMoreInformation()
{
}
Последний этап самый интересный. Здесь я не только сохраняю клиента, но и доказываю, что он действительно был сохранен. Мне потребуются метод Add в хранилище для сохранения клиента, метод Save для его перемещения в базу данных, а затем некий способ поиска этого клиента в хранилище. Поэтому я добавлю в хранилище методы Add, Save и FindById:
public class CustomerRepository
{
public void Add(Customer customer)
{ throw new NotImplementedException(); }
public int Save()
{ throw new NotImplementedException(); }
public Customer FindById(int id)
{ throw new NotImplementedException(); }
}
Теперь я могу добавлять логику последнего этапа, которая будет вызываться сценарием HappyPath. Я внесу объект Customer в хранилище и проверю, удается ли его найти в этом хранилище. Здесь я использую выражения Assert, чтобы определить успешно ли выполняется мой сценарий. Если Customer найден (т. е. IsNotNull), тест пройден. Это самый распространенный шаблон проверки того, были ли сохранены данные. Однако из моего опыта работы с Entity Framework я вижу проблему, которую тест не выявит. Для начала напишем следующий код так, чтобы я могла показать вам проблему более наглядно:
[Then(@"that customer should be stored in the system")]
public void ThenThatCustomerShouldBeStoredInTheSystem()
{
_repository = new CustomerRepository();
_repository.Add(_newCustomer);
_repository.Save();
Assert.IsNotNull(_repository.FindById(_newCustomer.Id));
}
Когда я вновь запускаю тест HappyPath, он проваливается. Из вывода теста на рис. 4 видно, как работает мой SpecFlow-сценарий на данный момент. Но обратите внимание на причину провала теста: она вызвана не тем, что FindById не нашел объект Customer, а тем, что методы хранилища пока не реализованы.
Рис. 4. Вывод неудачного теста, показывающий состояние каждого этапа
Test Name: HappyPath
Test Outcome: Failed
Result Message:
Test method UnitTestProject1.UserStories.Add.AddCustomerFeature.HappyPath threw exception:
System.NotImplementedException: The method or operation is not implemented.
Result StandardOutput:
Given a user has entered information about a customer
-> done: Steps.GivenAUserHasEnteredInformationAboutACustomer() (0.0s)
And she has provided a first name and a last name as required
-> done: Steps. GivenSheHasProvidedAFirstNameAndALastNameAsRequired() (0.0s)
When she completes entering more information
-> done: Steps.WhenSheCompletesEnteringMoreInformation() (0.0s)
Then that customer should be stored in the system
-> error: The method or operation is not implemented.
Так что мой следующий шаг — создание логики для хранилища. В конечном счете я буду использовать это хранилище для взаимодействия с базой данных и, будучи сторонницей Entity Framework, я задействую в этом хранилище DbContext из Entity Framework. Начну с создания класса DbContext, который предоставляет Customers DbSet:
public class CustomerContext:DbContext
{
public DbSet<Customer> Customers { get; set; }
}
После этого я могу выполнить рефакторинг CustomerRepository, чтобы применять CustomerContext для сохранения. В этом демонстрационном коде я буду работать непосредственно с контекстом безо всяких абстракций. Вот обновленный CustomerRepository:
public class CustomerRepository
{
private CustomerContext _context = new CustomerContext();
public void Add(Customer customer
{ _context.Customers.Add(customer); }
public int Save()
{ return _context.SaveChanges(); }
public Customer FindById(int id)
{ return _context.Customers.Find(id); }
}
Теперь, когда я снова запускаю тест HappyPath, он проходит, и все мои этапы помечаются как выполненные (done). Но я-то знаю, что не все так хорошо, как кажется..
Убедитесь, что тестам на интеграцию известно поведение EF
Почему я не радуюсь тому, что мои тесты прошли успешно? Да потому, что мне известно: тест на самом деле не доказывает, что объект Customer был сохранен.
В методе ThenThatCustomerShouldBeStoredInTheSystem закомментируйте вызов Save и снова запустите тест. Он по-прежнему успешно проходит. Но я даже не сохранила клиента в базе данных! Теперь-то вы чувствуете запашок? А пахнет тем, что называют ложно положительным результатом.
Проблема в том, что метод DbSet Find, применяемый мной в хранилище, — это особый метод в Entity Framework, который перед поиском в базе данных сначала проверяет объекты в памяти, отслеживаемые контекстом. Когда я вызываю Add, я ставлю CustomerContext в известность об этом экземпляре объекта Customer. Вызов Customers.Find обнаружил этот экземпляр и пропустил лишнее обращение к базе данных. По сути, идентификатор клиента по-прежнему равен 0, так как клиент еще не сохранен.
Поэтому, раз я использую Entity Framework (и вы тоже должны учитывать поведение любой другой применяемой вами ORM-инфраструктуры), у меня есть более простой способ проверки того, действительно ли клиент попал в базу данных. Когда EF-команда SaveChanges вставляет клиента в базу данных, она возвращает новый идентификатор клиента, сгенерированный базой данных, и применяет его к вставленному экземпляру. Следовательно, если идентификатор нового клиента больше не равен 0, можно быть уверенным, что клиент реально помещен в базу данных. И мне незачем повторно запрашивать базу данных.
Теперь соответственно переделаем Assert для этого метода. Вот метод, который точно выполнит корректный тест:
[Then(@"that customer should be stored in the system")]
public void ThenThatCustomerShouldBeStoredInTheSystem()
{
_repository = new CustomerRepository();
_repository.Add(_newCustomer);
_repository.Save();
Assert.IsNotNull(_newCustomer.Id>0);
}
Он проходит, и я знаю, что его успех обоснован. Совсем не трудно определить провальный тест, используя, например, Assert.IsNull(FindById(customer.Id), чтобы убедиться в том, что неудача вызвана ложной причиной. Но в данном случае проблема не проявлялась до тех пор, пока я не удалила вызов Save. Если вы не уверены в том, как работает EF, будет разумнее создать еще и некоторые специфические тесты на интеграцию, не связанные с историями пользователей, чтобы убедиться в правильности поведения хранилища.
Тест поведения или тест на интеграцию?
Постепенно изучая SpecFlow на примере своего первого сценария, я столкнулась с тем, что сочла «скользкой дорожкой». В моем сценарии утверждается, что клиент должен быть сохранен в «системе».
Проблема в том, что я не была уверена в определении системы. Мой прошлый опыт подсказывал мне, что база данных или, как минимум, некий механизм сохранения является очень важной частью системы.
Пользователя не интересуют хранилища и базы данных — только его приложение. Но он расстроится, если вернется в свое приложение и не найдет никакой информации о новом клиенте, потому что на самом деле тот не был сохранен в базе данных (из-за того, что я не думала, что _repository.Save был необходим для удовлетворения требований его сценария).
Я дополнительно проконсультировалась с Деннисом Думеном (Dennis Doomen), автором Fluent Assertions и очень опытным специалистом по BDD, TDD и прочим методологиям в крупных корпоративных системах. Он подтвердил, что как разработчик я определенно должна применять свои знания к этапам и тестам, даже если это приведет к выходу за рамки намерений пользователя, который определил исходный сценарий. Пользователи вкладывают свои знания, а я добавляю свои так, чтобы технические проблемы не обрушились на голову пользователя.
Изучайте BDD и SpecFlow
Я совершенно уверена: не будь всех этих инструментов для поддержки BDD, мой путь в освоении этой методологии не был бы таким простым. Хотя я специалист по работе с данными, я уделяю много внимания работе со своими клиентами, вникаю в их бизнес и добиваюсь того, чтобы они были удовлетворены использованием программного обеспечения, которое я помогаю им создавать. Вот почему мне так важны Domain Driven Design и Behavior Driven Design. Полагаю, что многие разработчики чувствую примерно то же самое — пусть и не столь явно, и, возможно, их вдохновят эти методологии.
Помимо друзей, помогавших мне постичь эту методологию, есть ряд ресурсов, которые я сочла весьма полезными. Прежде всего это статья в журнале «MSDN Magazine» под названием «Behavior-Driven Development with SpecFlow and WatiN», которую можно найти по ссылке msdn.microsoft.com/magazine/gg490346. Я также увидела превосходный видеоролик от Дэвида Старра, автора учебного курса «Test First Development» на Pluralsight.com. (Если честно, я пересмотрела его несколько раз.) В Wikipedia есть статья по BDD (bit.ly/LCgkxf), интересная тем, что она дает более широкую картину истории BDD и того, как она сочетается с другими методологиями. И еще я с нетерпением жду книгу «BDD and Cucumber», соавтором которой является Пол Рейнер (Paul Rayner) (он тоже консультировал меня по этой статье).