Эта статья является продолжением первой части и раскрывает технические подробности работы с «исполняемой спецификацией» с помощью SpecFlow.
Для начала работы вам понадобится плагин к Visual Studio (скачивается с официального сайта) и пакет SpecFlow (устанавливается из nuget).
Итак, наш Product Owner попросил команду разработать калькулятор…
@calculator
Feature: Sum
As a math idiot
I want to be told the sum of two numbers
So that I can avoid silly mistakes
@positive @sprint1
Scenario: Add two numbers
Given I have entered 50 into the calculator
And I have entered 70 into the calculator
When I press add
Then the result should be 120 on the screen
Стоит обратить внимание на @атрибуты. У них есть несколько важных свойств. Первое, — если вы используете NUnit, SpecFlow добавить атрибут [NUnit.Framework.CategoryAttribute(«calculator»)]. Это очень удобно для составление тест-планов. Деление по категориями поддерживают R#, родной NUnit Runner и Team City.
Давайте автоматизируем этот сценарий. К этому времени разработчики уже подготовили интерфейс калькулятора:
public interface ICalculator
{
decimal Sum(params decimal[] values);
decimal Minus(decimal a, decimal b);
decimal Sin(decimal a);
decimal Multiply(params decimal[] values);
decimal Divide(decimal a, decimal b);
}
Добавим сервисный контекст тестирования:
public class CalculationContext
{
private readonly List<decimal> _values = new List<decimal>();
public ICalculator Calculator { get; private set; }
public decimal Result { get; set; }
public Exception Exception { get; set; }
public List<decimal> Values
{
get { return _values; }
}
public CalculationContext()
{
Calculator = new Calculator();
}
}
Для автоматизации шагов SpecFlow использует специальные атрибуты:
[Binding]
public class Sum : CalcStepsBase
{
public CalculationContext Context {get;set;}
public Sum(CalculationContext context)
{
Context = CalculationContext();
}
[Given("I have entered (.*) into the calculator")]
public void Enter(int digit)
{
Context.Values.Add(digit);
}
[When("I press (.*)")]
public void Press(string action)
{
switch (action.ToLower())
{
case "add":
case "plus":
Context.Result = Context.Calculator.Sum(Context.Values.ToArray());
break;
default: throw new InconclusiveException(string.Format("Action "{0}" is not implemented", action));
}
}
[Then("the result should be (.*) on the screen")]
public void Result(decimal expected)
{
Assert.AreEqual(expected, Context.Result);
}
}
В этом подходе есть несколько преимуществ:
- Каждый шаг нужно автоматизировать лишь однажды
- Вы избегаете проблемы со сложными цепочками наследования, код выглядит гораздо понятнее
- Атрибуты используют регулярные выражения, так один атрибут может «поймать» несколько шагов. Атрибут When, в данном случае, отработает для фразы «add» и «plus»
Все, все шаги реализованы, тест можно запустить прямо из .feature-файла.
Альтернативная запись
@positive
Scenario: Paste numbers
Given I have entered two numbers
| a | b |
| 1 | 2 |
When I press add
Then the result should be 3 on the screen
[Given("I have entered two numbers")]
public void Paste(Table values)
{
var calcRow = values.CreateInstance<CalcTable>();
Context.Values.Add(calcRow.A);
Context.Values.Add(calcRow.B);
}
public class CalcTable
{
public decimal A { get; set; }
public decimal B { get; set; }
}
Такой вариант записи может быть удобен, когда нужно заполнить большой объект. Например, данные аккаунта пользователя.
Мы что будем тестировать только один набор данных?
Конечно, одного теста недостаточно, писать так десятки сценариев для разных чисел удовольствие сомнительное. На помощь приходит Scenario Outline
@calculator
Feature: Calculations
As a math idiot
I want to be told the calculation result of two numbers
So that I can avoid silly mistakes
@positive @b12 @tc34
Scenario Outline: Add two numbers
Given I have entered <firstValue> into the calculator
And I have entered <secondValue> into the calculator
When I press <action>
Then the <result> should be on the screen
Examples:
| firstValue | secondValue | action | result |
| 1 | 2 | plus | 3 |
| 2 | 3 | minus | -1 |
| 2 | 2 | multiply | 4 |
SpecFlow подставит значения из таблицы в плейсхолдер. Уже неплохо, но нужно еще дописать автоматизацию:
[When("I press (.*)")]
public void Press(string action)
{
switch (action.ToLower())
{
case "add":
case "plus":
Context.Result = Context.Calculator.Sum(Context.Values.ToArray());
break;
case "minus":
Context.Result = Context.Calculator.Minus(Context.Values[0], Context.Values[1]);
break;
case "multiply":
Context.Result = Context.Calculator.Multiply(Context.Values.ToArray());
break;
case "sin":
Context.Result = Context.Calculator.Sin(Context.Values[0]);
break;
default: throw new InconclusiveException(string.Format("Action "{0}" is not implemented", action));
}
}
[Then("the result should be (.*) on the screen")]
[Then("the (.*) should be on the screen")]
public void Result(decimal expected)
{
Assert.AreEqual(expected, Context.Result);
}
Мы изменил для лучшей читаемости вторую строку. Поэтому на метод Result нужно повесить второй атрибут. На выходе вы получите 3 теста с отчетами вида:
Given I have entered 1 into the calculator
-> done: Sum.Enter(1) (0,0s)
And I have entered 2 into the calculator
-> done: Sum.Enter(2) (0,0s)
When I press plus
-> done: Sum.Press("plus") (0,0s)
Then the result should be 3 on the screen
-> done: Sum.Result(3) (0,0s)
А что на счет негативных тестов?
Добавим проверку деления на 0:
@calculator
Feature: Devision
As a math idiot
I want to be told the devision of two numbers
So that I can avoid silly mistakes
@negative @exception
Scenario: Zero division
Given I have entered 10 into the calculator
And I have entered 0 into the calculator
When I press divide
Then exception must occur
В данном случае, не хочется мешать мухи с котлетами и для деления я предпочел бы иметь отдельный файл. Возникает вопрос. Что делать с контекстом? Он же остался в классе Sum. SpecFlow поддерживает инъекцию в конструктор. Выделим базовый класс:
public class CalcStepsBase
{
protected CalculationContext Context;
public CalcStepsBase(CalculationContext context)
{
Context = context;
}
}
Унаследуем новый класс с шагами для деления от него:
[Binding]
public class Division : CalcStepsBase
{
public Division(CalculationContext context) : base(context)
{
}
[When("I press divide"), Scope(Scenario = "Zero division")]
public void ZeroDivision()
{
try
{
Context.Calculator.Divide(Context.Values[0], Context.Values[1]);
}
catch (DivideByZeroException ex)
{
Context.Exception = ex;
}
}
[Then("exception must occur")]
public void Exception()
{
Assert.That(Context.Exception, Is.TypeOf<DivideByZeroException>());
}
}
Для того, чтобы байдинги не конфликтовали разделим их на позитивные и негативные
[When("I press (.*)"), Scope(Tag = "positive")]
[When("I press divide"), Scope(Scenario = "Zero division")]
Мы можем фильтровать область видимости как по сценарию, так и по тегам.
Data Driven Tests
Четырех примеров на сложение, вычитание и умножение явно недостаточно. Существует множество систем, с внушительными объемами входных и выходных значений. В этом случае простыня в DSL будет смотреться не очень наглядно. Для того чтобы с одной стороны писать тесты было удобно, а с другой, — чтобы сохранить GWT-формат можно пойти двумя путями:
- Использовать BDDfy
- допилить SpecFlow под свои нужды
Я остановился на втором варианте, чтобы не поддерживать два формата:
[TestFixture]
[Feature(
"Sum Excel",
As = "Math idiot",
IWant = "to be told sum of two numbers",
SoThat = "I can avoid silly mistakes")]
public class ExcelSumTests : GherkinGenerationTestsBase
{
[TestCaseSource("Excel")]
[Scenario("Sum Excel", "excel", "positive", "calculator")]
public void AddTwoNumbers_TheResultShouldBeOnTheScreen(string firstValue, string secondValue, string action, string result)
{
Given(string.Format("I have entered {0} into the calculator", firstValue));
Given(string.Format("I have entered {0} into the calculator", secondValue), "And ");
When(string.Format("I press {0}", action));
Then(string.Format("the result should be {0} on the screen", result));
}
public static IEnumerable<object[]> Excel()
{
return ExcelTestCaseDataReader.FromFile("Sum.xlsx").GetArguments();
}
}
Background и предусловия
Иногда для целой пачки сценариев требуется указать набор предусловий. Копипастить их в каждый сценарий очевидно не удобно. На помощь приходят два подхода.
Background
Background:
Given Calculator is initialized
@positive
Scenario: Add two numbers
Given I have entered 1 into the calculator
When I press sin
Then the result should be 0.841470984807896 on the screen
В секцию Background можно вынести все большие предусловия для сценариев.
Использование тегов
Предусловия можно реализовать еще и с помощью тегов:
[Binding]
public class Sum : CalcStepsBase
{
public Sum(CalculationContext context)
: base(context)
{
}
[BeforeScenario("calculator")]
[Given("Calculator is initialized")]
public void InitCalculator()
{
Context.Init();
}
}
Метод, помеченный атрибутом BeforeScenario будет выполняться перед запуском сценария. В конструктор передаются атрибуты для ограничения области видимости. Мы помечали сценарии тегом calculator. Теперь перед каждым запуском такого сценария будет выполняться метод InitCalculator.
Отчеты
Для того, чтобы построить отчет, потребуется утилита specflow.exe и nunit. Ниже msbuild-скрипт, который сначала запускает nunit, а затем строит specflow-отчет.
<?xml version="1.0" encoding="utf-8" ?>
<Project ToolsVersion="4.0" DefaultTarget="Compile" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<NUnitAddinFiles Include="$(teamcity_dotnet_nunitaddin)-2.6.2.*" />
</ItemGroup>
<PropertyGroup>
<teamcity_build_checkoutDir Condition=" '$(teamcity_build_checkoutDir)' == '' ">.</teamcity_build_checkoutDir>
<NUnitHome>C:/Program Files (x86)/NUnit 2.6.2</NUnitHome>
<NUnitConsole>"$(NUnitHome)binnunit-console.exe"</NUnitConsole>
<testResultsTxt>"$(teamcity_build_checkoutDir)TestResult.txt"</testResultsTxt>
<testResultsXml>"$(teamcity_build_checkoutDir)TestResult.xml"</testResultsXml>
<projectFile>"$(teamcity_build_checkoutDir)Etna.QA.SpecFlow.ExamplesEtna.QA.SpecFlow.Examples.csproj"</projectFile>
<SpecflowExe>"C:Program Files (x86)TechTalkSpecFlowspecflow.exe"</SpecflowExe>
</PropertyGroup>
<Target Name="RunTests">
<MakeDir Directories="$(NUnitHome)/bin/addins" />
<Copy SourceFiles="@(NUnitAddinFiles)" DestinationFolder="$(NUnitHome)/bin/addins" />
<Exec Command="$(NUnitConsole) /domain:multiple /labels /out=$(testResultsTxt) /xml=$(testResultsXml) $(projectFile)" ContinueOnError="true"/>
</Target>
<Target Name="SpecflowReports">
<Exec Command="$(SpecflowExe) nunitexecutionreport $(projectFile) /xmlTestResult:$(testResultsXml) /testOutput:$(testResultsTxt) /out:"$(teamcity_build_checkoutDir)/SpecFlowExecutionReport.html""/>
<Exec Command="$(SpecflowExe) stepdefinitionreport $(projectFile) /out:"$(teamcity_build_checkoutDir)/SpecFlowStepDefinitionReport.html""/>
</Target>
</Project>
Стоит обратить внимание на флаг /domain:multiple. Он указывает NUnit на то, что нужно запускать сборку из папки, в которой она находится. В противном случае могут возникнуть проблемы с конфигами.
В итоге мы получим вот такой отчет
Запуск по расписанию в Team City
В настройке билда нужно указать новые артефакт: наш отчет о выполнении:
Вместо шага с запуском NUnit мы будем использовать msbuild-скрипт, который написали ранее:
В Team City появится новая вкладка с отчетом. У нас она выглядит так:
Фиолетовым показаны не автоматизированные сценарии, зеленым — успешно, пройденные, красным — тесты с ошибками.
Все, осталось только поставить триггер на запуск тестов по расписанию.
Синхронизация с таск-трекером
TechTalk предлагает свой коммерческий продукт SpecLog для управления требованиями и связи с таск-трекерами. Нам он не подошел по ряду причин. Сейчас я работаю над прозрачной связью тестовых сценариев SpecFlow с тест-кейсами в TFS. С Update 2 в TFS появились теги. Интересной кажется идея использовать соглашения в аннотация для связи с таск-трекером, например: @b8924, tc345. Как только появится рабочее решение, напишу об этом.
Автор: marshinov