From 57341910fbbda6f6626cf1e3144c19d8e14f43ec Mon Sep 17 00:00:00 2001 From: yanjustino Date: Sat, 8 Mar 2025 15:50:47 -0300 Subject: [PATCH] Refactor GherXunit attributes and tests: update attribute descriptions, enhance async handling, and improve scenario definitions --- .github/workflows/dotnet.yml | 2 +- QuickStart.md | 185 +++++++++++++++ README.md | 37 ++- README_PTBR.md | 39 ++-- src/base/GherXunit.Core/Attributes.cs | 4 +- .../Backgrounds/BackgroundTest.Context.cs | 13 ++ .../Backgrounds/BackgroundTest.Steps.cs | 49 ++++ .../Backgrounds/BackgroundTest.cs | 45 ++++ src/base/GherXunit.Core/Methods.cs | 65 ++++-- src/base/GherXunit.Core/StepStringHandler.cs | 12 +- .../GherXunit/GherXunit.Content.Attributes.cs | 4 +- .../GherXunit/GherXunit.Content.Methods.cs | 65 ++++-- .../GherXunit.Content.StringHandler.cs | 12 +- src/lib/GherXunit/GherXunit.csproj | 2 +- src/sample/BddSample/BddSample.csproj | 2 + .../Backgrounds/BackgroundTest.Steps.cs | 23 +- .../Samples/Backgrounds/BackgroundTest.cs | 3 +- .../Samples/Outline/ScenarioOutlineTest.cs | 2 +- .../ExemploA/ProcessamentoServiceTests.cs | 221 ++++++++++++++++++ 19 files changed, 661 insertions(+), 124 deletions(-) create mode 100644 QuickStart.md create mode 100644 src/base/GherXunit.Core/Backgrounds/BackgroundTest.Context.cs create mode 100644 src/base/GherXunit.Core/Backgrounds/BackgroundTest.Steps.cs create mode 100644 src/base/GherXunit.Core/Backgrounds/BackgroundTest.cs create mode 100644 src/sample/BddSample/Samples/Refactoring/ExemploA/ProcessamentoServiceTests.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e063a44..7def053 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest env: - Version: 1.1.${{ github.run_number }} + Version: 1.2.${{ github.run_number }} Solution_Path: ./src/GherXunit.sln Project_Path: ./src/lib/GherXunit/GherXunit.csproj Package_Path: ./src/lib/GherXunit/**/*. diff --git a/QuickStart.md b/QuickStart.md new file mode 100644 index 0000000..96a7502 --- /dev/null +++ b/QuickStart.md @@ -0,0 +1,185 @@ +# Refactoring tests and extracting more readable scenarios +It's easy to mess up your test code when you're trying to write test scenarios. I know how it feels to have a test code +that is hard to read and maintain.**GherXunit** is a library that helps you write test scenarios in a structure familiar +to those already using xUnit. + +In this section, you will learn how to define test scenarios and implement step methods using **GherXunit**. +For that, we will show you how to define test scenarios using Gherkin syntax and implement step methods using **GherXunit**. + +## Example of Scenario Definition +The following test class `DataProcessingServiceTests` represents a common scenario where we have a complex test that needs to be more readable and maintainable. +In this case, the behavior is hidden into the test methods, becoming hard to understand and maintain. +In fact, the test methods are mixed with the setup of the mocks and the configuration of the test environment. + +```csharp +The test class contains the method: `ProcessData_ShouldProcessSuccessfully`. Also, the test class contains the setup of +the mocks and the configuration of the test environment. The following code snippet shows the test class `DataProcessingServiceTests`: + +```csharp +public class DataProcessingServiceTests +{ + private readonly Mock _apiClientMock; + private readonly Mock _repositoryMock; + private readonly Mock _queueServiceMock; + private readonly Mock> _loggerMock; + private readonly DataProcessingService _dataProcessingService; + + public DataProcessingServiceTests() + { + _apiClientMock = new Mock(); + _repositoryMock = new Mock(); + _queueServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _dataProcessingService = new DataProcessingService( + _apiClientMock.Object, + _repositoryMock.Object, + _queueServiceMock.Object, + _loggerMock.Object + ); + } + + [Fact] + public async Task ProcessData_ShouldProcessSuccessfully() + { + // Arrange + var data = new Data { Value = "Test" }; + + _apiClientMock.Setup(api => api.FetchDataAsync()) + .ReturnsAsync(data); + + _repositoryMock.Setup(repo => repo.SaveDataAsync(data)) + .Returns(Task.CompletedTask); + + _queueServiceMock.Setup(queue => queue.PublishMessage(data.Value)) + .Returns(Task.CompletedTask); + + // Act + await _dataProcessingService.ProcessData(); + + // Assert + _apiClientMock.Verify(api => api.FetchDataAsync(), Times.Once); + _repositoryMock.Verify(repo => repo.SaveDataAsync(data), Times.Once); + _queueServiceMock.Verify(queue => queue.PublishMessage(data.Value), Times.Once); + + _loggerMock.Verify( + log => log.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Processing completed")), + null, + It.IsAny>() + ), Times.Once); + } + + ... +} +``` +We will refactor this test class using **GherXunit** to separate the test methods from the Feature definition. +The first step is to define the Feature and Scenarios using the Gherkin syntax. + +### Removing Test Methods to Separate Classes +For that, we will use `partial` classes to separate the test methods from the Feature definition. +The `DataProcessingServiceTests` class will contain the test methods, and the `DataProcessingServiceTests.Features` class will contain the Feature definition. +In the `DataProcessingServiceTests` we will use the `IGherXunit` interface. +It's a marker interface that allows the `DataProcessingServiceTests` class to be recognized as a GherXunit test class. + +> [!IMPORTANT] +> The Output property is used to write messages to the test output. + +```csharp +// DataProcessingServiceTests.cs +public partial class DataProcessingServiceTests: IGherXunit +{ + public ITestOutputHelper Output { get; } + ... + + public DataProcessingServiceTests(..., ITestOutputHelper output) + { + ... + Output = output; + } +} + +// DataProcessingServiceTests.Features.cs +public partial class DataProcessingServiceTests +{ +} +``` + +### Writing the Feature Definition +Now, we will define the Feature and Scenarios using the Gherkin syntax. For that, we will use the `Feature` and `Scenario` attributes. + +```csharp +// DataProcessingServiceTests.Features.cs +[Feature("Data Processing")] +public partial class DataProcessingServiceTests : IAsyncLifetime +{ + [Background] + public async Task InitializeAsync() => await this.ExecuteAscync( + refer: Setup, + steps: "Given the processing service is configured"); + + [Scenario("Successful data processing")] + public async Task ProcessData_ShouldProcessSuccessfully_Scenario() => await this.ExecuteAscync( + refer: ProcessData_ShouldProcessSuccessfully, + steps: """ + Given the external API returns valid data + And the repository saves the data successfully + And the queue service publishes the message successfully + When the processing service is executed + Then the processing is completed successfully + And a confirmation message is logged + """); +} + +// DataProcessingServiceTests.cs +public partial class DataProcessingServiceTests : IGherXunit +{ + ... + + private Task Setup() + { + //TODO: Instantiate the mocks and configure the setup here. + return Task.CompletedTask; + } + + private async Task ProcessData_ShouldProcessSuccessfully() + { + ... + } + + ... +} +``` +In the file `DataProcessingServiceTests.Features.cs`, we define the Feature and Scenarios using the Gherkin syntax. Please note +the method `InitializeAsync` is used to set up the test environment before executing the scenarios. this method is decorated with the `Background` attribute and +calls the `Setup` method to configure the mocks. + +The `ProcessData_ShouldProcessSuccessfully_Scenario` method represents the test scenario and calls the `ProcessData_ShouldProcessSuccessfully` +method to execute the steps. Notice that the `ProcessData_ShouldProcessSuccessfully` does not have the Fact attribute anymore. +It is now a private method that represents the steps of the test scenario. The result of running the test scenarios defined +in the `DataProcessingServiceTests` class would be similar to the following output: + +```gherkindotnet +. +TEST RESULT: 🟢 SUCCESS +⤷ FEATURE Data Processing + ⤷ BACKGROUND Successful data processing + | GIVEN ↘ the processing service is configured +. +TEST RESULT: 🟢 SUCCESS +⤷ FEATURE Data Processing + ⤷ SCENARIO Successful data processing + | GIVEN ↘ the external API returns valid data + | AND ↘ the repository saves the data successfully + | AND ↘ the queue service publishes the message successfully + | WHEN ↘ the processing service is executed + | THEN ↘ the processing is completed successfully + | AND ↘ a confirmation message is logged +``` +The steps shown before are the simplified version of the test class `DataProcessingServiceTests` using **GherXunit**. +It's the first step to make your test code more readable and maintainable. We could go further and refactor the test methods +to be more descriptive and easier to understand, like removing repetitive code and separating concerns. + +We hope this guide helps you get started with **GherXunit**. If you have any questions or need further assistance, please let us know. diff --git a/README.md b/README.md index 3dedc69..6ca7500 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,7 @@ The following code snippet shows a test scenario defined using Gherkin syntax in ```csharp using GherXunit.Annotations; - -namespace BddTests.Samples.Features; +... [Feature("Subscribers see different articles based on their subscription level")] public partial class SubscriptionTest @@ -69,11 +68,6 @@ public partial class SubscriptionTest #### 📌 Example of Step Implementation: The following code snippet shows the implementation of the step methods for the test scenario defined in the `SubscriptionTest` class: ```csharp -using GherXunit.Annotations; -using Xunit.Abstractions; - -namespace BddTests.Samples.Features; - public partial class SubscriptionTest(ITestOutputHelper output): IGherXunit { public ITestOutputHelper Output { get; } = output; @@ -87,21 +81,20 @@ public partial class SubscriptionTest(ITestOutputHelper output): IGherXunit #### 📌 Example of output highlighting the test results: The result of running the test scenarios defined in the `SubscriptionTest` class would be similar to the following output: -```shell -11:11:49.683 |V| Run: 0821bae4-1a1a-447b-807e-2eb5042f1fe5 - Discovery result processing started -11:11:49.688 |V| Run: 0821bae4-1a1a-447b-807e-2eb5042f1fe5 - Discovery result processing finished: (+0 ~2 -0), interrupted: 0 -11:11:49.879 |I| Process /usr/local/share/dotnet/x64/dotnet:21042 has exited with code (0) -11:11:49.879 |I| Output stream: - -SCENARIO ⇲ [🟢]Free subscribers see only the free articles - GIVEN ⇲ Free Frieda has a free subscription - WHEN ⇲ Free Frieda logs in with her valid credentials - THEN ⇲ she sees a Free article - -SCENARIO ⇲ [🔴]Subscriber with a paid subscription can access both free and paid articles - GIVEN ⇲ Paid Patty has a basic-level paid subscription - WHEN ⇲ Paid Patty logs in with her valid credentials - THEN ⇲ she sees a Free article and a Paid article +```gherkindotnet +TEST RESULT: 🟢 SUCCESS +⤷ FEATURE Subscribers see different articles based on their subscription level + ⤷ SCENARIO Free subscribers see only the free articles + | GIVEN ↘ Free Frieda has a free subscription + | WHEN ↘ Free Frieda logs in with her valid credentials + | THEN ↘ she sees a Free article + +TEST RESULT: 🟢 SUCCESS +⤷ FEATURE Subscribers see different articles based on their subscription level + ⤷ SCENARIO Subscriber with a paid subscription can access both free and paid articles + | GIVEN ↘ Paid Patty has a basic-level paid subscription + | WHEN ↘ Paid Patty logs in with her valid credentials + | THEN ↘ she sees a Free article and a Paid article ``` ### 🔎 Is GherXunit for You? diff --git a/README_PTBR.md b/README_PTBR.md index af3c7e7..0e2188b 100644 --- a/README_PTBR.md +++ b/README_PTBR.md @@ -40,9 +40,8 @@ O trecho de código a seguir mostra um cenário de teste definido usando a sinta ```csharp using GherXunit.Annotations; - -namespace BddTests.Samples.Features; - +... + [Feature("Subscribers see different articles based on their subscription level")] public partial class SubscriptionTest { @@ -70,15 +69,10 @@ public partial class SubscriptionTest O trecho de código a seguir mostra a implementação dos métodos de passos para o cenário de teste definido na classe `SubscriptionTest`: ```csharp -using GherXunit.Annotations; -using Xunit.Abstractions; - -namespace BddTests.Samples.Features; - public partial class SubscriptionTest(ITestOutputHelper output): IGherXunit { public ITestOutputHelper Output { get; } = output; - private void WhenPattyLogsSteps() { } + private void WhenPattyLogsSteps() => Assert.True(true); private async Task WhenFriedaLogsSteps() => await Task.CompletedTask; } ``` @@ -90,20 +84,19 @@ public partial class SubscriptionTest(ITestOutputHelper output): IGherXunit O resultado da execução dos cenários de teste definidos na classe `SubscriptionTest` seria semelhante à saída a seguir: ```shell -11:11:49.683 |V| Run: 0821bae4-1a1a-447b-807e-2eb5042f1fe5 - Discovery result processing started -11:11:49.688 |V| Run: 0821bae4-1a1a-447b-807e-2eb5042f1fe5 - Discovery result processing finished: (+0 ~2 -0), interrupted: 0 -11:11:49.879 |I| Process /usr/local/share/dotnet/x64/dotnet:21042 has exited with code (0) -11:11:49.879 |I| Output stream: - -SCENARIO ⇲ [🟢]Free subscribers see only the free articles - GIVEN ⇲ Free Frieda has a free subscription - WHEN ⇲ Free Frieda logs in with her valid credentials - THEN ⇲ she sees a Free article - -SCENARIO ⇲ [🔴]Subscriber with a paid subscription can access both free and paid articles - GIVEN ⇲ Paid Patty has a basic-level paid subscription - WHEN ⇲ Paid Patty logs in with her valid credentials - THEN ⇲ she sees a Free article and a Paid article +TEST RESULT: 🟢 SUCCESS +⤷ FEATURE Subscribers see different articles based on their subscription level + ⤷ SCENARIO Free subscribers see only the free articles + | GIVEN ↘ Free Frieda has a free subscription + | WHEN ↘ Free Frieda logs in with her valid credentials + | THEN ↘ she sees a Free article + +TEST RESULT: 🟢 SUCCESS +⤷ FEATURE Subscribers see different articles based on their subscription level + ⤷ SCENARIO Subscriber with a paid subscription can access both free and paid articles + | GIVEN ↘ Paid Patty has a basic-level paid subscription + | WHEN ↘ Paid Patty logs in with her valid credentials + | THEN ↘ she sees a Free article and a Paid article ``` ### 🔎 O GherXunit é para você? diff --git a/src/base/GherXunit.Core/Attributes.cs b/src/base/GherXunit.Core/Attributes.cs index 3d11a2f..6fdd618 100644 --- a/src/base/GherXunit.Core/Attributes.cs +++ b/src/base/GherXunit.Core/Attributes.cs @@ -6,8 +6,8 @@ namespace GherXunit.Annotations; // Description attributes -public sealed class FeatureAttribute(string description) : DescriptionAttribute($"Feature: {description}"); -public sealed class RuleAttribute(string description) : DescriptionAttribute($"Rule: {description}"); +public sealed class FeatureAttribute(string description) : DescriptionAttribute(description); +public sealed class RuleAttribute(string description) : DescriptionAttribute(description); public sealed class BackgroundAttribute() : DescriptionAttribute("Background"); // Xunit attributes diff --git a/src/base/GherXunit.Core/Backgrounds/BackgroundTest.Context.cs b/src/base/GherXunit.Core/Backgrounds/BackgroundTest.Context.cs new file mode 100644 index 0000000..b060502 --- /dev/null +++ b/src/base/GherXunit.Core/Backgrounds/BackgroundTest.Context.cs @@ -0,0 +1,13 @@ +namespace BddTests.Samples.Backgrounds; + +public class BackgroundContext +{ + public string ContextId { get; } = Guid.NewGuid().ToString(); + public Dictionary OwnersAndBlogs { get; } = new(); + + public BackgroundContext() + { + OwnersAndBlogs.Add("Greg", "Greg's anti-tax rants"); + OwnersAndBlogs.Add("Dr. Bill", "Expensive Therapy"); + } +} \ No newline at end of file diff --git a/src/base/GherXunit.Core/Backgrounds/BackgroundTest.Steps.cs b/src/base/GherXunit.Core/Backgrounds/BackgroundTest.Steps.cs new file mode 100644 index 0000000..3760b31 --- /dev/null +++ b/src/base/GherXunit.Core/Backgrounds/BackgroundTest.Steps.cs @@ -0,0 +1,49 @@ +using System.Runtime.CompilerServices; +using GherXunit.Annotations; +using Xunit.Abstractions; + +namespace BddTests.Samples.Backgrounds; + +public partial class BackgroundTest(ITestOutputHelper output) : IGherXunit, IAsyncLifetime +{ + public ITestOutputHelper Output { get; } = output; + private BackgroundContext? Context { get; set; } + + private Task Setup() + { + Context = new BackgroundContext(); + return Task.CompletedTask; + } + + private async Task DrBillPostToBlogStep() + { + var result = await PostToBlog("Dr. Bill", "Expensive Therapy"); + Assert.Equal("Your article was published.", result); + } + + private async Task DrBillPostToBlogFailStep() + { + var result = await PostToBlog("Dr. Bill", "Greg's anti-tax rants"); + Assert.Equal("Hey! That's not your blog!", result); + } + + private async Task GregPostToBlogStep() + { + var result = await PostToBlog("Greg", "Greg's anti-tax rants"); + Assert.Equal("Your article was published.", result); + } + + private async Task PostToBlog(string owner, string blog) + { + if (!Context!.OwnersAndBlogs.TryGetValue(owner, out string? value) || value != blog) + return "Hey! That's not your blog!"; + + await Task.Yield(); + return "Your article was published."; + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/base/GherXunit.Core/Backgrounds/BackgroundTest.cs b/src/base/GherXunit.Core/Backgrounds/BackgroundTest.cs new file mode 100644 index 0000000..d0b13d0 --- /dev/null +++ b/src/base/GherXunit.Core/Backgrounds/BackgroundTest.cs @@ -0,0 +1,45 @@ +using GherXunit.Annotations; +using Xunit.Abstractions; + +namespace BddTests.Samples.Backgrounds; + +[Feature("Multiple site support Only blog owners can post to a blog, except administrators, who can post to all blogs.")] +public partial class BackgroundTest +{ + [Background] + public async Task InitializeAsync() => await this.BackgroundAsync( + refer: Setup, + steps: """ + Given a global administrator named <<"Greg">> + And a blog named <<"Greg's anti-tax rants">> + And a customer named <<"Dr. Bill">> + And a blog named <<"Expensive Therapy">> owned by <<"Dr. Bill">> + """); + + [Scenario("Dr. Bill posts to his own blog")] + public async Task DrBillPostToBlog() => await this.ExecuteAscync( + refer: DrBillPostToBlogStep, + steps: """ + Given I am logged in as Dr. Bill + When I try to post to "Expensive Therapy" + Then I should see "Your article was published." + """); + + [Scenario("Dr. Bill tries to post to somebody else's blog, and fails")] + public async Task DrBillPostToBlogFail() => await this.ExecuteAscync( + refer: DrBillPostToBlogFailStep, + steps: """ + Given I am logged in as Dr. Bill + When I try to post to "Greg's anti-tax rants" + Then I should see "Hey! That's not your blog!" + """); + + [Scenario("Greg posts to a client's blog")] + public async Task GregPostToBlog() => await this.ExecuteAscync( + refer: GregPostToBlogStep, + steps: """ + Given I am logged in as Greg + When I try to post to "Expensive Therapy" + Then I should see "Your article was published." + """); +} \ No newline at end of file diff --git a/src/base/GherXunit.Core/Methods.cs b/src/base/GherXunit.Core/Methods.cs index ccc613f..c6b4c6b 100644 --- a/src/base/GherXunit.Core/Methods.cs +++ b/src/base/GherXunit.Core/Methods.cs @@ -8,64 +8,68 @@ namespace GherXunit.Annotations; public static class GherXunitSteps { // Async methods - public static async Task ExecuteAscync(this IGherXunitStep feature, string steps) => await ExecuteAscync(feature, null, steps, []); - public static async Task ExecuteAscync(this IGherXunitStep feature, Delegate refer, string steps) => await ExecuteAscync(feature, refer.Method, steps); - public static async Task ExecuteAscync(this IGherXunitStep feature, Delegate refer, object[] param, string steps) => await ExecuteAscync(feature, refer.Method, steps, param); - public static async Task NonExecutableAsync(this IGherXunitStep feature, string? steps = null) => await ExecuteAscync(feature, null, steps, []); + public static async Task BackgroundAsync(this IGherXunitStep scenario, Delegate refer, string steps) => await ExecuteAscync(scenario, refer.Method, steps, true); + public static async Task ExecuteAscync(this IGherXunitStep scenario, string steps) => await ExecuteAscync(scenario, null, steps, false); + public static async Task ExecuteAscync(this IGherXunitStep scenario, Delegate refer, string steps) => await ExecuteAscync(scenario, refer.Method, steps); + public static async Task ExecuteAscync(this IGherXunitStep scenario, Delegate refer, object[] param, string steps) => await ExecuteAscync(scenario, refer.Method, steps, false, param); + public static async Task NonExecutableAsync(this IGherXunitStep scenario, string? steps = null) => await ExecuteAscync(scenario, null, steps, false); // Sync methods - public static void Execute(this IGherXunitStep feature, string steps) => Execute(feature, null, steps, []); - public static void Execute(this IGherXunitStep feature, Delegate refer, string steps) => Execute(feature, refer.Method, steps); - public static void Execute(this IGherXunitStep feature, Delegate refer, object[] param, string steps) => Execute(feature, refer.Method, steps, param); - public static void NonExecutable(this IGherXunitStep feature, string? steps) => Execute(feature, null, steps, []); + public static void Background(this IGherXunitStep scenario, Delegate refer, string steps) => Execute(scenario, refer.Method, steps, true); + public static void Execute(this IGherXunitStep scenario, string steps) => Execute(scenario, null, steps, false); + public static void Execute(this IGherXunitStep scenario, Delegate refer, string steps) => Execute(scenario, refer.Method, steps); + public static void Execute(this IGherXunitStep scenario, Delegate refer, object[] param, string steps) => Execute(scenario, refer.Method, steps, false, param); + public static void NonExecutable(this IGherXunitStep feature, string? steps) => Execute(feature, null, steps, false, []); // Private methods - private static void Execute(this IGherXunitStep feature, MethodInfo? method, string? steps, params object?[] param) + private static void Execute(this IGherXunitStep scenario, MethodInfo? method, string? steps, + bool isBackground = false, params object?[] param) { try { - method?.Invoke(feature, param); - feature.Write(method?.Name, steps); + method?.Invoke(scenario, param); + scenario.Write(method?.Name, steps, false, isBackground); } catch (Exception) { - feature.Write(method?.Name, steps, true); + scenario.Write(method?.Name, steps, true, isBackground); throw; } } - private static async Task ExecuteAscync(this IGherXunitStep feature, MethodInfo? method, string? steps, - params object?[] param) + private static async Task ExecuteAscync(this IGherXunitStep scenario, MethodInfo? method, string? steps, + bool isBackground = false, params object?[] param) { try { - var task = method is null ? Task.CompletedTask : (Task)method.Invoke(feature, param)!; + var task = method is null ? Task.CompletedTask : (Task)method.Invoke(scenario, param)!; await task; - feature.Write(method?.Name, steps); + scenario.Write(method?.Name, steps, false, isBackground); } catch (Exception) { - feature.Write(method?.Name, steps, true); + scenario.Write(method?.Name, steps, true, isBackground); throw; } } - private static void Write(this IGherXunitStep feature, string? methodName, string? steps, bool isException = false) + private static void Write(this IGherXunitStep scenario, string? methodName, string? steps, bool isException = false, bool isBackground = false) { if (steps is null) return; - var status = isException ? "🔴" : "🟢"; - var iTest = GetTest(feature, out var output); + var iTest = GetTest(scenario, out var output); + var featuresText = TryGetFeature(iTest); + var statusResult = $"TEST RESULT: {(isException ? "\U0001F534 FAIL" : "\U0001F7E2 SUCCESS")}\r\n"; - var display = iTest is null - ? $"Scenario [{status}]{methodName}\r\n{steps}" - : $"Scenario [{status}]{iTest.DisplayName}\r\n{steps}"; + var scenarioText = iTest is null + ? $"{(isBackground ? "Background" : "Scenario")} {methodName}\r\n" + : $"{(isBackground ? "Background" : "Scenario")} {iTest.DisplayName}\r\n"; var stepString = new StepStringHandler(); - stepString.AppendLiteral(display); + stepString.AppendLiteral($"{statusResult}{featuresText}{scenarioText}{steps}"); - output?.WriteLine(string.Empty); + output?.WriteLine("."); output?.WriteLine(stepString.ToString()); Console.WriteLine(string.Empty); Console.WriteLine(stepString.ToString()); @@ -83,4 +87,15 @@ private static void Write(this IGherXunitStep feature, string? methodName, strin outputHelper = output; return testField?.GetValue(output) as ITest; } + + private static string? TryGetFeature(ITest? test) + { + if (test is null) return null; + + var attributes = test.TestCase.TestMethod.TestClass.Class.GetCustomAttributes(typeof(FeatureAttribute)); + var attribute = attributes.FirstOrDefault(); + + var result = attribute?.GetNamedArgument("Description"); + return result is null ? null : $"Feature {result}\r\n"; + } } \ No newline at end of file diff --git a/src/base/GherXunit.Core/StepStringHandler.cs b/src/base/GherXunit.Core/StepStringHandler.cs index 907806f..45e2c02 100644 --- a/src/base/GherXunit.Core/StepStringHandler.cs +++ b/src/base/GherXunit.Core/StepStringHandler.cs @@ -15,10 +15,12 @@ public ref struct StepStringHandler() private string HighlightKeyword(string input) { return input - .Replace("Scenario", $"{"SCENARIO",8} ⇲") // Verde - .Replace("Given", $"{"GIVEN",8} ⇲") // Verde - .Replace("When", $"{"WHEN",8} ⇲") // Azul - .Replace("Then", $"{"THEN",8} ⇲") // Amarelo - .Replace("And", $"{"AND",8} ⇲"); // Ciano + .Replace("Feature", $"{"\u2937 FEATURE",8}") + .Replace("Scenario", $"{"\u2937 SCENARIO",13}") + .Replace("Background", $"{"\u2937 BACKGROUND",15}") + .Replace("Given", $"{"|",7} {"GIVEN",5} \u2198") + .Replace("When", $"{"|",7} {"WHEN" ,5} \u2198") + .Replace("Then", $"{"|",7} {"THEN" ,5} \u2198") + .Replace("And", $"{"|",7} {"AND" ,5} \u2198"); } } \ No newline at end of file diff --git a/src/lib/GherXunit/GherXunit.Content.Attributes.cs b/src/lib/GherXunit/GherXunit.Content.Attributes.cs index 246326f..f04f299 100644 --- a/src/lib/GherXunit/GherXunit.Content.Attributes.cs +++ b/src/lib/GherXunit/GherXunit.Content.Attributes.cs @@ -17,8 +17,8 @@ public struct GherXunitAttributes namespace GherXunit.Annotations; // Description attributes - public sealed class FeatureAttribute(string description) : DescriptionAttribute($"Feature: {description}"); - public sealed class RuleAttribute(string description) : DescriptionAttribute($"Rule: {description}"); + public sealed class FeatureAttribute(string description) : DescriptionAttribute(description); + public sealed class RuleAttribute(string description) : DescriptionAttribute(description); public sealed class BackgroundAttribute() : DescriptionAttribute("Background"); // Xunit attributes diff --git a/src/lib/GherXunit/GherXunit.Content.Methods.cs b/src/lib/GherXunit/GherXunit.Content.Methods.cs index 3a7af33..2f95d73 100644 --- a/src/lib/GherXunit/GherXunit.Content.Methods.cs +++ b/src/lib/GherXunit/GherXunit.Content.Methods.cs @@ -18,64 +18,68 @@ namespace GherXunit.Annotations; public static class GherXunitSteps { // Async methods - public static async Task ExecuteAscync(this IGherXunitStep feature, string steps) => await ExecuteAscync(feature, null, steps, []); - public static async Task ExecuteAscync(this IGherXunitStep feature, Delegate refer, string steps) => await ExecuteAscync(feature, refer.Method, steps); - public static async Task ExecuteAscync(this IGherXunitStep feature, Delegate refer, object[] param, string steps) => await ExecuteAscync(feature, refer.Method, steps, param); - public static async Task NonExecutableAsync(this IGherXunitStep feature, string? steps = null) => await ExecuteAscync(feature, null, steps, []); + public static async Task BackgroundAsync(this IGherXunitStep scenario, Delegate refer, string steps) => await ExecuteAscync(scenario, refer.Method, steps, true); + public static async Task ExecuteAscync(this IGherXunitStep scenario, string steps) => await ExecuteAscync(scenario, null, steps, false); + public static async Task ExecuteAscync(this IGherXunitStep scenario, Delegate refer, string steps) => await ExecuteAscync(scenario, refer.Method, steps); + public static async Task ExecuteAscync(this IGherXunitStep scenario, Delegate refer, object[] param, string steps) => await ExecuteAscync(scenario, refer.Method, steps, false, param); + public static async Task NonExecutableAsync(this IGherXunitStep scenario, string? steps = null) => await ExecuteAscync(scenario, null, steps, false); // Sync methods - public static void Execute(this IGherXunitStep feature, string steps) => Execute(feature, null, steps, []); - public static void Execute(this IGherXunitStep feature, Delegate refer, string steps) => Execute(feature, refer.Method, steps); - public static void Execute(this IGherXunitStep feature, Delegate refer, object[] param, string steps) => Execute(feature, refer.Method, steps, param); - public static void NonExecutable(this IGherXunitStep feature, string? steps) => Execute(feature, null, steps, []); + public static void Background(this IGherXunitStep scenario, Delegate refer, string steps) => Execute(scenario, refer.Method, steps, true); + public static void Execute(this IGherXunitStep scenario, string steps) => Execute(scenario, null, steps, false); + public static void Execute(this IGherXunitStep scenario, Delegate refer, string steps) => Execute(scenario, refer.Method, steps); + public static void Execute(this IGherXunitStep scenario, Delegate refer, object[] param, string steps) => Execute(scenario, refer.Method, steps, false, param); + public static void NonExecutable(this IGherXunitStep feature, string? steps) => Execute(feature, null, steps, false, []); // Private methods - private static void Execute(this IGherXunitStep feature, MethodInfo? method, string? steps, params object?[] param) + private static void Execute(this IGherXunitStep scenario, MethodInfo? method, string? steps, + bool isBackground = false, params object?[] param) { try { - method?.Invoke(feature, param); - feature.Write(method?.Name, steps); + method?.Invoke(scenario, param); + scenario.Write(method?.Name, steps, false, isBackground); } catch (Exception) { - feature.Write(method?.Name, steps, true); + scenario.Write(method?.Name, steps, true, isBackground); throw; } } - private static async Task ExecuteAscync(this IGherXunitStep feature, MethodInfo? method, string? steps, - params object?[] param) + private static async Task ExecuteAscync(this IGherXunitStep scenario, MethodInfo? method, string? steps, + bool isBackground = false, params object?[] param) { try { - var task = method is null ? Task.CompletedTask : (Task)method.Invoke(feature, param)!; + var task = method is null ? Task.CompletedTask : (Task)method.Invoke(scenario, param)!; await task; - feature.Write(method?.Name, steps); + scenario.Write(method?.Name, steps, false, isBackground); } catch (Exception) { - feature.Write(method?.Name, steps, true); + scenario.Write(method?.Name, steps, true, isBackground); throw; } } - private static void Write(this IGherXunitStep feature, string? methodName, string? steps, bool isException = false) + private static void Write(this IGherXunitStep scenario, string? methodName, string? steps, bool isException = false, bool isBackground = false) { if (steps is null) return; - var status = isException ? "🔴" : "🟢"; - var iTest = GetTest(feature, out var output); + var iTest = GetTest(scenario, out var output); + var featuresText = TryGetFeature(iTest); + var statusResult = $"TEST RESULT: {(isException ? "\U0001F534 FAIL" : "\U0001F7E2 SUCCESS")}\r\n"; - var display = iTest is null - ? $"Scenario [{status}]{methodName}\r\n{steps}" - : $"Scenario [{status}]{iTest.DisplayName}\r\n{steps}"; + var scenarioText = iTest is null + ? $"{(isBackground ? "Background" : "Scenario")} {methodName}\r\n" + : $"{(isBackground ? "Background" : "Scenario")} {iTest.DisplayName}\r\n"; var stepString = new StepStringHandler(); - stepString.AppendLiteral(display); + stepString.AppendLiteral($"{statusResult}{featuresText}{scenarioText}{steps}"); - output?.WriteLine(string.Empty); + output?.WriteLine("."); output?.WriteLine(stepString.ToString()); Console.WriteLine(string.Empty); Console.WriteLine(stepString.ToString()); @@ -93,6 +97,17 @@ private static void Write(this IGherXunitStep feature, string? methodName, strin outputHelper = output; return testField?.GetValue(output) as ITest; } + + private static string? TryGetFeature(ITest? test) + { + if (test is null) return null; + + var attributes = test.TestCase.TestMethod.TestClass.Class.GetCustomAttributes(typeof(FeatureAttribute)); + var attribute = attributes.FirstOrDefault(); + + var result = attribute?.GetNamedArgument("Description"); + return result is null ? null : $"Feature {result}\r\n"; + } } """; } \ No newline at end of file diff --git a/src/lib/GherXunit/GherXunit.Content.StringHandler.cs b/src/lib/GherXunit/GherXunit.Content.StringHandler.cs index 1eca5fc..e67efe3 100644 --- a/src/lib/GherXunit/GherXunit.Content.StringHandler.cs +++ b/src/lib/GherXunit/GherXunit.Content.StringHandler.cs @@ -21,11 +21,13 @@ public ref struct StepStringHandler() private string HighlightKeyword(string input) { return input - .Replace("Scenario", $"{"SCENARIO",8} ⇲") // Verde - .Replace("Given", $"{"GIVEN",8} ⇲") // Verde - .Replace("When", $"{"WHEN",8} ⇲") // Azul - .Replace("Then", $"{"THEN",8} ⇲") // Amarelo - .Replace("And", $"{"AND",8} ⇲"); // Ciano + .Replace("Feature", $"{"\u2937 FEATURE",8}") + .Replace("Scenario", $"{"\u2937 SCENARIO",13}") + .Replace("Background", $"{"\u2937 BACKGROUND",15}") + .Replace("Given", $"{"|",7} {"GIVEN",5} \u2198") + .Replace("When", $"{"|",7} {"WHEN" ,5} \u2198") + .Replace("Then", $"{"|",7} {"THEN" ,5} \u2198") + .Replace("And", $"{"|",7} {"AND" ,5} \u2198"); } } """; diff --git a/src/lib/GherXunit/GherXunit.csproj b/src/lib/GherXunit/GherXunit.csproj index 3caf059..21da8b5 100644 --- a/src/lib/GherXunit/GherXunit.csproj +++ b/src/lib/GherXunit/GherXunit.csproj @@ -7,7 +7,7 @@ true true GherXunit - 1.1.0 + 1.2.0 Yan Justino Emergingcode GherXunit emerges as a viable alternative for those seeking an approach that combines the expressiveness of BDD with the well-established structure of xUnit. diff --git a/src/sample/BddSample/BddSample.csproj b/src/sample/BddSample/BddSample.csproj index 9debf5b..a741bc2 100644 --- a/src/sample/BddSample/BddSample.csproj +++ b/src/sample/BddSample/BddSample.csproj @@ -11,7 +11,9 @@ + + diff --git a/src/sample/BddSample/Samples/Backgrounds/BackgroundTest.Steps.cs b/src/sample/BddSample/Samples/Backgrounds/BackgroundTest.Steps.cs index 96d45a5..3760b31 100644 --- a/src/sample/BddSample/Samples/Backgrounds/BackgroundTest.Steps.cs +++ b/src/sample/BddSample/Samples/Backgrounds/BackgroundTest.Steps.cs @@ -4,19 +4,15 @@ namespace BddTests.Samples.Backgrounds; -public partial class BackgroundTest : IGherXunit +public partial class BackgroundTest(ITestOutputHelper output) : IGherXunit, IAsyncLifetime { - public ITestOutputHelper Output { get; } - private BackgroundContext Context { get; } + public ITestOutputHelper Output { get; } = output; + private BackgroundContext? Context { get; set; } - public BackgroundTest(ITestOutputHelper output, BackgroundContext context) + private Task Setup() { - Output = output; - Context = context; - Output.WriteLine("CONTEXT {0}", Context.ContextId); - Output.WriteLine(""); - Setup(); - Output.WriteLine(""); + Context = new BackgroundContext(); + return Task.CompletedTask; } private async Task DrBillPostToBlogStep() @@ -39,10 +35,15 @@ private async Task GregPostToBlogStep() private async Task PostToBlog(string owner, string blog) { - if (!Context.OwnersAndBlogs.TryGetValue(owner, out string? value) || value != blog) + if (!Context!.OwnersAndBlogs.TryGetValue(owner, out string? value) || value != blog) return "Hey! That's not your blog!"; await Task.Yield(); return "Your article was published."; } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/src/sample/BddSample/Samples/Backgrounds/BackgroundTest.cs b/src/sample/BddSample/Samples/Backgrounds/BackgroundTest.cs index 16be6cf..d0b13d0 100644 --- a/src/sample/BddSample/Samples/Backgrounds/BackgroundTest.cs +++ b/src/sample/BddSample/Samples/Backgrounds/BackgroundTest.cs @@ -7,7 +7,8 @@ namespace BddTests.Samples.Backgrounds; public partial class BackgroundTest { [Background] - public void Setup() => this.Execute( + public async Task InitializeAsync() => await this.BackgroundAsync( + refer: Setup, steps: """ Given a global administrator named <<"Greg">> And a blog named <<"Greg's anti-tax rants">> diff --git a/src/sample/BddSample/Samples/Outline/ScenarioOutlineTest.cs b/src/sample/BddSample/Samples/Outline/ScenarioOutlineTest.cs index 155c432..b0ca697 100644 --- a/src/sample/BddSample/Samples/Outline/ScenarioOutlineTest.cs +++ b/src/sample/BddSample/Samples/Outline/ScenarioOutlineTest.cs @@ -2,7 +2,7 @@ namespace BddTests.Samples.Outline; -[Feature("Scenario Outline")] +[Feature("Outline")] public partial class ScenarioOutlineTest { [Scenarios("eating")] diff --git a/src/sample/BddSample/Samples/Refactoring/ExemploA/ProcessamentoServiceTests.cs b/src/sample/BddSample/Samples/Refactoring/ExemploA/ProcessamentoServiceTests.cs new file mode 100644 index 0000000..47907d8 --- /dev/null +++ b/src/sample/BddSample/Samples/Refactoring/ExemploA/ProcessamentoServiceTests.cs @@ -0,0 +1,221 @@ +using GherXunit.Annotations; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit.Abstractions; + +namespace BddTests.Samples.Refactoring.ExemploA; + +[Feature("Data Processing")] +public partial class DataProcessingServiceTests : IAsyncLifetime +{ + [Background] + public async Task InitializeAsync() => await this.BackgroundAsync( + refer: Setup, + steps: "Given the processing service is configured"); + + [Scenario("Successful data processing")] + public async Task ProcessData_ShouldProcessSuccessfully_Scenario() => await this.ExecuteAscync( + refer: ProcessData_ShouldProcessSuccessfully, + steps: """ + Given the external API returns valid data + And the repository saves the data successfully + And the queue service publishes the message successfully + When the processing service is executed + Then the processing is completed successfully + And a confirmation message is logged + """); +} + +public partial class DataProcessingServiceTests(ITestOutputHelper output) : IGherXunit +{ + private Mock _apiClientMock; + private Mock _repositoryMock; + private Mock _queueServiceMock; + private Mock> _loggerMock; + private DataProcessingService _dataProcessingService; + + public ITestOutputHelper? Output { get; } = output; + + private Task Setup() + { + _apiClientMock = new Mock(); + _repositoryMock = new Mock(); + _queueServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _dataProcessingService = new DataProcessingService( + _apiClientMock.Object, + _repositoryMock.Object, + _queueServiceMock.Object, + _loggerMock.Object + ); + return Task.CompletedTask; + } + + private async Task ProcessData_ShouldProcessSuccessfully() + { + // Arrange + var data = new Data { Value = "Test" }; + + _apiClientMock.Setup(api => api.FetchDataAsync()) + .ReturnsAsync(data); + + _repositoryMock.Setup(repo => repo.SaveDataAsync(data)) + .Returns(Task.CompletedTask); + + _queueServiceMock.Setup(queue => queue.PublishMessage(data.Value)) + .Returns(Task.CompletedTask); + + // Act + await _dataProcessingService.ProcessData(); + + // Assert + _apiClientMock.Verify(api => api.FetchDataAsync(), Times.Once); + _repositoryMock.Verify(repo => repo.SaveDataAsync(data), Times.Once); + _queueServiceMock.Verify(queue => queue.PublishMessage(data.Value), Times.Once); + + _loggerMock.Verify( + log => log.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Processing completed")), + null, + It.IsAny>() + ), Times.Once); + } + + [Fact] + public async Task ProcessData_ShouldThrowExceptionWhenApiFails() + { + // Arrange + _apiClientMock.Setup(api => api.FetchDataAsync()) + .ThrowsAsync(new HttpRequestException("API failure")); + + // Act & Assert + await Assert.ThrowsAsync(() => _dataProcessingService.ProcessData()); + + _repositoryMock.Verify(repo => repo.SaveDataAsync(It.IsAny()), Times.Never); + _queueServiceMock.Verify(queue => queue.PublishMessage(It.IsAny()), Times.Never); + + _loggerMock.Verify( + log => log.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Error fetching data from API")), + It.IsAny(), + It.IsAny>() + ), Times.Once); + } + + [Fact] + public async Task ProcessData_ShouldThrowExceptionWhenDatabaseFails() + { + // Arrange + var data = new Data { Value = "Test" }; + + _apiClientMock.Setup(api => api.FetchDataAsync()) + .ReturnsAsync(data); + + _repositoryMock.Setup(repo => repo.SaveDataAsync(data)) + .ThrowsAsync(new Exception("Database failure")); + + // Act & Assert + await Assert.ThrowsAsync(() => _dataProcessingService.ProcessData()); + + _queueServiceMock.Verify(queue => queue.PublishMessage(It.IsAny()), Times.Never); + + _loggerMock.Verify( + log => log.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Error saving data to the database")), + It.IsAny(), + It.IsAny>() + ), Times.Once); + } + + [Fact] + public async Task ProcessData_ShouldThrowExceptionWhenQueueFails() + { + // Arrange + var data = new Data { Value = "Test" }; + + _apiClientMock.Setup(api => api.FetchDataAsync()) + .ReturnsAsync(data); + + _repositoryMock.Setup(repo => repo.SaveDataAsync(data)) + .Returns(Task.CompletedTask); + + _queueServiceMock.Setup(queue => queue.PublishMessage(data.Value)) + .ThrowsAsync(new Exception("Queue failure")); + + // Act & Assert + await Assert.ThrowsAsync(() => _dataProcessingService.ProcessData()); + + _loggerMock.Verify( + log => log.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Error publishing message to the queue")), + It.IsAny(), + It.IsAny>() + ), Times.Once); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } +} + +public class Data +{ + public string Value { get; set; } = ""; +} + +public class DataProcessingService( + IApiClient apiClient, + IRepository repository, + IQueueService queueService, + ILogger logger) +{ + public async Task ProcessData() + { + try + { + var data = await apiClient.FetchDataAsync(); + await repository.SaveDataAsync(data); + await queueService.PublishMessage(data.Value); + + logger.LogInformation("Processing completed"); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error fetching data from API"); + throw; + } + catch (Exception ex) + { + if (ex.Message == "Database failure") + logger.LogError(ex, "Error saving data to the database"); + else + logger.LogError(ex, "Error publishing message to the queue"); + throw; + } + } +} + +public interface IRepository +{ + Task SaveDataAsync(Data data); +} + +public interface IQueueService +{ + Task PublishMessage(string value); +} + +public interface IApiClient +{ + Task FetchDataAsync(); +} \ No newline at end of file