diff --git a/docs/configuration.md b/docs/configuration.md index 2578471..8ab23d4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -128,6 +128,52 @@ Customize how step titles are generated from expressions: Configurator.StepTitleFactory = new MyStepTitleFactory(); ``` +### IncludeInputsInStepTitle + +Controls whether step arguments are appended to step titles (default: `true`): + +```csharp +Configurator.StepTitleFactory.IncludeInputsInStepTitle = false; +``` + +### AddGherkinPrefixToSecondarySteps + +When a step has an explicit title (via `[Given("...")]`, `[When("...")]`, `[Then("...")]`, or `[StepTitle("...")]`), BDDfy prepends the Gherkin keyword to the title by default. For consecutive steps in the same group, this means they get an "And" prefix. Set this to `false` to disable the "And" prefix on consecutive custom-titled steps: + +```csharp +Configurator.StepTitleFactory.AddGherkinPrefixToSecondarySteps = true; // default +``` + +**With prefix enabled (default):** + +``` +Scenario: With prefix enabled + Given the user is logged in + And the cart has items + And the payment gateway is available + When the user checks out + Then the order is confirmed + And a confirmation email is sent +``` + +```csharp +Configurator.StepTitleFactory.AddGherkinPrefixToSecondarySteps = false; +``` + +**With prefix disabled:** + +``` +Scenario: With prefix disabled + Given the user is logged in + the cart has items + the payment gateway is available + When the user checks out + Then the order is confirmed + a confirmation email is sent +``` + +> **Note:** Primary step keywords (Given/When/Then) are always applied regardless of this setting. Only the consecutive "And" prefix on custom-titled steps is affected. + ## Culture Set the culture used for formatting values in reports: diff --git a/docs/reflective-api.md b/docs/reflective-api.md index 0c0a229..fb5f70e 100644 --- a/docs/reflective-api.md +++ b/docs/reflective-api.md @@ -116,13 +116,47 @@ For more control over step text and ordering, use the executable attributes inst Pass a string to the attribute to override the humanized method name: ```csharp -[Given("Given the account balance is $10")] +[Given("the account balance is $10")] void GivenAccountHasEnoughBalance() { _card = new Card(true, 10); } ``` +By default, the Gherkin keyword (Given/When/Then/And/But) is automatically prepended to the custom title: + +```csharp +[When("the user checks out")] +void WhenTheUserClicksCheckout() { } +// Reports as: "When the user checks out" +``` + +This behavior can be disabled for consecutive steps via configuration: + +```csharp +Configurator.StepTitleFactory.AddGherkinPrefixToSecondarySteps = false; +// Consecutive custom-titled steps no longer get "And" prepended +// Primary steps (Given/When/Then) always keep their keyword +``` + +When both `[StepTitle]` and an executable attribute (e.g. `[When]`) are present, the executable attribute's text takes priority: + +```csharp +[StepTitle("the user completes checkout")] +[When("the user checks out")] +void WhenTheUserClicksCheckout() { } +// Reports as: "When the user checks out" (When attribute wins) +``` + +If the executable attribute has no explicit text, `[StepTitle]` is used as fallback: + +```csharp +[StepTitle("the payment gateway is available")] +[Given] +void CheckPaymentGateway() { } +// Reports as: "Given the payment gateway is available" +``` + ### Controlling Order Use the `Order` property when you need explicit ordering: diff --git a/src/TestStack.BDDfy.Tests/GlobalSuppressions.cs b/src/TestStack.BDDfy.Tests/GlobalSuppressions.cs index e40ab68..4cf5e65 100644 --- a/src/TestStack.BDDfy.Tests/GlobalSuppressions.cs +++ b/src/TestStack.BDDfy.Tests/GlobalSuppressions.cs @@ -6,4 +6,4 @@ using System.Diagnostics.CodeAnalysis; [assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Test step methods cannot be static", Scope = "namespaceanddescendants", Target = "~N:TestStack.BDDfy.Tests")] - \ No newline at end of file +[assembly: SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "parameters serve as placeholders for step arguments", Scope = "namespaceanddescendants", Target = "~N:TestStack.BDDfy.Tests")] diff --git a/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/ScenarioToBeScannedUsingFluentScanner.cs b/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/ScenarioToBeScannedUsingFluentScanner.cs index 4cc0d52..9de2e51 100644 --- a/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/ScenarioToBeScannedUsingFluentScanner.cs +++ b/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/ScenarioToBeScannedUsingFluentScanner.cs @@ -6,7 +6,7 @@ namespace TestStack.BDDfy.Tests.Scanner.FluentScanner [Story] class ScenarioToBeScannedUsingFluentScanner { - internal const string InputDateStepTitleTemplate = "The provided date is {0:MMM d yyyy}"; + internal const string InputDateStepTitleTemplate = "And the provided date is {0:MMM d yyyy}"; public static readonly DateTime InputDate = DateTime.Parse("2011-10-20", new CultureInfo("en-AU")); private string[] _input1 = null!; diff --git a/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/StepTitleTests.cs b/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/StepTitleTests.cs index be4b26d..66f0cb0 100644 --- a/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/StepTitleTests.cs +++ b/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/StepTitleTests.cs @@ -21,7 +21,7 @@ public void UseConfiguration_IncludeInputsInStepTitle() .Given(_=>something.Sub.GivenWithStepTitleAndArgument(1)) .When(_ => something.Sub.ActionWithArgument("foo")) .And(_ => something.Sub.ActionWithArgumentsDisabledInTitle("foo")) - .And(_ => something.Sub.ActionWithTemplateTitleAndArguments("foo")) + .And(_ => something.Sub.ActionWithTemplateTitleAndArgumentsDisabled("foo")) .And(_ => something.Sub.ActionWithArgumentsEnabledInTitle("foo")) .BDDfy(); @@ -59,7 +59,10 @@ public void MethodCallInStepTitle() .Then(_ => ThenTitleHas(AMethodCall())) .And(_ => something.Sub.ActionWithArgument("foo")) .And(_ => something.Sub.ActionWithArgumentsDisabledInTitle("foo")) - .And(_ => something.Sub.ActionWithTemplateTitleAndArguments("foo")) + .And(_ => something.Sub.ActionWithArgumentsEnabledInTitle("foo")) + .And(_ => something.Sub.ActionWithTemplateTitleAndArgumentsDisabled("foo")) + .And(_ => something.Sub.ActionWithTemplateTitleAndArgumentsEnabled("foo")) + .And(_ => something.Sub.ActionWithStepTitleWithArgument("foo")) .BDDfy(); var actualTitles = story.Scenarios.Single().Steps.Select(s => s.Title).ToArray(); @@ -71,6 +74,9 @@ public void MethodCallInStepTitle() "Then title has Mutated state", "And with arg foo", "And with arg", + "And with arg foo", + "And with foo arg", + "And with foo arg foo", "And with foo arg" }; @@ -115,7 +121,18 @@ public void ActionWithArgumentsEnabledInTitle(string arg) } [StepTitle("With {0} arg", false)] - public void ActionWithTemplateTitleAndArguments(string arg) + public void ActionWithTemplateTitleAndArgumentsDisabled(string arg) + { + + } + + [StepTitle("With {0} arg", true)] + public void ActionWithTemplateTitleAndArgumentsEnabled(string arg) + { + } + + [StepTitle("With {0} arg")] + public void ActionWithStepTitleWithArgument(string value) { } diff --git a/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/StepsWithChainedMethods.StepNameWillBeBuiltFromStepTitle.approved.txt b/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/StepsWithChainedMethods.StepNameWillBeBuiltFromStepTitle.approved.txt index 4d53368..b190706 100644 --- a/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/StepsWithChainedMethods.StepNameWillBeBuiltFromStepTitle.approved.txt +++ b/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/StepsWithChainedMethods.StepNameWillBeBuiltFromStepTitle.approved.txt @@ -1,6 +1,6 @@  ### Step name will be built from step title - i have a red car with a crazy horn + Given i have a red car with a crazy horn When i honk the horn at volume high Then i verify diagnostics a red car honked crazy horn at high volume diff --git a/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/UsingCustomStepTitleFactory.cs b/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/UsingCustomStepTitleFactory.cs index 85d4098..a543f1d 100644 --- a/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/UsingCustomStepTitleFactory.cs +++ b/src/TestStack.BDDfy.Tests/Scanner/FluentScanner/UsingCustomStepTitleFactory.cs @@ -13,6 +13,7 @@ public class UsingCustomStepTitleFactory private class CustomStepTitleFactory : IStepTitleFactory { public bool IncludeInputsInStepTitle { get; set; } = true; + public bool AddGherkinPrefixToSecondarySteps { get; set; } = true; public StepTitle Create( string? stepTextTemplate, diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.DoesNotDoublePrefixWhenTitleAlreadyStartsWithKeyword.approved.txt b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.DoesNotDoublePrefixWhenTitleAlreadyStartsWithKeyword.approved.txt new file mode 100644 index 0000000..1a47224 --- /dev/null +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.DoesNotDoublePrefixWhenTitleAlreadyStartsWithKeyword.approved.txt @@ -0,0 +1,8 @@ + +Scenario: Does not double prefix when title already starts with keyword + Given the account is active + And the balance is 100 + When the user withdraws 50 + Then the balance is 50 + And a receipt is printed + diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.WithPrefixDisabled.approved.txt b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.WithPrefixDisabled.approved.txt new file mode 100644 index 0000000..e00f56d --- /dev/null +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.WithPrefixDisabled.approved.txt @@ -0,0 +1,9 @@ + +Scenario: With prefix disabled + Given the user is logged in + the cart has items + the payment gateway is available + When the user checks out + Then the order is confirmed + a confirmation email is sent + diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.WithPrefixEnabled.approved.txt b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.WithPrefixEnabled.approved.txt new file mode 100644 index 0000000..792623d --- /dev/null +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.WithPrefixEnabled.approved.txt @@ -0,0 +1,9 @@ + +Scenario: With prefix enabled + Given the user is logged in + And the cart has items + And the payment gateway is available + When the user checks out + Then the order is confirmed + And a confirmation email is sent + diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.cs b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.cs new file mode 100644 index 0000000..00795f1 --- /dev/null +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/AddGherkinPrefixToCustomStepTitleTests.cs @@ -0,0 +1,102 @@ +using Shouldly; +using TestStack.BDDfy.Configuration; +using TestStack.BDDfy.Reporters; +using TestStack.BDDfy.Tests.Concurrency; +using Xunit; + +namespace TestStack.BDDfy.Tests.Scanner.ReflectiveScanner +{ + [Collection(TestCollectionName.ModifiesConfigurator)] + public class AddGherkinPrefixToCustomStepTitleTests + { + private class ScenarioWithCustomTitles + { + public void GivenTheUserIsLoggedIn() { } + + [Given("the cart has items")] + public void PopulateCart() { } + + [StepTitle("the payment gateway is available")] + [Given] + public void CheckGateway() { } + + [StepTitle("the user completes checkout")] + [When("the user checks out")] + public void WhenTheUserClicksCheckout() { } + + public void ThenTheOrderIsConfirmed() { } + + [Then("a confirmation email is sent")] + public void SendEmail() { } + } + + private class ScenarioWithPrefixAlreadyInTitle + { + [Given("Given the account is active")] + public void SetupAccount() { } + + [Given("Given the balance is 100")] + public void SetupBalance() { } + + [When("When the user withdraws 50")] + public void PerformWithdrawal() { } + + [Then("Then the balance is 50")] + public void CheckBalance() { } + + [Then("Then a receipt is printed")] + public void CheckReceipt() { } + } + + [Fact] + public void WithPrefixEnabled() + { + try + { + Configurator.StepTitleFactory.AddGherkinPrefixToSecondarySteps = true; + + var scenario = new ScenarioWithCustomTitles(); + var story = scenario.BDDfy(); + + var reporter = new TextReporter(); + reporter.Process(story); + reporter.ToString().ShouldMatchApproved(); + } + finally + { + Configurator.StepTitleFactory.AddGherkinPrefixToSecondarySteps = true; + } + } + + [Fact] + public void WithPrefixDisabled() + { + try + { + Configurator.StepTitleFactory.AddGherkinPrefixToSecondarySteps = false; + + var scenario = new ScenarioWithCustomTitles(); + var story = scenario.BDDfy(); + + var reporter = new TextReporter(); + reporter.Process(story); + reporter.ToString().ShouldMatchApproved(); + } + finally + { + Configurator.StepTitleFactory.AddGherkinPrefixToSecondarySteps = true; + } + } + + [Fact] + public void DoesNotDoublePrefixWhenTitleAlreadyStartsWithKeyword() + { + var scenario = new ScenarioWithPrefixAlreadyInTitle(); + var story = scenario.BDDfy(); + + var reporter = new TextReporter(); + reporter.Process(story); + reporter.ToString().ShouldMatchApproved(); + } + } +} diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ExecutableAttributeOrderOrdersTheStepsCorrectly.cs b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ExecutableAttributeOrderOrdersTheStepsCorrectly.cs index d4f6f70..f2af999 100644 --- a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ExecutableAttributeOrderOrdersTheStepsCorrectly.cs +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ExecutableAttributeOrderOrdersTheStepsCorrectly.cs @@ -4,191 +4,98 @@ namespace TestStack.BDDfy.Tests.Scanner.ReflectiveScanner { - public class ExecutableAttributeOrderOrdersTheStepsCorrectly + public class ExecutableAttributeShould { - private readonly List _steps; - private class TypeWithOrderedAttribute { [AndThen(Order = 2)] - public void AndThen2() { } + public void AndThenOutcome05() { } [AndThen(Order = -3)] - public void AndThenNeg3() { } + public void AndThenOutcome03() { } [AndThen] - public void AndThen0() { } + public void AndThenOutcome04() { } [AndThen(Order = 2)] - public void AndThen2Again() { } + public void AndThenOutcome06() { } [Then(Order = 1)] - public void Then1() { } + public void ThenOutcome01() { } [Then(Order = 3)] - public void Then3() { } + public void ThenOutcome02() { } [Given(Order = 1)] - public void Given1() { } + public void GivenState01() { } [Given(Order = 3)] - public void Given3() { } + public void GivenState02() { } [AndGiven] - public void AndGiven0() { } + public void AndGivenState04() { } [AndGiven(Order = 2)] - public void AndGiven2() { } + public void AndGivenState05() { } [AndGiven(Order = -3)] - public void AndGivenNeg3() { } + public void AndGivenState03() { } [AndGiven(Order = 2)] - public void AndGiven2Again() { } + public void AndGivenState06() { } [When(Order = 1)] - public void When1() { } + public void WhenAction01() { } [When(Order = 3)] - public void When3() { } + public void WhenAction02() { } [AndWhen(Order = 2)] - public void AndWhen2() { } + public void AndWhenAction05() { } [AndWhen(Order = -3)] - public void AndWhenNeg3() { } + public void AndWhenAction03() { } [AndWhen] - public void AndWhen0() { } + public void AndWhenAction04() { } [AndWhen(Order = 2)] - public void AndWhen2Again() { } + public void AndWhenAction06() { } } - public ExecutableAttributeOrderOrdersTheStepsCorrectly() + [Fact] + public void OrderTheStepsCorrectly() { var testObject = new TypeWithOrderedAttribute(); var stepScanners = Configurator.Scanners.GetStepScanners(testObject).ToArray(); var scanner = new ReflectiveScenarioScanner(stepScanners); var scenario = scanner.Scan(TestContext.GetContext(testObject)).First(); - _steps = scenario.Steps; - } - - [Fact] - public void Step0IsGiven1() - { - _steps[0].Title.ShouldBe("Given 1"); - } - - [Fact] - public void Step1IsGiven3() - { - _steps[1].Title.ShouldBe("Given 3"); - } - - - [Fact] - public void Step2IsAndGivenNeg3() - { - _steps[2].Title.ShouldBe("And given neg 3"); - } - - [Fact] - public void Step3IsAndGiven0() - { - _steps[3].Title.ShouldBe("And given 0"); - } - - [Fact] - public void Step4AndGiven2() - { - _steps[4].Title.ShouldBe("And given 2"); - } - - [Fact] - public void Step5AndGiven2Again() - { - _steps[5].Title.ShouldBe("And given 2 again"); - } - - - [Fact] - public void Step6IsWhen1() - { - _steps[6].Title.ShouldBe("When 1"); - } - - [Fact] - public void Step7IsWhen3() - { - _steps[7].Title.ShouldBe("When 3"); - } - - - [Fact] - public void Step8IsAndWhenNeg3() - { - _steps[8].Title.ShouldBe("And when neg 3"); - } - - [Fact] - public void Step9IsAndWhen0() - { - _steps[9].Title.ShouldBe("And when 0"); - } - - - [Fact] - public void Step10AndWhen2() - { - _steps[10].Title.ShouldBe("And when 2"); - } - - [Fact] - public void Step11AndWhen2Again() - { - _steps[11].Title.ShouldBe("And when 2 again"); - } - - - [Fact] - public void Step12IsThen1() - { - _steps[12].Title.ShouldBe("Then 1"); - } - - [Fact] - public void Step13IsThen3() - { - _steps[13].Title.ShouldBe("Then 3"); - } - - - [Fact] - public void Step14IsAndThenNeg3() - { - _steps[14].Title.ShouldBe("And then neg 3"); - } - - [Fact] - public void Step15IsAndThen0() - { - _steps[15].Title.ShouldBe("And then 0"); - } - - - [Fact] - public void Step16AndThen2() - { - _steps[16].Title.ShouldBe("And then 2"); - } - - [Fact] - public void Step17AndThen2Again() - { - _steps[17].Title.ShouldBe("And then 2 again"); + var actualTitles = scenario.Steps.Select(s => s.Title).ToArray(); + var expectedTitles = new[] + { + "Given state 01", + "And state 02", + "And state 03", + "And state 04", + "And state 05", + "And state 06", + "When action 01", + "And action 02", + "And action 03", + "And action 04", + "And action 05", + "And action 06", + "Then outcome 01", + "And outcome 02", + "And outcome 03", + "And outcome 04", + "And outcome 05", + "And outcome 06" + }; + + actualTitles.ShouldBe(expectedTitles); } } } diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ExecutableAttributeScannerTests.cs b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ExecutableAttributeScannerTests.cs index 97ee691..266159f 100644 --- a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ExecutableAttributeScannerTests.cs +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ExecutableAttributeScannerTests.cs @@ -62,13 +62,13 @@ static void VerifyMethod(string expectedStepTitle, bool exists = true) [Fact] public void TheMethodWithPascalCaseIsSeparatedAndTurnedIntoLowerCaseExceptTheFirstWord() { - VerifyMethod("The pascal case for method name"); + VerifyMethod("Given the pascal case for method name"); } [Fact] public void TheMethodWithUnderscoreAndLowerCaseWordsIsSeparatedAndCaseIsRetained() { - VerifyMethod("with lower case underscored method name"); + VerifyMethod("Then with lower case underscored method name"); } [Fact] @@ -86,8 +86,8 @@ public void TheMethodWithArrayArgumentWithProvidedTextUsesArrayToFormatTheTextTe [Fact] public void TheMethodWithArgumentWithoutProvidedTextGetsArgumentsAppendedToTheMethodName() { - VerifyMethod("Step is run with arguments without provided text 1, 2, 3"); - VerifyMethod("Step is run with arguments without provided text 3, 4, 5"); + VerifyMethod("Given step is run with arguments without provided text 1, 2, 3"); + VerifyMethod("Given step is run with arguments without provided text 3, 4, 5"); } [Fact] @@ -107,13 +107,13 @@ public void TheMethodWithArgumentWithProvidedTextDoesNotUseTheMethodName() [Fact] public void TheMethodWithArgumentWithTextProvidedOnTheExecutableAttributeUsesExecutableAttributeTemplate() { - VerifyMethod("Running step with arg 1, 2 and 3 using exec attribute template"); + VerifyMethod("When running step with arg 1, 2 and 3 using exec attribute template"); } [Fact] public void RunStepWithArgsTemplateOverrideAllOtherTemplates() { - VerifyMethod("Running step with arg 1, 2 and 3 when template is provided by exec attribute and RunStepWithArgs attribute"); + VerifyMethod("When running step with arg 1, 2 and 3 when template is provided by exec attribute and RunStepWithArgs attribute"); VerifyMethod("Running step with args using exec attribute template and run step with args template 1, 2, 3", false); VerifyMethod("The template provided on RunStepWithArgs overrides all the others 4, 5, 6"); } @@ -121,13 +121,13 @@ public void RunStepWithArgsTemplateOverrideAllOtherTemplates() [Fact] public void TheMethodWithArgumentWithTextProvidedOnTheExecutableAttributeConstructorUsesExecutableAttributeTemplate() { - VerifyMethod("Running step with arg 1, 2 and 3 using exec attribute(string stepTitle)"); + VerifyMethod("When running step with arg 1, 2 and 3 using exec attribute(string stepTitle)"); } [Fact] public void RunStepWithArgsTemplateOverrideAllOtherTemplatesThatUseTheConstructor() { - VerifyMethod("Running step with arg 1, 2 and 3 when template is provided by exec attribute(string stepTitle) and RunStepWithArgs attribute"); + VerifyMethod("When running step with arg 1, 2 and 3 when template is provided by exec attribute(string stepTitle) and RunStepWithArgs attribute"); VerifyMethod("Running step with args using exec attribute template and run step with args template 1, 2, 3", false); VerifyMethod("The template provided on RunStepWithArgs overrides all the others 4, 5, 6 attribute(string stepTitle)"); } diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariations.approved.txt b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariations.approved.txt new file mode 100644 index 0000000..0241801 --- /dev/null +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariations.approved.txt @@ -0,0 +1,17 @@ + +Scenario: All variations + Given the user is logged in + And the shopping cart contains 3 items + And the discount code SAVE20 is applied + And the payment gateway is available + And the shipping address is set to London + And the order total is calculated + And the delivery fee is 4.99 + When the user checks out + And the payment is processed + Then the order is confirmed + And verify order totals 100, 200 + And verify order totals 300, 400 + And the inventory is reduced by 1 of 2 items + And a confirmation email is sent + diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariationsCombined.approved.txt b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariationsCombined.approved.txt new file mode 100644 index 0000000..d9304d8 --- /dev/null +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariationsCombined.approved.txt @@ -0,0 +1,15 @@ + +Scenario: All variations combined + Given the system is in maintenance mode + And the database connection pool is initialized + And the cache has 50 entries preloaded + And the retry policy allows 3 attempts + When the request is sent to /api/orders /api/orders + And the timeout is set to 5000ms + And the health check endpoint is called + Then the service returns a 503 status + And the response includes a retry-after header + And verify response headers cache-control, no-store + And the response body contains error: Service Unavailable + And the response body contains retryAfter: 300 + diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariationsCombinedWithIncludeInputsDisabled.approved.txt b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariationsCombinedWithIncludeInputsDisabled.approved.txt new file mode 100644 index 0000000..7a27335 --- /dev/null +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariationsCombinedWithIncludeInputsDisabled.approved.txt @@ -0,0 +1,15 @@ + +Scenario: All variations combined with include inputs disabled + Given the system is in maintenance mode + And the database connection pool is initialized + And the cache has 50 entries preloaded + And the retry policy allows 3 attempts + When the request is sent to /api/orders /api/orders + And the timeout is set to 5000ms + And the health check endpoint is called + Then the service returns a 503 status + And the response includes a retry-after header + And verify response headers + And the response body contains error: Service Unavailable + And the response body contains retryAfter: 300 + diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariationsWithIncludeInputsDisabled.approved.txt b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariationsWithIncludeInputsDisabled.approved.txt new file mode 100644 index 0000000..bb09be3 --- /dev/null +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.AllVariationsWithIncludeInputsDisabled.approved.txt @@ -0,0 +1,17 @@ + +Scenario: All variations with include inputs disabled + Given the user is logged in + And the shopping cart contains 3 items + And the discount code SAVE20 is applied + And the payment gateway is available + And the shipping address is set to London + And the order total is calculated + And the delivery fee is 4.99 + When the user checks out + And the payment is processed + Then the order is confirmed + And verify order totals + And verify order totals + And the inventory is reduced by 1 of 2 items + And a confirmation email is sent + diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.cs b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.cs new file mode 100644 index 0000000..16897ad --- /dev/null +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/ReflectiveStepTitleTests.cs @@ -0,0 +1,168 @@ +using Shouldly; +using TestStack.BDDfy.Configuration; +using TestStack.BDDfy.Reporters; +using TestStack.BDDfy.Tests.Concurrency; +using Xunit; + +namespace TestStack.BDDfy.Tests.Scanner.ReflectiveScanner +{ + [Collection(TestCollectionName.ModifiesConfigurator)] + public class ReflectiveStepTitleTests + { + private class ScenarioWithVariousStepTitleVariations + { + public void GivenTheUserIsLoggedIn() { } + + [Given(StepTitle = "the shopping cart contains 3 items")] + public void PopulateShoppingCart() { } + + [Given("the discount code {0} is applied")] + [RunStepWithArgs("SAVE20")] + public void ApplyDiscountCode(string code) { } + + [StepTitle("the payment gateway is available")] + [Given] + public void CheckPaymentGateway() { } + + [StepTitle("the shipping address is set to {0}")] + [Given] + [RunStepWithArgs("London")] + public void ConfigureShippingAddress(string city) { } + + [StepTitle("the order total is calculated", false)] + [Given] + [RunStepWithArgs(99.99)] + public void CalculateOrderTotal(double amount) { } + + [StepTitle("the delivery fee is", true)] + [Given] + [RunStepWithArgs(4.99)] + public void CalculateDeliveryFee(double fee) { } + + [StepTitle("the user completes checkout")] + [When("the user checks out")] + public void WhenTheUserClicksCheckout() { } + + public void AndWhenThePaymentIsProcessed() { } + + public void ThenTheOrderIsConfirmed() { } + + public void AndThenAConfirmationEmailIsSent() { } + + [Then] + [RunStepWithArgs(100, 200)] + [RunStepWithArgs(300, 400)] + public void VerifyOrderTotals(int subtotal, int total) { } + + [Then] + [RunStepWithArgs(1, 2, StepTextTemplate = "the inventory is reduced by {0} of {1} items")] + public void UpdateInventory(int reduced, int total) { } + } + + [Fact] + public void AllVariations() + { + var scenario = new ScenarioWithVariousStepTitleVariations(); + var story = scenario.BDDfy(); + + var reporter = new TextReporter(); + reporter.Process(story); + reporter.ToString().ShouldMatchApproved(); + } + + [Fact] + public void AllVariationsWithIncludeInputsDisabled() + { + try + { + Configurator.StepTitleFactory.IncludeInputsInStepTitle = false; + + var scenario = new ScenarioWithVariousStepTitleVariations(); + var story = scenario.BDDfy(); + + var reporter = new TextReporter(); + reporter.Process(story); + reporter.ToString().ShouldMatchApproved(); + } + finally + { + Configurator.StepTitleFactory.IncludeInputsInStepTitle = true; + } + } + + private class ScenarioWithAllVariationsCombined + { + public void GivenTheSystemIsInMaintenanceMode() { } + + [StepTitle("the database connection pool is initialized")] + [Given] + public void InitializeConnectionPool() { } + + [Given("the cache has {0} entries preloaded")] + [RunStepWithArgs(50)] + public void PreloadCache(int count) { } + + [Given(StepTitle = "the retry policy allows {0} attempts")] + [RunStepWithArgs(3)] + public void ConfigureRetryPolicy(int maxRetries) { } + + [StepTitle("the request is sent to {0}", true)] + [When] + [RunStepWithArgs("/api/orders")] + public void SendRequest(string endpoint) { } + + [StepTitle("the timeout is set to {0}ms", false)] + [When] + [RunStepWithArgs(5000)] + public void ConfigureTimeout(int milliseconds) { } + + public void WhenTheHealthCheckEndpointIsCalled() { } + + public void ThenTheServiceReturnsA503Status() { } + + [StepTitle("the response includes a retry-after header")] + [Then] + public void ValidateRetryAfterHeader() { } + + [Then] + [RunStepWithArgs("cache-control", "no-store")] + public void VerifyResponseHeaders(string header, string value) { } + + [Then] + [RunStepWithArgs("error", "Service Unavailable", StepTextTemplate = "the response body contains {0}: {1}")] + [RunStepWithArgs("retryAfter", "300", StepTextTemplate = "the response body contains {0}: {1}")] + public void VerifyResponseBody(string key, string value) { } + } + + [Fact] + public void AllVariationsCombined() + { + var scenario = new ScenarioWithAllVariationsCombined(); + var story = scenario.BDDfy(); + + var reporter = new TextReporter(); + reporter.Process(story); + reporter.ToString().ShouldMatchApproved(); + } + + [Fact] + public void AllVariationsCombinedWithIncludeInputsDisabled() + { + try + { + Configurator.StepTitleFactory.IncludeInputsInStepTitle = false; + + var scenario = new ScenarioWithAllVariationsCombined(); + var story = scenario.BDDfy(); + + var reporter = new TextReporter(); + reporter.Process(story); + reporter.ToString().ShouldMatchApproved(); + } + finally + { + Configurator.StepTitleFactory.IncludeInputsInStepTitle = true; + } + } + } +} diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenCombinationOfExecutableAttributeAndMethodNamingConventionIsUsed.cs b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenCombinationOfExecutableAttributeAndMethodNamingConventionIsUsed.cs index 985e0a1..9f3672e 100644 --- a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenCombinationOfExecutableAttributeAndMethodNamingConventionIsUsed.cs +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenCombinationOfExecutableAttributeAndMethodNamingConventionIsUsed.cs @@ -81,7 +81,7 @@ public void GivenStepIsScanned() [Fact] public void ExecutableAttributesHaveHigherPriorityThanNamingConventions() { - VerifyStepAndItsProperties(() => _sut.ThenThisMethodIsFoundAsAGivenStepNotThenStep(), ExecutionOrder.ConsecutiveSetupState); + VerifyStepAndItsProperties(() => _sut.ThenThisMethodIsFoundAsAGivenStepNotThenStep(), ExecutionOrder.ConsecutiveSetupState, expectedTitle: "And this method is found as a given step not then step"); } [Fact] @@ -93,7 +93,7 @@ public void WhenStepIsScanned() [Fact] public void LegacyTransitionStepIsScanned() { - VerifyStepAndItsProperties(() => _sut.LegacyTransitionMethod(), ExecutionOrder.ConsecutiveTransition); + VerifyStepAndItsProperties(() => _sut.LegacyTransitionMethod(), ExecutionOrder.ConsecutiveTransition, expectedTitle: "And legacy transition method"); } [Fact] @@ -111,18 +111,19 @@ public void AndThenStepIsScanned() [Fact] public void LegacyAssertionStepIsScanned() { - VerifyStepAndItsProperties(() => _sut.TestThatSomethingIsRight(), ExecutionOrder.Assertion); + VerifyStepAndItsProperties(() => _sut.TestThatSomethingIsRight(), ExecutionOrder.ConsecutiveAssertion, expectedTitle: "And test that something is right"); } [Fact] public void LegacyConsecutiveAssertionStepIsScanned() { - VerifyStepAndItsProperties(() => _sut.TestThatSomethingIsWrong(), ExecutionOrder.ConsecutiveAssertion); + VerifyStepAndItsProperties(() => _sut.TestThatSomethingIsWrong(), ExecutionOrder.ConsecutiveAssertion, expectedTitle: "And test that something is wrong"); } - void VerifyStepAndItsProperties(Expression stepMethodAction, ExecutionOrder expectedOrder, int expectedCount = 1) + void VerifyStepAndItsProperties(Expression stepMethodAction, ExecutionOrder expectedOrder, int expectedCount = 1, string? expectedTitle = null) { - var matchingSteps = _scenario.Steps.Where(s => s.Title == Configurator.Humanizer.Humanize(Helpers.GetMethodInfo(stepMethodAction).Name)); + var title = expectedTitle ?? Configurator.Humanizer.Humanize(Helpers.GetMethodInfo(stepMethodAction).Name); + var matchingSteps = _scenario.Steps.Where(s => s.Title == title); matchingSteps.Count().ShouldBe(expectedCount); matchingSteps.All(s => s.ExecutionOrder == expectedOrder).ShouldBe(true); } diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenStepsAreDefinedInABaseClass.cs b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenStepsAreDefinedInABaseClass.cs index 0cd4748..1fb8506 100644 --- a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenStepsAreDefinedInABaseClass.cs +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenStepsAreDefinedInABaseClass.cs @@ -35,20 +35,20 @@ Scenario Scenario } } - [RunStepWithArgs("GivenInTheBaseClass")] - [RunStepWithArgs("WhenInTheBaseClass")] - [RunStepWithArgs("ThenInTheBaseClass")] - void ThenTheFollowingStepFromBaseClassIsScanned(string stepName) + [RunStepWithArgs("GivenInTheBaseClass", "Given in the base class", "And in the base class")] + [RunStepWithArgs("WhenInTheBaseClass", "When in the base class", "And in the base class")] + [RunStepWithArgs("ThenInTheBaseClass", "Then in the base class", "And in the base class")] + void ThenTheFollowingStepFromBaseClassIsScanned(string stepName, string title, string promotedTitle) { - Scenario.Steps.Count(s => s.Title == Configurator.Humanizer.Humanize(stepName)).ShouldBe(1); + Scenario.Steps.Any(s => s.Title == title || s.Title == promotedTitle).ShouldBe(true); } - [RunStepWithArgs("GivenInTheSubClass")] - [RunStepWithArgs("WhenInTheSubClass")] - [RunStepWithArgs("ThenInTheSubClass")] - void ThenTheFollowingStepFromSubClassScanned(string stepName) + [RunStepWithArgs("GivenInTheSubClass", "Given in the sub class", "And in the sub class")] + [RunStepWithArgs("WhenInTheSubClass", "When in the sub class", "And in the sub class")] + [RunStepWithArgs("ThenInTheSubClass", "Then in the sub class", "And in the sub class")] + void ThenTheFollowingStepFromSubClassScanned(string stepName, string title, string promotedTitle) { - Scenario.Steps.Count(s => s.Title == Configurator.Humanizer.Humanize(stepName)).ShouldBe(1); + Scenario.Steps.Any(s => s.Title == title || s.Title == promotedTitle).ShouldBe(true); } [Fact] diff --git a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenTestClassUsesExecutableAttributes.cs b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenTestClassUsesExecutableAttributes.cs index 070d05b..5038515 100644 --- a/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenTestClassUsesExecutableAttributes.cs +++ b/src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenTestClassUsesExecutableAttributes.cs @@ -85,19 +85,17 @@ public void Given() [Fact] public void AndGiven() { - var step = _steps.Single(s => s.Title == "Some other part of the given"); + var step = _steps.Single(s => s.Title == "And some other part of the given"); step.ExecutionOrder.ShouldBe(ExecutionOrder.ConsecutiveSetupState); step.Asserts.ShouldBe(false); - step.Title.ShouldBe(GetStepTextFromMethodName(() => _typeWithAttribute.SomeOtherPartOfTheGiven())); } [Fact] public void ButGiven() { - var step = _steps.Single(s => s.Title == "Setup should avoid somethings"); + var step = _steps.Single(s => s.Title == "But setup should avoid somethings"); step.ExecutionOrder.ShouldBe(ExecutionOrder.ConsecutiveSetupState); step.Asserts.ShouldBe(false); - step.Title.ShouldBe(GetStepTextFromMethodName(() => _typeWithAttribute.SetupShouldAvoidSomethings())); } [Fact] @@ -112,18 +110,16 @@ public void When() [Fact] public void TheOtherPartOfWhen() { - var step = _steps.Single(s => s.Title == "The other part of when"); + var step = _steps.Single(s => s.Title == "And the other part of when"); step.ExecutionOrder.ShouldBe(ExecutionOrder.ConsecutiveTransition); - step.Title.ShouldBe(GetStepTextFromMethodName(() => _typeWithAttribute.TheOtherPartOfWhen())); step.Asserts.ShouldBe(false); } [Fact] public void ButWhen() { - var step = _steps.Single(s => s.Title == "And something has not happened"); + var step = _steps.Single(s => s.Title == "But and something has not happened"); step.ExecutionOrder.ShouldBe(ExecutionOrder.ConsecutiveTransition); - step.Title.ShouldBe(GetStepTextFromMethodName(() => _typeWithAttribute.AndSomethingHasNotHappened())); step.Asserts.ShouldBe(false); } @@ -139,16 +135,15 @@ public void ThenStepsWithArgs() [Fact] public void AndThen() { - var step = _steps.Single(s => s.Title == TypeWithAttribute.MethodTextForAndThen); + var step = _steps.Single(s => s.Title == "The text for the AndThen part"); step.ExecutionOrder.ShouldBe(ExecutionOrder.ConsecutiveAssertion); step.Asserts.ShouldBe(true); - step.Title!.Trim().ShouldBe(TypeWithAttribute.MethodTextForAndThen); } [Fact] public void But() { - var step = _steps.Single(s => s.Title == "I dont want this to be true"); + var step = _steps.Single(s => s.Title == "But i dont want this to be true"); step.ExecutionOrder.ShouldBe(ExecutionOrder.ConsecutiveAssertion); step.Asserts.ShouldBe(true); } diff --git a/src/TestStack.BDDfy/Abstractions/DefaultStepTitleFactory.cs b/src/TestStack.BDDfy/Abstractions/DefaultStepTitleFactory.cs index cb974d9..be1d09c 100644 --- a/src/TestStack.BDDfy/Abstractions/DefaultStepTitleFactory.cs +++ b/src/TestStack.BDDfy/Abstractions/DefaultStepTitleFactory.cs @@ -6,6 +6,7 @@ namespace TestStack.BDDfy.Abstractions; internal class DefaultStepTitleFactory : IStepTitleFactory { public bool IncludeInputsInStepTitle { get; set; } = true; + public bool AddGherkinPrefixToSecondarySteps { get; set; } = true; public StepTitle Create( string? stepTextTemplate, @@ -18,23 +19,35 @@ public StepTitle Create( string createTitle() { var flatInputArray = inputArguments.Select(o => o.Value!).FlattenArrays(); - var name = methodInfo.Name; var titleAttribute = methodInfo.GetCustomAttribute(true); var executableAttribute = methodInfo.GetCustomAttribute(true); - includeInputsInStepTitle ??= titleAttribute?.IncludeInputsInStepTitle ?? IncludeInputsInStepTitle; + var callerSuppliedTemplate = stepTextTemplate != null; + includeInputsInStepTitle ??= titleAttribute?.IncludeInputsInStepTitle; + stepTextTemplate ??= titleAttribute != null + ? (NullIfEmpty(titleAttribute.StepTitle) ?? "") + : NullIfEmpty(executableAttribute?.StepTitle); + var stepTextTemplateWasNotSupplied = string.IsNullOrWhiteSpace(stepTextTemplate); - var titleTemplate = titleAttribute?.StepTitle ?? executableAttribute?.StepTitle; + stepTextTemplate ??= methodInfo.Name; - if (titleTemplate is not null) - { - name = string.Format(Configurator.CultureInfo, titleTemplate, flatInputArray); - } + var formattedStepTitle = string.Format(Configurator.CultureInfo, stepTextTemplate, flatInputArray); + var stepTitle = stepTextTemplateWasNotSupplied ? Configurator.Humanizer.Humanize(formattedStepTitle) : formattedStepTitle; + + var shouldAddPrefix = stepTextTemplateWasNotSupplied + || IsPrimaryPrefix(stepPrefix) + || (!callerSuppliedTemplate && AddGherkinPrefixToSecondarySteps); + + if (shouldAddPrefix) + stepTitle = AppendPrefix(stepTitle, stepPrefix); - var stepTitle = AppendPrefix(Configurator.Humanizer.Humanize(name), stepPrefix); + if (stepTextTemplate != formattedStepTitle && titleAttribute?.IncludeInputsInStepTitle is null) + includeInputsInStepTitle??= false; - if (!string.IsNullOrEmpty(stepTextTemplate)) stepTitle = string.Format(Configurator.CultureInfo, stepTextTemplate, flatInputArray); - else if (includeInputsInStepTitle.Value) + if (stepTitle!.Contains('<') && stepTitle.Contains('>') && titleAttribute?.IncludeInputsInStepTitle is null) + includeInputsInStepTitle??=false; + + if (includeInputsInStepTitle ?? IncludeInputsInStepTitle) { var parameters = methodInfo.GetParameters(); var stringFlatInputs = @@ -83,4 +96,9 @@ private static string AppendPrefix(string? title, string stepPrefix) return stepTitle; } + + private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + + private static bool IsPrimaryPrefix(string prefix) => + prefix is "Given" or "When" or "Then"; } \ No newline at end of file diff --git a/src/TestStack.BDDfy/Abstractions/IStepTitleFactory.cs b/src/TestStack.BDDfy/Abstractions/IStepTitleFactory.cs index 3a264cb..b5bab7a 100644 --- a/src/TestStack.BDDfy/Abstractions/IStepTitleFactory.cs +++ b/src/TestStack.BDDfy/Abstractions/IStepTitleFactory.cs @@ -5,6 +5,7 @@ namespace TestStack.BDDfy.Abstractions; public interface IStepTitleFactory { bool IncludeInputsInStepTitle { get; set; } + bool AddGherkinPrefixToSecondarySteps { get; set; } public StepTitle Create( string? stepTextTemplate, diff --git a/src/TestStack.BDDfy/Scanners/ScenarioScanners/FluentScenarioScanner.cs b/src/TestStack.BDDfy/Scanners/ScenarioScanners/FluentScenarioScanner.cs index 3209535..c97de9b 100644 --- a/src/TestStack.BDDfy/Scanners/ScenarioScanners/FluentScenarioScanner.cs +++ b/src/TestStack.BDDfy/Scanners/ScenarioScanners/FluentScenarioScanner.cs @@ -7,7 +7,7 @@ internal class FluentScenarioScanner(List steps, string? title): IScenario public IEnumerable Scan(ITestContext testContext) { var scenarioText = title ?? testContext.TestObject.GetType().Name; - if (testContext.Examples != null) + if (testContext.Examples is not null) { var scenarioId = Configurator.IdGenerator.GetScenarioId(); return testContext.Examples.Select(example => diff --git a/src/TestStack.BDDfy/Scanners/ScenarioScanners/ReflectiveScenarioScanner.cs b/src/TestStack.BDDfy/Scanners/ScenarioScanners/ReflectiveScenarioScanner.cs index 89ea36b..ee252b1 100644 --- a/src/TestStack.BDDfy/Scanners/ScenarioScanners/ReflectiveScenarioScanner.cs +++ b/src/TestStack.BDDfy/Scanners/ScenarioScanners/ReflectiveScenarioScanner.cs @@ -18,6 +18,7 @@ public virtual IEnumerable Scan(ITestContext testContext) scenarioTitle ??= GetScenarioText(scenarioType); var orderedSteps = steps.OrderBy(o => o.ExecutionOrder).ThenBy(o => o.ExecutionSubOrder).ToList(); + PromoteConsecutiveSteps(orderedSteps); yield return new Scenario(testContext.TestObject, orderedSteps, scenarioTitle, testContext.Tags); yield break; } @@ -31,6 +32,7 @@ public virtual IEnumerable Scan(ITestContext testContext) { var steps = ScanScenarioForSteps(testContext, example); var orderedSteps = steps.OrderBy(o => o.ExecutionOrder).ThenBy(o => o.ExecutionSubOrder).ToList(); + PromoteConsecutiveSteps(orderedSteps); yield return new Scenario(scenarioId, testContext.TestObject, orderedSteps, scenarioTitle, example, testContext.Tags); } } @@ -96,5 +98,107 @@ public virtual IEnumerable GetMethodsOfInterest(Type scenarioType) .Except(allPropertyMethods).Where(mi=> mi is not null) .Select(x=>x!)]; } + + private static void PromoteConsecutiveSteps(List orderedSteps) + { + ExecutionOrder? lastPrimaryOrder = null; + + foreach (var step in orderedSteps) + { + if (!step.AllowConsecutivePromotion) + { + // Reset tracking — non-promotable steps don't participate + var po = GetPrimaryOrder(step.ExecutionOrder); + if (po != null) lastPrimaryOrder = po; + continue; + } + + var primaryOrder = GetPrimaryOrder(step.ExecutionOrder); + if (primaryOrder == null) continue; + + if (primaryOrder == lastPrimaryOrder) + { + if (!IsAlreadyConsecutive(step.ExecutionOrder)) + { + step.ExecutionOrder = GetConsecutiveOrder(step.ExecutionOrder); + ReplacePrefixWithAnd(step); + } + else + { + StripRedundantKeyword(step); + } + } + else + { + lastPrimaryOrder = primaryOrder; + } + } + } + + private static ExecutionOrder? GetPrimaryOrder(ExecutionOrder order) => order switch + { + ExecutionOrder.SetupState or ExecutionOrder.ConsecutiveSetupState => ExecutionOrder.SetupState, + ExecutionOrder.Transition or ExecutionOrder.ConsecutiveTransition => ExecutionOrder.Transition, + ExecutionOrder.Assertion or ExecutionOrder.ConsecutiveAssertion => ExecutionOrder.Assertion, + _ => null + }; + + private static bool IsAlreadyConsecutive(ExecutionOrder order) => + order is ExecutionOrder.ConsecutiveSetupState or ExecutionOrder.ConsecutiveTransition or ExecutionOrder.ConsecutiveAssertion; + + private static ExecutionOrder GetConsecutiveOrder(ExecutionOrder order) => order switch + { + ExecutionOrder.SetupState => ExecutionOrder.ConsecutiveSetupState, + ExecutionOrder.Transition => ExecutionOrder.ConsecutiveTransition, + ExecutionOrder.Assertion => ExecutionOrder.ConsecutiveAssertion, + _ => order + }; + + private static void ReplacePrefixWithAnd(Step step) + { + var title = step.Title; + if (string.IsNullOrEmpty(title)) return; + + var addPrefix = Configurator.StepTitleFactory.AddGherkinPrefixToSecondarySteps; + + string[] prefixes = ["Given ", "When ", "Then "]; + foreach (var prefix in prefixes) + { + if (title.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + var remainder = title[prefix.Length..]; + step.OverrideTitle(addPrefix ? "And " + remainder : remainder); + return; + } + } + + if (!addPrefix) + return; + + // No known prefix found — prepend "And" with lowercased first char + step.OverrideTitle("And " + title[..1].ToLowerInvariant() + title[1..]); + } + + /// + /// Strips redundant Gherkin keywords from already-consecutive step titles. + /// E.g. "And given the pin is correct" → "And the pin is correct" + /// + private static void StripRedundantKeyword(Step step) + { + var title = step.Title; + if (string.IsNullOrEmpty(title)) return; + + string[] redundantPrefixes = ["And given ", "And when ", "And then ", "But given ", "But when ", "But then "]; + foreach (var prefix in redundantPrefixes) + { + if (title.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + var conjunction = title[..prefix.IndexOf(' ')]; // "And" or "But" + var remainder = title[prefix.Length..]; + step.OverrideTitle(conjunction + " " + remainder); + return; + } + } + } } } \ No newline at end of file diff --git a/src/TestStack.BDDfy/Scanners/StepScanners/ExecutableAttribute/ExecutableAttributeStepScanner.cs b/src/TestStack.BDDfy/Scanners/StepScanners/ExecutableAttribute/ExecutableAttributeStepScanner.cs index f1d61e8..ee5c182 100644 --- a/src/TestStack.BDDfy/Scanners/StepScanners/ExecutableAttribute/ExecutableAttributeStepScanner.cs +++ b/src/TestStack.BDDfy/Scanners/StepScanners/ExecutableAttribute/ExecutableAttributeStepScanner.cs @@ -30,15 +30,17 @@ public IEnumerable Scan(ITestContext testContext, MethodInfo candidateMeth if (executableAttribute == null) yield break; - var stepTitle = new StepTitle(executableAttribute.StepTitle); - if (string.IsNullOrWhiteSpace(stepTitle) && Configurator.Humanizer.Humanize(candidateMethod.Name) is string humanizedName) - stepTitle = new StepTitle(humanizedName); - var shouldReport = executableAttribute.ShouldReport; + var stepPrefix = GetStepPrefix(executableAttribute); var runStepWithArgsAttributes = (RunStepWithArgsAttribute[])candidateMethod.GetCustomAttributes(typeof(RunStepWithArgsAttribute), true); if (runStepWithArgsAttributes.Length == 0) { + var stepArgs = Array.Empty(); + var template = string.IsNullOrWhiteSpace(executableAttribute.StepTitle) ? null : executableAttribute.StepTitle; + var isPrimaryStep = IsPrimaryExecutionOrder(executableAttribute.ExecutionOrder); + var effectivePrefix = template != null && !isPrimaryStep ? "" : stepPrefix; + var stepTitle = Configurator.StepTitleFactory.Create(template, null, candidateMethod, stepArgs, testContext, effectivePrefix); var stepAction = StepActionFactory.GetStepAction(candidateMethod, []); yield return new Step( stepAction, @@ -48,32 +50,34 @@ public IEnumerable Scan(ITestContext testContext, MethodInfo candidateMeth shouldReport, []) { - ExecutionSubOrder = executableAttribute.Order + ExecutionSubOrder = executableAttribute.Order, + AllowConsecutivePromotion = true }; } foreach (var runStepWithArgsAttribute in runStepWithArgsAttributes) { var inputArguments = runStepWithArgsAttribute.InputArguments; - var flatInput = inputArguments.FlattenArrays(); - var stringFlatInputs = flatInput.Select(i => i.ToString()).ToArray(); - var methodName = stepTitle + " " + string.Join(", ", stringFlatInputs); - - if (!string.IsNullOrEmpty(runStepWithArgsAttribute.StepTextTemplate)) - methodName = string.Format(runStepWithArgsAttribute.StepTextTemplate, flatInput); - else if (!string.IsNullOrEmpty(executableAttribute.StepTitle)) - methodName = string.Format(executableAttribute.StepTitle, flatInput); + var hasRunStepTemplate = !string.IsNullOrEmpty(runStepWithArgsAttribute.StepTextTemplate); + var stepTextTemplate = hasRunStepTemplate + ? runStepWithArgsAttribute.StepTextTemplate + : (string.IsNullOrWhiteSpace(executableAttribute.StepTitle) ? null : executableAttribute.StepTitle); + var stepArgs = inputArguments.Select(v => new StepArgument(() => v)).ToArray(); + var isPrimaryStep = IsPrimaryExecutionOrder(executableAttribute.ExecutionOrder); + var effectivePrefix = stepTextTemplate != null && (!isPrimaryStep || hasRunStepTemplate) ? "" : stepPrefix; + var stepTitle = Configurator.StepTitleFactory.Create(stepTextTemplate, null, candidateMethod, stepArgs, testContext, effectivePrefix); var stepAction = StepActionFactory.GetStepAction(candidateMethod, inputArguments); yield return new Step( stepAction, - new StepTitle(methodName), + stepTitle, executableAttribute.Asserts, executableAttribute.ExecutionOrder, shouldReport, []) { - ExecutionSubOrder = executableAttribute.Order + ExecutionSubOrder = executableAttribute.Order, + AllowConsecutivePromotion = true }; } } @@ -84,8 +88,10 @@ public IEnumerable Scan(ITestContext testContext, MethodInfo method, Examp if (executableAttribute == null) yield break; + var stepPrefix = GetStepPrefix(executableAttribute); + var hasExplicitTitle = !string.IsNullOrWhiteSpace(executableAttribute.StepTitle); var stepTitle = executableAttribute.StepTitle; - if (string.IsNullOrWhiteSpace(stepTitle) && Configurator.Humanizer.Humanize(method.Name) is string humanizedName) + if (!hasExplicitTitle && Configurator.Humanizer.Humanize(method.Name) is string humanizedName) stepTitle = humanizedName; var shouldReport = executableAttribute.ShouldReport; @@ -112,13 +118,36 @@ public IEnumerable Scan(ITestContext testContext, MethodInfo method, Examp } var stepAction = StepActionFactory.GetStepAction(method, [.. inputs]); + var isPrimaryStep = IsPrimaryExecutionOrder(executableAttribute.ExecutionOrder); + var effectivePrefix = hasExplicitTitle && !isPrimaryStep ? "" : stepPrefix; + var finalTitle = Configurator.StepTitleFactory.Create(stepTitle ?? string.Empty, effectivePrefix, testContext); yield return new Step( stepAction, - new StepTitle(stepTitle), + finalTitle, executableAttribute.Asserts, executableAttribute.ExecutionOrder, shouldReport, - []); + []) + { + AllowConsecutivePromotion = true + }; } + + private static string GetStepPrefix(ExecutableAttribute attribute) => attribute switch + { + GivenAttribute => "Given", + AndGivenAttribute => "And", + ButGivenAttribute => "But", + WhenAttribute => "When", + AndWhenAttribute => "And", + ButWhenAttribute => "But", + ThenAttribute => "Then", + AndThenAttribute => "And", + ButAttribute => "But", + _ => "" + }; + + private static bool IsPrimaryExecutionOrder(ExecutionOrder order) => + order is ExecutionOrder.SetupState or ExecutionOrder.Transition or ExecutionOrder.Assertion; } } \ No newline at end of file diff --git a/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/DefaultMethodNameStepScanner.cs b/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/DefaultMethodNameStepScanner.cs index 4a4aad9..d40b5cb 100644 --- a/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/DefaultMethodNameStepScanner.cs +++ b/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/DefaultMethodNameStepScanner.cs @@ -5,23 +5,23 @@ public class DefaultMethodNameStepScanner : MethodNameStepScanner public DefaultMethodNameStepScanner() : base(CleanupTheStepText) { - AddMatcher(new MethodNameMatcher(s => s.StartsWith("Given", StringComparison.OrdinalIgnoreCase), ExecutionOrder.SetupState)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("AndGiven", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveSetupState)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("And_Given_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveSetupState)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("But_Given_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveSetupState)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("ButGiven", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveSetupState)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("When", StringComparison.OrdinalIgnoreCase), ExecutionOrder.Transition)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("AndWhen", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveTransition)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("And_When_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveTransition)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("But_When_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveTransition)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("ButWhen", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveTransition)); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("Then", StringComparison.OrdinalIgnoreCase), ExecutionOrder.Assertion) { Asserts = true }); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("And", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true }); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("AndThen", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true }); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("And_Then_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true }); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("But", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true }); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("But_Then_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true }); - AddMatcher(new MethodNameMatcher(s => s.StartsWith("ButThen", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("Given", StringComparison.OrdinalIgnoreCase), ExecutionOrder.SetupState) { StepPrefix = "Given" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("AndGiven", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveSetupState) { StepPrefix = "And" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("And_Given_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveSetupState) { StepPrefix = "And" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("But_Given_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveSetupState) { StepPrefix = "But" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("ButGiven", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveSetupState) { StepPrefix = "But" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("When", StringComparison.OrdinalIgnoreCase), ExecutionOrder.Transition) { StepPrefix = "When" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("AndWhen", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveTransition) { StepPrefix = "And" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("And_When_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveTransition) { StepPrefix = "And" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("But_When_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveTransition) { StepPrefix = "But" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("ButWhen", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveTransition) { StepPrefix = "But" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("Then", StringComparison.OrdinalIgnoreCase), ExecutionOrder.Assertion) { Asserts = true, StepPrefix = "Then" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("And", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true, StepPrefix = "And" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("AndThen", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true, StepPrefix = "And" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("And_Then_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true, StepPrefix = "And" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("But", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true, StepPrefix = "But" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("But_Then_", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true, StepPrefix = "But" }); + AddMatcher(new MethodNameMatcher(s => s.StartsWith("ButThen", StringComparison.OrdinalIgnoreCase), ExecutionOrder.ConsecutiveAssertion) { Asserts = true, StepPrefix = "But" }); AddMatcher(new MethodNameMatcher(s => s.EndsWith("Context", StringComparison.OrdinalIgnoreCase), ExecutionOrder.Initialize) { ShouldReport = false }); AddMatcher(new MethodNameMatcher(s => s.Equals("Setup", StringComparison.OrdinalIgnoreCase), ExecutionOrder.Initialize) { ShouldReport = false }); AddMatcher(new MethodNameMatcher(s => s.StartsWith("TearDown", StringComparison.OrdinalIgnoreCase), ExecutionOrder.TearDown) { ShouldReport = false }); diff --git a/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/MethodNameMatcher.cs b/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/MethodNameMatcher.cs index d078f3c..5beef67 100644 --- a/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/MethodNameMatcher.cs +++ b/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/MethodNameMatcher.cs @@ -1,6 +1,6 @@ namespace TestStack.BDDfy { - public class MethodNameMatcher(Predicate isMethodOfInterest, ExecutionOrder executionOrder) + public class MethodNameMatcher(Predicate isMethodOfInterest, ExecutionOrder executionOrder, string stepPrefix = "") { public MethodNameMatcher(Predicate isMethodOfInterest, bool asserts, ExecutionOrder executionOrder, bool shouldReport) : this(isMethodOfInterest, executionOrder) @@ -13,5 +13,6 @@ public MethodNameMatcher(Predicate isMethodOfInterest, bool asserts, Exe public bool Asserts { get; set; } = false; public bool ShouldReport { get; set; } = true; public ExecutionOrder ExecutionOrder { get; private set; } = executionOrder; + public string StepPrefix { get; set; } = stepPrefix; } } \ No newline at end of file diff --git a/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/MethodNameStepScanner.cs b/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/MethodNameStepScanner.cs index c0adae2..7ad6328 100644 --- a/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/MethodNameStepScanner.cs +++ b/src/TestStack.BDDfy/Scanners/StepScanners/MethodName/MethodNameStepScanner.cs @@ -65,13 +65,13 @@ public IEnumerable Scan(ITestContext testContext, MethodInfo method) var returnsItsText = method.ReturnType == typeof(IEnumerable); if (argAttributes.Length == 0) - yield return GetStep(testContext.TestObject, matcher, method, returnsItsText, [], null); + yield return GetStep(testContext, matcher, method, returnsItsText, [], null); foreach (var argAttribute in argAttributes) { var inputs = argAttribute.InputArguments; if (inputs != null && inputs.Length > 0) - yield return GetStep(testContext.TestObject, matcher, method, returnsItsText, inputs, argAttribute); + yield return GetStep(testContext, matcher, method, returnsItsText, inputs, argAttribute); } yield break; @@ -83,15 +83,14 @@ public IEnumerable Scan(ITestContext testContext, MethodInfo method, Examp foreach (var matcher in _matchers.Where(x=> x.IsMethodOfInterest(method.Name))) { var returnsItsText = method.ReturnType == typeof(IEnumerable); - return [GetStep(matcher, method, returnsItsText, example)]; + return [GetStep(testContext, matcher, method, returnsItsText, example)]; } return []; } - private Step GetStep(MethodNameMatcher matcher, MethodInfo method, bool returnsItsText, Example example) + private Step GetStep(ITestContext testContext, MethodNameMatcher matcher, MethodInfo method, bool returnsItsText, Example example) { - var stepMethodName = GetStepTitleFromMethodName(method, null); var methodParameters = method.GetParameters(); var inputs = new object[methodParameters.Length]; @@ -107,43 +106,54 @@ private Step GetStep(MethodNameMatcher matcher, MethodInfo method, bool returnsI } } + var stepTitle = CreateStepTitle(testContext, matcher, method, null, inputs); var stepAction = GetStepAction(method, [.. inputs], returnsItsText); - return new Step(stepAction, new StepTitle(stepMethodName), matcher.Asserts, matcher.ExecutionOrder, matcher.ShouldReport, []); + return new Step(stepAction, stepTitle, matcher.Asserts, matcher.ExecutionOrder, matcher.ShouldReport, []) { AllowConsecutivePromotion = true }; } - private Step GetStep(object testObject, MethodNameMatcher matcher, MethodInfo method, bool returnsItsText, object[] inputs, RunStepWithArgsAttribute? argAttribute) + private Step GetStep(ITestContext testContext, MethodNameMatcher matcher, MethodInfo method, bool returnsItsText, object[] inputs, RunStepWithArgsAttribute? argAttribute) { - var stepMethodName = GetStepTitle(method, testObject, argAttribute, returnsItsText); + var stepTitle = GetStepTitle(testContext, matcher, method, argAttribute, returnsItsText, inputs); var stepAction = GetStepAction(method, inputs, returnsItsText); - return new Step(stepAction, new StepTitle(stepMethodName), matcher.Asserts, matcher.ExecutionOrder, matcher.ShouldReport, []); + return new Step(stepAction, stepTitle, matcher.Asserts, matcher.ExecutionOrder, matcher.ShouldReport, []) { AllowConsecutivePromotion = true }; } - private string GetStepTitle(MethodInfo method, object testObject, RunStepWithArgsAttribute? argAttribute, bool returnsItsText) + private StepTitle GetStepTitle(ITestContext testContext, MethodNameMatcher matcher, MethodInfo method, RunStepWithArgsAttribute? argAttribute, bool returnsItsText, object[] inputs) { - string stepTitleFromMethodName() => GetStepTitleFromMethodName(method, argAttribute); - if (returnsItsText) - return GetStepTitleFromMethod(method, argAttribute, testObject) ?? stepTitleFromMethodName(); + { + var titleFromMethod = GetStepTitleFromMethod(method, argAttribute, testContext.TestObject); + if (titleFromMethod != null) + return new StepTitle(titleFromMethod); + } - return stepTitleFromMethodName(); + return CreateStepTitle(testContext, matcher, method, argAttribute, inputs); } - private string GetStepTitleFromMethodName(MethodInfo method, RunStepWithArgsAttribute? argAttribute) + private StepTitle CreateStepTitle(ITestContext testContext, MethodNameMatcher matcher, MethodInfo method, RunStepWithArgsAttribute? argAttribute, object[] inputs) { - var methodName = _stepTextTransformer(Configurator.Humanizer.Humanize(method.Name)); - if (argAttribute is null) return methodName; + var stepTextTemplate = argAttribute?.StepTextTemplate; + var stepArgs = inputs.Select(v => new StepArgument(() => v)).ToArray(); - var inputs = argAttribute.InputArguments; + // If there's a StepTitle attribute, let the factory handle it (no prefix since user provided explicit title) + var titleAttribute = method.GetCustomAttribute(true); + if (titleAttribute != null) + { + var hasExplicitText = !string.IsNullOrWhiteSpace(titleAttribute.StepTitle) || !string.IsNullOrEmpty(stepTextTemplate); + var titlePrefix = hasExplicitText ? "" : matcher.StepPrefix; + return Configurator.StepTitleFactory.Create(stepTextTemplate, null, method, stepArgs, testContext, titlePrefix); + } - if (argAttribute is null) return methodName; - - if (string.IsNullOrEmpty(argAttribute.StepTextTemplate)) + // If there's an explicit StepTextTemplate from RunStepWithArgs, use it without prefix + // (the user is providing the complete title) + if (!string.IsNullOrEmpty(stepTextTemplate)) { - var stringFlatInputs = inputs.FlattenArrays().Select(i => i.ToTextRepresentation()).ToArray(); - return methodName + " " + string.Join(", ", stringFlatInputs); + return Configurator.StepTitleFactory.Create(stepTextTemplate, null, method, stepArgs, testContext, ""); } - return string.Format(Configurator.CultureInfo, argAttribute.StepTextTemplate, inputs.FlattenArrays()); + // Naming convention fallback: humanize + cleanup transform, pass as pre-built title + var humanized = _stepTextTransformer(Configurator.Humanizer.Humanize(method.Name)); + return Configurator.StepTitleFactory.Create(humanized, null, method, stepArgs, testContext, matcher.StepPrefix); } private static string? GetStepTitleFromMethod(MethodInfo method, RunStepWithArgsAttribute? argAttribute, object testObject) diff --git a/src/TestStack.BDDfy/Step.cs b/src/TestStack.BDDfy/Step.cs index 2a01541..92afa5c 100644 --- a/src/TestStack.BDDfy/Step.cs +++ b/src/TestStack.BDDfy/Step.cs @@ -27,7 +27,7 @@ public Step( public Step(Step step) { Id = step.Id; - _stepTitle = step._stepTitle; + _stepTitle = step._stepTitle; Asserts = step.Asserts; ExecutionOrder = step.ExecutionOrder; ShouldReport = step.ShouldReport; @@ -41,11 +41,15 @@ public Step(Step step) public bool Asserts { get; private set; } public bool ShouldReport { get; private set; } public string? Title => (_title ??= _stepTitle)?.Trim(); - public ExecutionOrder ExecutionOrder { get; private set; } + public ExecutionOrder ExecutionOrder { get; internal set; } public Result Result { get; set; } public Exception? Exception { get; set; } public int ExecutionSubOrder { get; set; } public TimeSpan Duration { get; set; } public List Arguments { get; private set; } + + internal bool AllowConsecutivePromotion { get; set; } + internal void OverrideTitle(string newTitle) => _title = newTitle; + public override string ToString() => Title ?? nameof(Step); } } \ No newline at end of file diff --git a/src/TestStack.BDDfy/StepTitleAttribute.cs b/src/TestStack.BDDfy/StepTitleAttribute.cs index 9dba3ff..7cca2f5 100644 --- a/src/TestStack.BDDfy/StepTitleAttribute.cs +++ b/src/TestStack.BDDfy/StepTitleAttribute.cs @@ -1,20 +1,15 @@ namespace TestStack.BDDfy { [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] - public class StepTitleAttribute : Attribute + + public class StepTitleAttribute(string stepTitle): Attribute { - public StepTitleAttribute(string stepTitle) - { - StepTitle = stepTitle; - } - - public StepTitleAttribute(string stepTitle, bool includeInputsInStepTitle) + public StepTitleAttribute(string stepTitle, bool includeInputsInStepTitle): this(stepTitle) { IncludeInputsInStepTitle = includeInputsInStepTitle; - StepTitle = stepTitle; } - public string StepTitle { get; private set; } + public string StepTitle { get; private set; } = stepTitle; public bool? IncludeInputsInStepTitle { get; private set; } }