Skip to content

Commit 451b792

Browse files
Fix issue #165
Additionally now your method can return string to be used as static or dynamic title If your method return IEnumerable<string> , the first yeild will be used as step title (both for fluent and reflective APIs)
1 parent dacc255 commit 451b792

6 files changed

Lines changed: 320 additions & 18 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using Shouldly;
2+
using Xunit;
3+
4+
namespace TestStack.BDDfy.Tests.Scanner.FluentScanner
5+
{
6+
/// <summary>
7+
/// Test for https://github.com/TestStack/TestStack.BDDfy/issues/165
8+
/// Steps that return a string or IEnumerable&lt;string&gt; in the fluent API
9+
/// should use the returned value as the step title, allowing dynamic titles
10+
/// without specifying a title parameter on every call.
11+
/// </summary>
12+
public class FluentStepReturningDynamicTitle
13+
{
14+
private int _input;
15+
16+
string GivenWithStringReturn()
17+
{
18+
_input = 42;
19+
return "Given the system is initialised with value 42";
20+
}
21+
22+
string WhenWithStringReturn()
23+
{
24+
_input *= 2;
25+
return "When the value is doubled";
26+
}
27+
28+
string ThenWithStringReturn()
29+
{
30+
_input.ShouldBe(84);
31+
return "Then the value is 84";
32+
}
33+
34+
IEnumerable<string> GivenWithEnumerableReturn()
35+
{
36+
yield return "Given setup via enumerable";
37+
_input = 10;
38+
}
39+
40+
IEnumerable<string> WhenWithEnumerableReturn()
41+
{
42+
yield return "When action via enumerable";
43+
_input += 5;
44+
}
45+
46+
IEnumerable<string> ThenWithEnumerableReturn()
47+
{
48+
yield return "Then result via enumerable";
49+
_input.ShouldBe(15);
50+
}
51+
52+
string WhenThrowingString() => throw new InvalidOperationException("string step failed");
53+
54+
IEnumerable<string> WhenThrowingEnumerable()
55+
{
56+
throw new InvalidOperationException("enumerable step failed");
57+
}
58+
59+
IEnumerable<string> WhenYieldsThenThrows()
60+
{
61+
yield return "When it yields then throws an exception";
62+
throw new InvalidOperationException("failed after yield");
63+
}
64+
65+
[Fact]
66+
public void ShouldUseDynamicTitleFromStringReturnValue()
67+
{
68+
var story = this
69+
.Given(_ => _.GivenWithStringReturn())
70+
.When(_ => _.WhenWithStringReturn())
71+
.Then(_ => _.ThenWithStringReturn())
72+
.BDDfy();
73+
74+
var scenario = story.Scenarios.First();
75+
var titles = scenario.Steps.Select(s => s.Title).ToArray();
76+
77+
titles.ShouldBe([
78+
"Given the system is initialised with value 42",
79+
"When the value is doubled",
80+
"Then the value is 84"
81+
]);
82+
}
83+
84+
[Fact]
85+
public void ShouldUseDynamicTitleFromEnumerableReturnValue()
86+
{
87+
var story = this
88+
.Given(_ => _.GivenWithEnumerableReturn())
89+
.When(_ => _.WhenWithEnumerableReturn())
90+
.Then(_ => _.ThenWithEnumerableReturn())
91+
.BDDfy();
92+
93+
var scenario = story.Scenarios.First();
94+
var titles = scenario.Steps.Select(s => s.Title).ToArray();
95+
96+
titles.ShouldBe([
97+
"Given setup via enumerable",
98+
"When action via enumerable",
99+
"Then result via enumerable"
100+
]);
101+
}
102+
103+
[Fact]
104+
public void ShouldThrowWhenStringReturningStepThrows()
105+
{
106+
Should.Throw<InvalidOperationException>(() =>
107+
this.Given(_ => _.GivenWithStringReturn())
108+
.When(_ => _.WhenThrowingString())
109+
.Then(_ => _.ThenWithStringReturn())
110+
.BDDfy());
111+
}
112+
113+
[Fact]
114+
public void ShouldThrowWhenEnumerableReturningStepThrows()
115+
{
116+
Should.Throw<InvalidOperationException>(() =>
117+
this.Given(_ => _.GivenWithEnumerableReturn())
118+
.When(_ => _.WhenThrowingEnumerable())
119+
.Then(_ => _.ThenWithEnumerableReturn())
120+
.BDDfy());
121+
}
122+
123+
[Fact]
124+
public void ShouldSetTitleBeforeThrowingWhenEnumerableYieldsThenThrows()
125+
{
126+
// The step yields a title then throws — the title should be set
127+
// and the exception should still propagate as a step failure.
128+
var ex = Should.Throw<InvalidOperationException>(() =>
129+
this.Given(_ => _.GivenWithEnumerableReturn())
130+
.When(_ => _.WhenYieldsThenThrows())
131+
.Then(_ => _.ThenWithEnumerableReturn())
132+
.BDDfy());
133+
134+
ex.Message.ShouldBe("failed after yield");
135+
}
136+
}
137+
}

src/TestStack.BDDfy.Tests/Scanner/ReflectiveScanner/WhenStepsReturnTheirText.cs

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ namespace TestStack.BDDfy.Tests.Scanner.ReflectiveScanner
55
{
66
public class WhenStepsReturnTheirText
77
{
8-
class TestClassWithStepsReturningTheirText(int input1, string input2)
8+
class ScenarioWithEnumerableSteps(int input1, string input2)
99
{
1010
private readonly int _input1 = input1;
1111
private readonly string _input2 = input2;
@@ -27,24 +27,125 @@ IEnumerable<string> ThenSomeAssertions()
2727
}
2828
}
2929

30+
class ScenarioWithStringSteps
31+
{
32+
string GivenTheSystemIsReady()
33+
{
34+
return "Given the system is ready for testing";
35+
}
36+
37+
string WhenAnActionOccurs()
38+
{
39+
return "When an action occurs";
40+
}
41+
42+
string ThenTheOutcomeIsCorrect()
43+
{
44+
return "Then the outcome is correct";
45+
}
46+
}
47+
48+
class ScenarioWithThrowingEnumerableStep
49+
{
50+
IEnumerable<string> GivenSomething()
51+
{
52+
yield return "Given something";
53+
}
54+
55+
IEnumerable<string> WhenItThrows()
56+
{
57+
throw new InvalidOperationException("enumerable step failed");
58+
}
59+
60+
IEnumerable<string> ThenNever()
61+
{
62+
yield return "Then never";
63+
}
64+
}
65+
66+
class ScenarioWithEnumerableStepThrowingAfterYield
67+
{
68+
IEnumerable<string> GivenSomething()
69+
{
70+
yield return "Given something";
71+
}
72+
73+
IEnumerable<string> WhenItYieldsThenThrows()
74+
{
75+
yield return "When it yields then throws";
76+
throw new InvalidOperationException("failed after yield");
77+
}
78+
79+
IEnumerable<string> ThenNever()
80+
{
81+
yield return "Then never";
82+
}
83+
}
84+
85+
class ScenarioWithThrowingStringStep
86+
{
87+
string GivenSomething() => "Given something";
88+
89+
string WhenItThrows() => throw new InvalidOperationException("string step failed");
90+
91+
string ThenNever() => "Then never";
92+
}
93+
3094
[Fact]
31-
public void Test()
95+
public void EnumerableStepsUseDynamicTitles()
3296
{
33-
var testObject = new TestClassWithStepsReturningTheirText(1, "some input");
97+
var testObject = new ScenarioWithEnumerableSteps(1, "some input");
3498
var steps = new DefaultMethodNameStepScanner().Scan(TestContext.GetContext(testObject)).ToList();
3599

36100
AssertStep(steps[0], "Given inputs 1 and some input", ExecutionOrder.SetupState);
37101
AssertStep(steps[1], "When input 2 is applied on 123", ExecutionOrder.Transition);
38102
AssertStep(steps[2], "Then some assertions", ExecutionOrder.Assertion, true);
39103
}
40104

105+
[Fact]
106+
public void StringStepsUseDynamicTitles()
107+
{
108+
var testObject = new ScenarioWithStringSteps();
109+
var steps = new DefaultMethodNameStepScanner().Scan(TestContext.GetContext(testObject)).ToList();
110+
111+
AssertStep(steps[0], "Given the system is ready for testing", ExecutionOrder.SetupState);
112+
AssertStep(steps[1], "When an action occurs", ExecutionOrder.Transition);
113+
AssertStep(steps[2], "Then the outcome is correct", ExecutionOrder.Assertion, true);
114+
}
115+
116+
[Fact]
117+
public void EnumerableStepThrowingAtScanTimeThrowsStepTitleException()
118+
{
119+
var testObject = new ScenarioWithThrowingEnumerableStep();
120+
Should.Throw<StepTitleException>(() =>
121+
new DefaultMethodNameStepScanner().Scan(TestContext.GetContext(testObject)).ToList());
122+
}
123+
124+
[Fact]
125+
public void EnumerableStepThrowingAfterYieldUsesYieldedTitle()
126+
{
127+
// The title is extracted at scan time via FirstOrDefault() which succeeds.
128+
// The exception occurs at execution time when the step body runs after the yield.
129+
var testObject = new ScenarioWithEnumerableStepThrowingAfterYield();
130+
var steps = new DefaultMethodNameStepScanner().Scan(TestContext.GetContext(testObject)).ToList();
131+
132+
AssertStep(steps[1], "When it yields then throws", ExecutionOrder.Transition);
133+
}
134+
135+
[Fact]
136+
public void StringStepThrowingAtScanTimeThrowsStepTitleException()
137+
{
138+
var testObject = new ScenarioWithThrowingStringStep();
139+
Should.Throw<StepTitleException>(() =>
140+
new DefaultMethodNameStepScanner().Scan(TestContext.GetContext(testObject)).ToList());
141+
}
142+
41143
private static void AssertStep(Step step, string stepTitle, ExecutionOrder order, bool asserts = false, bool shouldReport = true)
42144
{
43145
step.Title.ShouldBe(stepTitle);
44146
step.Asserts.ShouldBe(asserts);
45147
step.ExecutionOrder.ShouldBe(order);
46148
step.ShouldReport.ShouldBe(shouldReport);
47149
}
48-
49150
}
50151
}

src/TestStack.BDDfy/Processors/AsyncTestRunner.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ namespace TestStack.BDDfy.Processors
44
{
55
public static class AsyncTestRunner
66
{
7-
public static void Run(Func<object?> performStep)
7+
public static object? Run(Func<object?> performStep)
88
{
99
var oldSyncContext = SynchronizationContext.Current;
1010
try
@@ -28,6 +28,7 @@ public static void Run(Func<object?> performStep)
2828

2929
throw;
3030
}
31+
return null;
3132
}
3233
else
3334
{
@@ -37,6 +38,7 @@ public static void Run(Func<object?> performStep)
3738
ExceptionProcessor.PreserveStackTrace(ex);
3839
throw ex;
3940
}
41+
return result;
4042
}
4143
}
4244
finally

src/TestStack.BDDfy/Processors/ScenarioExecutor.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,21 @@ public Result ExecuteStep(Step step)
5555
{
5656
try
5757
{
58+
object? result;
5859
if (Configurator.AsyncVoidSupportEnabled)
59-
AsyncTestRunner.Run(() => Configurator.StepExecutor.Execute(step, _scenario.TestObject));
60+
result = AsyncTestRunner.Run(() => Configurator.StepExecutor.Execute(step, _scenario.TestObject));
6061
else
61-
Configurator.StepExecutor.Execute(step, _scenario.TestObject);
62+
result = Configurator.StepExecutor.Execute(step, _scenario.TestObject);
63+
64+
if (result is string title && !string.IsNullOrWhiteSpace(title))
65+
step.OverrideTitle(title);
66+
else if (result is IEnumerable<string> enumerable)
67+
EnumerateAndOverrideTitle(step, enumerable);
68+
6269
step.Result = Result.Passed;
6370
}
6471
catch (Exception ex)
6572
{
66-
// ToDo: more thought should be put into this. Is it safe to get the exception?
6773
var exception = ExceptionResolver.Resolve(ex);
6874

6975
if (exception is NotImplementedException)
@@ -86,6 +92,25 @@ public Result ExecuteStep(Step step)
8692
return step.Result;
8793
}
8894

95+
private static void EnumerateAndOverrideTitle(Step step, IEnumerable<string> enumerable)
96+
{
97+
// Fully enumerate to force execution of iterator method bodies.
98+
// Override the title on the first yielded value immediately so that
99+
// if the step throws after the yield, the title is already set.
100+
using var enumerator = enumerable.GetEnumerator();
101+
while (enumerator.MoveNext())
102+
{
103+
if (!string.IsNullOrWhiteSpace(enumerator.Current))
104+
{
105+
step.OverrideTitle(enumerator.Current);
106+
break;
107+
}
108+
}
109+
110+
// Continue enumeration to run remaining step body
111+
while (enumerator.MoveNext()) { }
112+
}
113+
89114
private static bool IsInconclusive(Exception exception)
90115
{
91116
return exception.GetType().Name.Contains("InconclusiveException");

src/TestStack.BDDfy/Scanners/StepScanners/Fluent/FluentScanner.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,41 @@ public void AddStep(
101101
bool asserts,
102102
string stepPrefix)
103103
{
104-
var action = stepAction.Compile();
104+
// If the method returns string or IEnumerable<string>, recompile as Func to
105+
// preserve the return value for dynamic title override at execution time.
106+
if (stepAction.Body is MethodCallExpression call && ReturnsDynamicTitle(call.Method.ReturnType))
107+
{
108+
var funcExpr = Expression.Lambda(call, stepAction.Parameters);
109+
var func = funcExpr.Compile();
110+
Func<object, object?> stepDelegate = o => func.DynamicInvoke(o);
111+
112+
StepTitle title;
113+
List<StepArgument> args;
114+
115+
if (string.IsNullOrWhiteSpace(stepTextTemplate) && IsChainedMethodCall(stepAction.Body))
116+
{
117+
title = FluentScanner<TScenario>.BuildChainedTitle(stepAction, stepPrefix);
118+
args = [];
119+
}
120+
else
121+
{
122+
var inputArguments = stepAction.ExtractArguments(_testObject).ToArray();
123+
title = CreateTitle(stepTextTemplate, includeInputsInStepTitle, GetMethodInfo(stepAction), inputArguments, stepPrefix);
124+
args = [.. inputArguments.Where(s => !string.IsNullOrEmpty(s.Name))];
125+
}
126+
127+
_steps.Add(new Step(stepDelegate, title, FixAsserts(asserts, executionOrder),
128+
FixConsecutiveStep(executionOrder), reports, args));
129+
return;
130+
}
105131

132+
var action = stepAction.Compile();
106133
AddStep(action, stepAction, stepTextTemplate, includeInputsInStepTitle, reports, executionOrder, asserts, stepPrefix);
107134
}
108135

136+
private static bool ReturnsDynamicTitle(Type returnType)
137+
=> returnType == typeof(string) || returnType == typeof(IEnumerable<string>);
138+
109139
private void AddStep(
110140
Action<TScenario> action,
111141
LambdaExpression stepAction,

0 commit comments

Comments
 (0)