简体   繁体   English

JUnit5:如何重复失败的测试?

[英]JUnit5: How to repeat failed test?

One of the practice many companies follow is to repeat unstable test until is passes x times (in a row or in total).许多公司遵循的一种做法是重复不稳定的测试,直到通过x次(连续或总共)。 If it is executed n times and fail to pass at least x times it is marked as failed.如果它被执行了n次并且未能通过至少x次,则它被标记为失败。

TestNG supports that with the following annotation: TestNG 支持以下注释:

@Test(invocationCount = 5, successPercentage = 40)

How do I realize similar functionality with JUnit5?我如何使用 JUnit5 实现类似的功能?

There's similar annotation in JUnit5, called @RepeatedTest(5) but it is not executed conditionally. JUnit5 中有类似的注释,称为@RepeatedTest(5)但它不是有条件地执行。

Ok, I took a little bit of time to whip together a little example of how to do this using the TestTemplateInvocationContextProvider , ExecutionCondition , and TestExecutionExceptionHandler extension points.好的,我花了一点时间整理了一个关于如何使用TestTemplateInvocationContextProviderExecutionConditionTestExecutionExceptionHandler扩展点ExecutionCondition此操作的小示例。

The way I was able to handle failing tests was to mark them as "aborted" rather than let them flat out fail (so that the entire test execution does not consider it a failure) and only fail tests when we can't get the minimum amount of successful runs.我能够处理失败测试的方法是将它们标记为“中止”,而不是让它们完全失败(这样整个测试执行不会认为它失败)并且只有在我们无法获得最小值时才使测试失败成功运行的数量。 If the minimum amount of tests has already succeeded, then we also mark the remaining tests as "disabled".如果最小数量的测试已经成功,那么我们还将剩余的测试标记为“已禁用”。 The test failures are tracked in a ExtensionContext.Store so that the state can be looked up at each place.ExtensionContext.Store中跟踪测试失败,以便可以在每个位置查找状态。

This is a very rough example that definitely has a few problems but can hopefully serve as an example of how to compose different annotations.这是一个非常粗略的示例,肯定存在一些问题,但有望作为如何组合不同注释的示例。 I ended up writing it in Kotlin:我最终用 Kotlin 写了它:

@Retry -esque annotation loosely based on the TestNG example: @Retry -esque 注释松散地基于 TestNG 示例:

import org.junit.jupiter.api.TestTemplate
import org.junit.jupiter.api.extension.ExtendWith

@TestTemplate
@Target(AnnotationTarget.FUNCTION)
@ExtendWith(RetryTestExtension::class)
annotation class Retry(val invocationCount: Int, val minSuccess: Int)

TestTemplateInvocationContext used by templatized tests:模板化测试使用的TestTemplateInvocationContext

import org.junit.jupiter.api.extension.Extension
import org.junit.jupiter.api.extension.TestTemplateInvocationContext

class RetryTemplateContext(
  private val invocation: Int,
  private val maxInvocations: Int,
  private val minSuccess: Int
) : TestTemplateInvocationContext {
  override fun getDisplayName(invocationIndex: Int): String {
    return "Invocation number $invocationIndex (requires $minSuccess success)"
  }

  override fun getAdditionalExtensions(): MutableList<Extension> {
    return mutableListOf(
      RetryingTestExecutionExtension(invocation, maxInvocations, minSuccess)
    )
  }
}

TestTemplateInvocationContextProvider extension for the @Retry annotation: @Retry注释的TestTemplateInvocationContextProvider扩展:

import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.ExtensionContextException
import org.junit.jupiter.api.extension.TestTemplateInvocationContext
import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider
import org.junit.platform.commons.support.AnnotationSupport
import java.util.stream.IntStream
import java.util.stream.Stream

class RetryTestExtension : TestTemplateInvocationContextProvider {
  override fun supportsTestTemplate(context: ExtensionContext): Boolean {
    return context.testMethod.map { it.isAnnotationPresent(Retry::class.java) }.orElse(false)
  }

  override fun provideTestTemplateInvocationContexts(context: ExtensionContext): Stream<TestTemplateInvocationContext> {
    val annotation = AnnotationSupport.findAnnotation(
        context.testMethod.orElseThrow { ExtensionContextException("Must be annotated on method") },
        Retry::class.java
    ).orElseThrow { ExtensionContextException("${Retry::class.java} not found on method") }

    checkValidRetry(annotation)

    return IntStream.rangeClosed(1, annotation.invocationCount)
        .mapToObj { RetryTemplateContext(it, annotation.invocationCount, annotation.minSuccess) }
  }

  private fun checkValidRetry(annotation: Retry) {
    if (annotation.invocationCount < 1) {
      throw ExtensionContextException("${annotation.invocationCount} must be greater than or equal to 1")
    }
    if (annotation.minSuccess < 1 || annotation.minSuccess > annotation.invocationCount) {
      throw ExtensionContextException("Invalid ${annotation.minSuccess}")
    }
  }
}

Simple data class representing the retry (injected into test cases in this example using ParameterResolver ).表示重试的简单data class (在本示例中使用ParameterResolver注入到测试用例中)。

data class RetryInfo(val invocation: Int, val maxInvocations: Int)

Exception used for representing failed retries:用于表示失败重试的Exception

import java.lang.Exception

internal class RetryingTestFailure(invocation: Int, cause: Throwable) : Exception("Failed test execution at invocation #$invocation", cause)

Main extension implementing ExecutionCondition , ParameterResolver , and TestExecutionExceptionHandler .实现ExecutionConditionParameterResolverTestExecutionExceptionHandler主要扩展。

import org.junit.jupiter.api.extension.ConditionEvaluationResult
import org.junit.jupiter.api.extension.ExecutionCondition
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.ParameterContext
import org.junit.jupiter.api.extension.ParameterResolver
import org.junit.jupiter.api.extension.TestExecutionExceptionHandler
import org.opentest4j.TestAbortedException

internal class RetryingTestExecutionExtension(
  private val invocation: Int,
  private val maxInvocations: Int,
  private val minSuccess: Int
) : ExecutionCondition, ParameterResolver, TestExecutionExceptionHandler {
  override fun evaluateExecutionCondition(
    context: ExtensionContext
  ): ConditionEvaluationResult {
    val failureCount = getFailures(context).size
    // Shift -1 because this happens before test
    val successCount = (invocation - 1) - failureCount
    when {
      (maxInvocations - failureCount) < minSuccess -> // Case when we cannot hit our minimum success
        return ConditionEvaluationResult.disabled("Cannot hit minimum success rate of $minSuccess/$maxInvocations - $failureCount failures already")
      successCount < minSuccess -> // Case when we haven't hit success threshold yet
        return ConditionEvaluationResult.enabled("Have not ran $minSuccess/$maxInvocations successful executions")
      else -> return ConditionEvaluationResult.disabled("$minSuccess/$maxInvocations successful runs have already ran. Skipping run $invocation")
    }
  }

  override fun supportsParameter(
    parameterContext: ParameterContext,
    extensionContext: ExtensionContext
  ): Boolean = parameterContext.parameter.type == RetryInfo::class.java

  override fun resolveParameter(
    parameterContext: ParameterContext,
    extensionContext: ExtensionContext
  ): Any = RetryInfo(invocation, maxInvocations)

  override fun handleTestExecutionException(
    context: ExtensionContext,
    throwable: Throwable
  ) {

    val testFailure = RetryingTestFailure(invocation, throwable)
    val failures: MutableList<RetryingTestFailure> = getFailures(context)
    failures.add(testFailure)
    val failureCount = failures.size
    val successCount = invocation - failureCount
    if ((maxInvocations - failureCount) < minSuccess) {
      throw testFailure
    } else if (successCount < minSuccess) {
      // Case when we have still have retries left
      throw TestAbortedException("Aborting test #$invocation/$maxInvocations- still have retries left",
        testFailure)
    }
  }

  private fun getFailures(context: ExtensionContext): MutableList<RetryingTestFailure> {
    val namespace = ExtensionContext.Namespace.create(
      RetryingTestExecutionExtension::class.java)
    val store = context.parent.get().getStore(namespace)
    @Suppress("UNCHECKED_CAST")
    return store.getOrComputeIfAbsent(context.requiredTestMethod.name, { mutableListOf<RetryingTestFailure>() }, MutableList::class.java) as MutableList<RetryingTestFailure>
  }
}

And then, the test consumer:然后,测试消费者:

import org.junit.jupiter.api.DisplayName

internal class MyRetryableTest {
  @DisplayName("Fail all retries")
  @Retry(invocationCount = 5, minSuccess = 3)
  internal fun failAllRetries(retryInfo: RetryInfo) {
    println(retryInfo)
    throw Exception("Failed at $retryInfo")
  }

  @DisplayName("Only fail once")
  @Retry(invocationCount = 5, minSuccess = 4)
  internal fun succeedOnRetry(retryInfo: RetryInfo) {
    if (retryInfo.invocation == 1) {
      throw Exception("Failed at ${retryInfo.invocation}")
    }
  }

  @DisplayName("Only requires single success and is first execution")
  @Retry(invocationCount = 5, minSuccess = 1)
  internal fun firstSuccess(retryInfo: RetryInfo) {
    println("Running: $retryInfo")
  }

  @DisplayName("Only requires single success and is last execution")
  @Retry(invocationCount = 5, minSuccess = 1)
  internal fun lastSuccess(retryInfo: RetryInfo) {
    if (retryInfo.invocation < 5) {
      throw Exception("Failed at ${retryInfo.invocation}")
    }
  }

  @DisplayName("All required all succeed")
  @Retry(invocationCount = 5, minSuccess = 5)
  internal fun allRequiredAllSucceed(retryInfo: RetryInfo) {
    println("Running: $retryInfo")
  }

  @DisplayName("Fail early and disable")
  @Retry(invocationCount = 5, minSuccess = 4)
  internal fun failEarly(retryInfo: RetryInfo) {
    throw Exception("Failed at ${retryInfo.invocation}")
  }
}

And the test output in IntelliJ looks like: IntelliJ 中的测试输出如下所示:

IntelliJ 测试输出

I don't know if throwing a TestAbortedException from the TestExecutionExceptionHandler.handleTestExecutionException is supposed to abort the test, but I am using it here.我不知道从TestExecutionExceptionHandler.handleTestExecutionException抛出TestAbortedException是否应该中止测试,但我在这里使用它。

U can try this extension for junit 5.你可以试试这个 junit 5 的扩展。

<dependency>
    <groupId>io.github.artsok</groupId>
    <artifactId>rerunner-jupiter</artifactId>
    <version>LATEST</version>
</dependency> 

Examples:例子:

     /** 
        * Repeated three times if test failed.
        * By default Exception.class will be handled in test
        */
       @RepeatedIfExceptionsTest(repeats = 3)
       void reRunTest() throws IOException {
           throw new IOException("Error in Test");
       }


       /**
        * Repeated two times if test failed. Set IOException.class that will be handled in test
        * @throws IOException - error occurred
        */
       @RepeatedIfExceptionsTest(repeats = 2, exceptions = IOException.class)
       void reRunTest2() throws IOException {
           throw new IOException("Exception in I/O operation");
       }


       /**
        * Repeated ten times if test failed. Set IOException.class that will be handled in test
        * Set formatter for test. Like behavior as at {@link org.junit.jupiter.api.RepeatedTest}
        * @throws IOException - error occurred
        */
       @RepeatedIfExceptionsTest(repeats = 10, exceptions = IOException.class, 
       name = "Rerun failed test. Attempt {currentRepetition} of {totalRepetitions}")
       void reRunTest3() throws IOException {
           throw new IOException("Exception in I/O operation");
       }

       /**
       * Repeated 100 times with minimum success four times, then disabled all remaining repeats.
       * See image below how it works. Default exception is Exception.class
       */
       @DisplayName("Test Case Name")
       @RepeatedIfExceptionsTest(repeats = 100, minSuccess = 4)
       void reRunTest4() {
            if(random.nextInt() % 2 == 0) {
                throw new RuntimeException("Error in Test");
            }
       }

View at IDEA:在 IDEA 上查看:

IDEA 看起来像

With minimum success four times then disables all other:最少成功四次,然后禁用所有其他:最少成功四次然后禁用所有其他

You can also mix @RepeatedIfExceptionsTest with @DisplayName您还可以将 @RepeatedIfExceptionsTest 与 @DisplayName 混合使用

source -> github源-> github

if you are running tests via Maven, with Surefire you care re-run failing tests automatically by using rerunFailingTestsCount .如果您通过 Maven 运行测试,使用 Surefire,您可以使用rerunFailingTestsCount自动重新运行失败的测试。

However, as of 2.21.0, that does not work for JUnit 5 (only 4.x).但是,从 2.21.0 开始,这不适用于 JUnit 5(仅 4.x)。 But hopefully it will be supported in the next releases.但希望它会在下一个版本中得到支持。

If you happen to be running your tests using the Gradle build tool, you can use the Test Retry Gradle plugin.如果您碰巧使用Gradle构建工具运行测试,则可以使用Test Retry Gradle 插件。 This will rerun each failed test a certain number of times, with the option of failing the build if too many failures have occurred overall.这将重新运行每个失败的测试一定次数,如果总体上发生太多失败,则可以选择使构建失败。

plugins {
    id 'org.gradle.test-retry' version '1.2.0'
}

test {
    retry {
        maxRetries = 3
        maxFailures = 20 // Optional attribute
    }
}

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM