Skip to content

Commit

Permalink
xRetry.Reqnroll
Browse files Browse the repository at this point in the history
Initial project added by copying xRetry.SpecFlow, renaming & replacing strings, and doing the bare-minimum to make it build.

No Docs (except the generated readme copied from xRetry.SpecFlow)
No Tests
  • Loading branch information
JoshKeegan committed May 23, 2024
1 parent d08ce32 commit 0c039b6
Show file tree
Hide file tree
Showing 13 changed files with 433 additions and 0 deletions.
7 changes: 7 additions & 0 deletions src/xRetry.Reqnroll/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace xRetry.Reqnroll
{
internal static class Constants
{
public const string RETRY_TAG = "retry";
}
}
25 changes: 25 additions & 0 deletions src/xRetry.Reqnroll/GeneratorPlugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Reqnroll.Generator.Plugins;
using Reqnroll.Generator.UnitTestProvider;
using Reqnroll.Infrastructure;
using Reqnroll.UnitTestProvider;
using xRetry.Reqnroll;
using xRetry.Reqnroll.Parsers;

[assembly: GeneratorPlugin(typeof(GeneratorPlugin))]
namespace xRetry.Reqnroll
{
public class GeneratorPlugin : IGeneratorPlugin
{
public void Initialize(GeneratorPluginEvents generatorPluginEvents, GeneratorPluginParameters generatorPluginParameters,
UnitTestProviderConfiguration unitTestProviderConfiguration)
{
generatorPluginEvents.CustomizeDependencies += customiseDependencies;
}

private void customiseDependencies(object sender, CustomizeDependenciesEventArgs eventArgs)
{
eventArgs.ObjectContainer.RegisterTypeAs<RetryTagParser, IRetryTagParser>();
eventArgs.ObjectContainer.RegisterTypeAs<TestGeneratorProvider, IUnitTestGeneratorProvider>();
}
}
}
7 changes: 7 additions & 0 deletions src/xRetry.Reqnroll/Parsers/IRetryTagParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace xRetry.Reqnroll.Parsers
{
public interface IRetryTagParser
{
RetryTag Parse(string tag);
}
}
39 changes: 39 additions & 0 deletions src/xRetry.Reqnroll/Parsers/RetryTag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;

namespace xRetry.Reqnroll.Parsers
{
public class RetryTag : IEquatable<RetryTag>
{
public readonly int? MaxRetries;
public readonly int? DelayBetweenRetriesMs;

public RetryTag(int? maxRetries, int? delayBetweenRetriesMs)
{
MaxRetries = maxRetries;
DelayBetweenRetriesMs = delayBetweenRetriesMs;
}

public bool Equals(RetryTag other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return MaxRetries == other.MaxRetries && DelayBetweenRetriesMs == other.DelayBetweenRetriesMs;
}

public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != this.GetType()) return false;
return Equals((RetryTag) obj);
}

public override int GetHashCode()
{
unchecked
{
return (MaxRetries.GetHashCode() * 397) ^ DelayBetweenRetriesMs.GetHashCode();
}
}
}
}
41 changes: 41 additions & 0 deletions src/xRetry.Reqnroll/Parsers/RetryTagParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Text.RegularExpressions;

namespace xRetry.Reqnroll.Parsers
{
public class RetryTagParser : IRetryTagParser
{
// unescaped: ^retry(\(([0-9]+)(,([0-9]+))?\))?$
private readonly Regex regex = new Regex($"^{Constants.RETRY_TAG}(\\(([0-9]+)(,([0-9]+))?\\))?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);

public RetryTag Parse(string tag)
{
if (tag == null)
{
throw new ArgumentNullException(nameof(tag));
}

int? maxRetries = null;
int? delayBetweenRetriesMs = null;

Match match = regex.Match(tag);
if (match.Success)
{
// Group 2 is max retries
if (match.Groups[2].Success)
{
maxRetries = int.Parse(match.Groups[2].Value);

// Group 4 is delay between retries
if (match.Groups[4].Success)
{
delayBetweenRetriesMs = int.Parse(match.Groups[4].Value);
}
}
}

return new RetryTag(maxRetries, delayBetweenRetriesMs);
}
}
}
85 changes: 85 additions & 0 deletions src/xRetry.Reqnroll/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
[//]: # (This file is auto-generated, do not modify it directly. Instead, update the files under docs/)


[//]: \# (Src: xRetry.Reqnroll/header.md)

# xRetry.Reqnroll
Retry flickering test cases for Reqnroll when using xUnit.

[//]: \# (Src: ciBadge.md)

[![pipeline status](https://github.com/JoshKeegan/xRetry/actions/workflows/cicd.yaml/badge.svg)](https://github.com/JoshKeegan/xRetry/actions)


[//]: \# (Src: whenToUse.md)

## When to use this
This is intended for use on flickering tests, where the reason for failure is an external
dependency and the failure is transient, e.g:
- HTTP request over the network
- Database call that could deadlock, timeout etc...

Whenever a test includes real-world infrastructure, particularly when communicated with via the
internet, there is a risk of the test randomly failing so we want to try and run it again.
This is the intended use case of the library.

If you have a test that covers some flaky code, where sporadic failures are caused by a bug,
this library should **not** be used to cover it up!

[//]: \# (Src: xRetry.Reqnroll/usage.md)

## Usage: Reqnroll 3

Add the [`xRetry.Reqnroll` NuGet package](https://www.nuget.org/packages/xRetry.Reqnroll "xRetry NuGet.Reqnroll package") to your project.

### Scenarios (and outlines)

Above any scenario or scenario outline that should be retried, add a `@retry` tag, e.g:

```gherkin
@retry
Scenario: Retry three times by default
When I increment the default retry count
Then the default result should be 3
```

This will attempt to run the test until it passes, up to 3 times by default.
You can optionally specify a number of times to attempt to run the test in brackets, e.g. `@retry(5)`.

You can also optionally specify a delay between each retry (in milliseconds) as a second
parameter, e.g. `@retry(5,100)` will run your test up to 5 times, waiting 100ms between each attempt.
Note that you must not include a space between the parameters, as Cucumber/Reqnroll uses
a space to separate tags, i.e. `@retry(5, 100)` would not work due to the space after the comma.

### Features

You can also make every test in a feature retryable by adding the `@retry` tag to the feature, e.g:

```gherkin
@retry
Feature: Retryable Feature
Scenario: Retry scenario three times by default
When I increment the retry count
Then the result should be 3
```

All options that can be used against an individual scenario can also be applied like this at the feature level.
If a `@retry` tag exists on both the feature and a scenario within that feature, the tag on the scenario will take
precedent over the one on the feature. This is useful if you wanted all scenarios in a feature to be retried
by default but for some cases also wanted to wait some time before each retry attempt. You can also use this to prevent a specific scenario not be retried, even though it is within a feature with a `@retry` tag, by adding `@retry(1)` to the scenario.


[//]: \# (Src: logs.md)

## Viewing retry logs
By default, you won't see whether your tests are being retried as we make this information available
via the xunit diagnostic logs but test runners will hide these detailed logs by default.
To enable them you must configure your xUnit test project to have `diagnosticMessages` set to `true` in the `xunit.runner.json`.
See the [xUnit docs](https://xunit.net/docs/configuration-files) for a full setup guide of their config file, or see
this projects own unit tests which has been set up with this enabled.

[//]: \# (Src: issues.md)

## Issues
If you find a bug whilst using this library, please report it [on GitHub](https://github.com/JoshKeegan/xRetry/issues).
14 changes: 14 additions & 0 deletions src/xRetry.Reqnroll/SkipException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;

// ReSharper disable once CheckNamespace
// ReSharper disable once IdentifierTypo
namespace Xunit
{
/// <summary>
/// Do not use.
/// Exists purely as a marker to replicate the exception thrown by Xunit.SkippableFact that Reqnroll.xUnit
/// makes use of. That way we can intercept the exception that is throwing without also having our own runtime
/// plugin, or adding a direct dependency on either of these other libraries.
/// </summary>
internal class SkipException : Exception { }
}
143 changes: 143 additions & 0 deletions src/xRetry.Reqnroll/TestGeneratorProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System;
using System.CodeDom;
using System.Collections.Generic;
using System.Linq;
using Reqnroll.Generator;
using Reqnroll.Generator.CodeDom;
using Reqnroll.Generator.UnitTestProvider;
using xRetry.Reqnroll.Parsers;

namespace xRetry.Reqnroll
{
public class TestGeneratorProvider : XUnit2TestGeneratorProvider
{
private const string IGNORE_TAG = "ignore";
private const string RETRY_FACT_ATTRIBUTE = "xRetry.RetryFact";
private const string RETRY_THEORY_ATTRIBUTE = "xRetry.RetryTheory";

private readonly IRetryTagParser retryTagParser;

public TestGeneratorProvider(CodeDomHelper codeDomHelper, IRetryTagParser retryTagParser)
: base(codeDomHelper)
{
this.retryTagParser = retryTagParser;
}

// Called for scenario outlines, even when it has no tags.
// We don't yet have access to tags against the scenario at this point, but can handle feature tags now.
public override void SetRowTest(TestClassGenerationContext generationContext, CodeMemberMethod testMethod, string scenarioTitle)
{
base.SetRowTest(generationContext, testMethod, scenarioTitle);

string[] featureTags = generationContext.Feature.Tags.Select(t => stripLeadingAtSign(t.Name)).ToArray();

applyRetry(featureTags, Enumerable.Empty<string>(), testMethod);
}

// Called for scenarios, even when it has no tags.
// We don't yet have access to tags against the scenario at this point, but can handle feature tags now.
public override void SetTestMethod(TestClassGenerationContext generationContext, CodeMemberMethod testMethod, string friendlyTestName)
{
base.SetTestMethod(generationContext, testMethod, friendlyTestName);

string[] featureTags = generationContext.Feature.Tags.Select(t => stripLeadingAtSign(t.Name)).ToArray();

applyRetry(featureTags, Enumerable.Empty<string>(), testMethod);
}

// Called for both scenarios & scenario outlines, but only if it has tags
public override void SetTestMethodCategories(TestClassGenerationContext generationContext,
CodeMemberMethod testMethod, IEnumerable<string> scenarioCategories)
{
// Optimisation: Prevent multiple enumerations
scenarioCategories = scenarioCategories as string[] ?? scenarioCategories.ToArray();

base.SetTestMethodCategories(generationContext, testMethod, scenarioCategories);

// Feature tags will have already been processed in one of the methods above, which are executed before this
IEnumerable<string> featureTags = generationContext.Feature.Tags.Select(t => stripLeadingAtSign(t.Name));
applyRetry((string[]) scenarioCategories, featureTags, testMethod);
}

/// <summary>
/// Apply retry tags to the current test
/// </summary>
/// <param name="tags">Tags that haven't yet been processed. If the test has just been created these will be for the feature, otherwise for the scenario</param>
/// <param name="processedTags">Tags that have already been processed. If the test has just been created this will be empty, otherwise they will be the feature tags</param>
/// <param name="testMethod">Test method we are applying retries for</param>
private void applyRetry(IList<string> tags, IEnumerable<string> processedTags, CodeMemberMethod testMethod)
{
// Do not add retries to skipped tests (even if they have the retry attribute) as retrying won't affect the outcome.
// This allows for the new (for Reqnroll 3.1.x) implementation that relies on Xunit.SkippableFact to still work, as it
// too will replace the attribute for running the test with a custom one.
if (tags.Any(isIgnoreTag) || processedTags.Any(isIgnoreTag))
{
return;
}

string strRetryTag = getRetryTag(tags);
if (strRetryTag == null)
{
return;
}

RetryTag retryTag = retryTagParser.Parse(strRetryTag);

// Remove the original fact or theory attribute
CodeAttributeDeclaration originalAttribute = testMethod.CustomAttributes.OfType<CodeAttributeDeclaration>()
.FirstOrDefault(a =>
a.Name == FACT_ATTRIBUTE ||
a.Name == THEORY_ATTRIBUTE ||
a.Name == RETRY_FACT_ATTRIBUTE ||
a.Name == RETRY_THEORY_ATTRIBUTE);
if (originalAttribute == null)
{
return;
}
testMethod.CustomAttributes.Remove(originalAttribute);

// Add the Retry attribute
CodeAttributeDeclaration retryAttribute = CodeDomHelper.AddAttribute(testMethod,
originalAttribute.Name == FACT_ATTRIBUTE || originalAttribute.Name == RETRY_FACT_ATTRIBUTE
? RETRY_FACT_ATTRIBUTE
: RETRY_THEORY_ATTRIBUTE);

retryAttribute.Arguments.Add(new CodeAttributeArgument(
new CodePrimitiveExpression(retryTag.MaxRetries ?? RetryFactAttribute.DEFAULT_MAX_RETRIES)));
retryAttribute.Arguments.Add(new CodeAttributeArgument(
new CodePrimitiveExpression(retryTag.DelayBetweenRetriesMs ??
RetryFactAttribute.DEFAULT_DELAY_BETWEEN_RETRIES_MS)));

// Always skip on Xunit.SkipException (from Xunit.SkippableFact) which is used by Reqnroll.xUnit to implement
// dynamic test skipping. This way we can intercept the exception that is already thrown without also having
// our own runtime plugin.
retryAttribute.Arguments.Add(new CodeAttributeArgument(
new CodeArrayCreateExpression(new CodeTypeReference(typeof(Type)),
new CodeExpression[]
{
new CodeTypeOfExpression(typeof(Xunit.SkipException))
})));

// Copy arguments from the original attribute. If it's already a retry attribute, don't copy the retry arguments though
for (int i = originalAttribute.Name == RETRY_FACT_ATTRIBUTE ||
originalAttribute.Name == RETRY_THEORY_ATTRIBUTE
? retryAttribute.Arguments.Count
: 0;
i < originalAttribute.Arguments.Count;
i++)
{
retryAttribute.Arguments.Add(originalAttribute.Arguments[i]);
}
}

private static string stripLeadingAtSign(string s) => s.StartsWith("@") ? s.Substring(1) : s;

private static bool isIgnoreTag(string tag) => tag.Equals(IGNORE_TAG, StringComparison.OrdinalIgnoreCase);

private static string getRetryTag(IEnumerable<string> tags) =>
tags.FirstOrDefault(t =>
t.StartsWith(Constants.RETRY_TAG, StringComparison.OrdinalIgnoreCase) &&
// Is just "retry", or is "retry("... for params
(t.Length == Constants.RETRY_TAG.Length || t[Constants.RETRY_TAG.Length] == '('));
}
}
19 changes: 19 additions & 0 deletions src/xRetry.Reqnroll/xRetry.Reqnroll.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net461;netstandard2.0</TargetFrameworks>
<AssemblyName>xRetry.ReqnrollPlugin</AssemblyName>

<IsPackable>true</IsPackable>
<NuspecProperties>version=$(Version)</NuspecProperties>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Reqnroll.CustomPlugin" Version="2.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\xRetry\xRetry.csproj" />
</ItemGroup>

</Project>
Loading

0 comments on commit 0c039b6

Please sign in to comment.