首先让我说我同意测试应该是稳定的并且永远不应该重试。但是,我们并不生活在一个理想的世界中,在某些非常具体的场景中,重试测试可能是一个有效的用例。
我正在运行 UI 测试(针对 Angular 应用程序使用 selenium),有时 chromedriver 由于不明原因而变得无响应。这种行为完全不受我的控制,不存在可行的解决方案。我无法在 SpecFlow 步骤中重试此操作,因为我有登录应用程序的“给定”步骤。当它在“何时”步骤中失败时,我还需要重新运行“给定”步骤。在这种情况下,我想关闭驱动程序,重新启动它,然后重新运行之前的所有步骤。作为最后的手段,我为 SpecFlow 编写了一个自定义测试运行程序,可以从如下错误中恢复:
免责声明:这不是预期用途,它可能会在任何版本的 SpecFlow 中中断。如果您是测试纯粹主义者,请不要继续阅读。
首先,我们创建一个类,以便轻松创建自定义 ITestRunner(将所有方法提供为虚拟方法,以便可以覆盖它们):
public class OverrideableTestRunner : ITestRunner
{
private readonly ITestRunner _runner;
public OverrideableTestRunner(ITestRunner runner)
{
_runner = runner;
}
public int ThreadId => _runner.ThreadId;
public FeatureContext FeatureContext => _runner.FeatureContext;
public ScenarioContext ScenarioContext => _runner.ScenarioContext;
public virtual void And(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
_runner.And(text, multilineTextArg, tableArg, keyword);
}
public virtual void But(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
_runner.But(text, multilineTextArg, tableArg, keyword);
}
public virtual void CollectScenarioErrors()
{
_runner.CollectScenarioErrors();
}
public virtual void Given(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
_runner.Given(text, multilineTextArg, tableArg, keyword);
}
public virtual void InitializeTestRunner(int threadId)
{
_runner.InitializeTestRunner(threadId);
}
public virtual void OnFeatureEnd()
{
_runner.OnFeatureEnd();
}
public virtual void OnFeatureStart(FeatureInfo featureInfo)
{
_runner.OnFeatureStart(featureInfo);
}
public virtual void OnScenarioEnd()
{
_runner.OnScenarioEnd();
}
public virtual void OnScenarioInitialize(ScenarioInfo scenarioInfo)
{
_runner.OnScenarioInitialize(scenarioInfo);
}
public virtual void OnScenarioStart()
{
_runner.OnScenarioStart();
}
public virtual void OnTestRunEnd()
{
_runner.OnTestRunEnd();
}
public virtual void OnTestRunStart()
{
_runner.OnTestRunStart();
}
public virtual void Pending()
{
_runner.Pending();
}
public virtual void SkipScenario()
{
_runner.SkipScenario();
}
public virtual void Then(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
_runner.Then(text, multilineTextArg, tableArg, keyword);
}
public virtual void When(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
_runner.When(text, multilineTextArg, tableArg, keyword);
}
}
接下来,我们创建自定义测试运行程序,它会记住对场景的调用并可以重新运行前面的步骤:
public class RetryTestRunner : OverrideableTestRunner
{
/// <summary>
/// Which exceptions to handle (default: all)
/// </summary>
public Predicate<Exception> HandleExceptionFilter { private get; set; } = _ => true;
/// <summary>
/// The action that is executed to recover
/// </summary>
public Action RecoverAction { private get; set; } = () => { };
/// <summary>
/// The maximum number of retries
/// </summary>
public int MaxRetries { private get; set; } = 10;
/// <summary>
/// The executed actions for this scenario, these need to be replayed in the case of an error
/// </summary>
private readonly List<(MethodInfo method, object[] args)> _previousSteps = new List<(MethodInfo method, object[] args)>();
/// <summary>
/// The number of the current try (to make sure we don't go over the specified limit)
/// </summary>
private int _currentTryNumber = 0;
public NonSuckingTestRunner(ITestExecutionEngine engine) : base(new TestRunner(engine))
{
}
public override void OnScenarioStart()
{
base.OnScenarioStart();
_previousSteps.Clear();
_currentTryNumber = 0;
}
public override void Given(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
base.Given(text, multilineTextArg, tableArg, keyword);
Checker()(text, multilineTextArg, tableArg, keyword);
}
public override void But(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
base.But(text, multilineTextArg, tableArg, keyword);
Checker()(text, multilineTextArg, tableArg, keyword);
}
public override void And(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
base.And(text, multilineTextArg, tableArg, keyword);
Checker()(text, multilineTextArg, tableArg, keyword);
}
public override void Then(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
base.Then(text, multilineTextArg, tableArg, keyword);
Checker()(text, multilineTextArg, tableArg, keyword);
}
public override void When(string text, string multilineTextArg, Table tableArg, string keyword = null)
{
base.When(text, multilineTextArg, tableArg, keyword);
Checker()(text, multilineTextArg, tableArg, keyword);
}
// Use this delegate combination to make a params call possible
// It is not possible to use a params argument and the CallerMemberName
// in one method, so we curry the method to make it possible. #functionalprogramming
public delegate void ParamsFunc(params object[] args);
private ParamsFunc Checker([CallerMemberName] string method = null)
{
return args =>
{
// Record the previous step
_previousSteps.Add((GetType().GetMethod(method), args));
// Determine if we should retry
if (ScenarioContext.ScenarioExecutionStatus != ScenarioExecutionStatus.TestError || !HandleExceptionFilter(ScenarioContext.TestError) || _currentTryNumber >= MaxRetries)
{
return;
}
// HACKY: Reset the test state to a non-error state
typeof(ScenarioContext).GetProperty(nameof(ScenarioContext.ScenarioExecutionStatus)).SetValue(ScenarioContext, ScenarioExecutionStatus.OK);
typeof(ScenarioContext).GetProperty(nameof(ScenarioContext.TestError)).SetValue(ScenarioContext, null);
// Trigger the recovery action
RecoverAction.Invoke();
// Retry the steps
_currentTryNumber++;
var stepsToPlay = _previousSteps.ToList();
_previousSteps.Clear();
stepsToPlay.ForEach(s => s.method.Invoke(this, s.args));
};
}
}
接下来,配置 SpecFlow 以使用我们自己的测试运行程序(也可以作为插件添加)。
/// <summary>
/// We need this because this is the only way to configure specflow before it starts
/// </summary>
[TestClass]
public class CustomDependencyProvider : DefaultDependencyProvider
{
[AssemblyInitialize]
public static void AssemblyInitialize(TestContext testContext)
{
// Override the dependency provider of specflow
ContainerBuilder.DefaultDependencyProvider = new CustomDependencyProvider();
TestRunnerManager.OnTestRunStart(typeof(CustomDependencyProvider).Assembly);
}
[AssemblyCleanup]
public static void AssemblyCleanup()
{
TestRunnerManager.OnTestRunEnd(typeof(CustomDependencyProvider).Assembly);
}
public override void RegisterTestThreadContainerDefaults(ObjectContainer testThreadContainer)
{
base.RegisterTestThreadContainerDefaults(testThreadContainer);
// Use our own testrunner
testThreadContainer.RegisterTypeAs<NonSuckingTestRunner, ITestRunner>();
}
}
另外,将此添加到您的 .csproj:
<PropertyGroup>
<GenerateSpecFlowAssemblyHooksFile>false</GenerateSpecFlowAssemblyHooksFile>
</PropertyGroup>
现在我们可以使用 testrunner 从错误中恢复:
[Binding]
public class TestInitialize
{
private readonly RetryTestRunner _testRunner;
public TestInitialize(ITestRunner testRunner)
{
_testRunner = testRunner as RetryTestRunner;
}
[BeforeScenario()]
public void TestInit()
{
_testRunner.RecoverAction = () =>
{
StopDriver();
StartDriver();
};
_testRunner.HandleExceptionFilter = ex => ex is WebDriverException;
}
}
要在 AfterScenario 步骤中使用它,您可以向 testrunner 添加一个 RetryScenario() 方法并调用它。
最后说明:当您无能为力时,将此作为最后的手段。运行不稳定的测试总比不运行测试好。