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

19

许多公司采用的一种实践是反复进行不稳定的测试,直到测试连续通过x次为止(可以是总次数中的连续),如果测试执行了n次并且未能至少通过x次,则将其标记为失败。

TestNG使用以下注释支持此功能:

@Test(invocationCount = 5, successPercentage = 40)

我该如何使用JUnit5实现类似的功能?

在JUnit5中有一个类似的注解,叫做@RepeatedTest(5),但它不会有条件地执行。


2
你可能需要编写自定义运行器,该运行器可以处理@Test@RepeatedTest属性的特性,例如currentRepetitiontotalRepetitionssuccess - Naman
或者直接使用TestNG。 - M. Prokhorov
1
@nullpointer。Runner是JUnit4中的一个概念。Junit5没有自定义的Runner。 - dzieciou
1
你期望它总是执行5次,还是期望它运行直到至少有 40% 的成功执行? - mkobit
@mkobit。最终我更喜欢后一种策略。但首先有第一种策略会是一个好的起点。 - dzieciou
5个回答

14
好的,我花了一点时间,用 TestTemplateInvocationContextProviderExecutionConditionTestExecutionExceptionHandler扩展点,为您提供了一个简单的示例。

我处理失败测试的方法是将它们标记为“中止”,而不是让它们完全失败(这样整个测试执行就不会认为它是失败的),并且只有在我们无法获得最小数量的成功运行时才会失败测试。如果已经成功运行了最小数量的测试,则我们还会将剩余的测试标记为“禁用”。测试失败记录在ExtensionContext.Store中,以便可以在每个地方查找状态。

这只是一个非常粗略的示例,肯定存在一些问题,但希望可以作为组合不同注释的示例。最终我用Kotlin编写了它:

@Retry类似于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 是用于模板化测试的上下文对象:

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扩展用于@Retry注释:

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}")
    }
  }
}

这是一个简单的data class,代表了重试(在本例中使用ParameterResolver将其注入到测试用例中)。

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

异常用于表示重试失败:

import java.lang.Exception

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

主要扩展实现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>
  }
}

接下来,是测试消费者:

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}")
  }
}

在IntelliJ中的测试输出如下:

IntelliJ test output

我不确定从TestExecutionExceptionHandler.handleTestExecutionException抛出一个TestAbortedException是否应该终止测试,但是我在这里使用它。


感谢@mkobit。FYI,我将七个单独的Kotlin文件放入了一个Gist中:https://gist.github.com/seanf/c6d16b00713ef3fdcd7f3371b4c5798a 你有授权证书想法吗? - seanf
@seanf 我不确定 Stack Overflow 是否拥有答案的许可证,所以我会遵循那个。如果没有,就将其视为在 Unlicense 下发布。 - mkobit
这个解决方案对我们来说几乎可以工作,但我们想尝试在没有TestTemplate的情况下使其工作,因为TestTemplate是可测试的,如果您想标记为此类易错的测试之一也是ParameterizedTest,则会出现问题。 - Hakanai

13

你可以尝试使用这个 JUnit 5 的扩展。

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

示例:

     /** 
        * 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");
            }
       }

在 IDEA 中查看:

IDEA 界面

测试方法最少成功四次,然后禁用所有其他测试: 测试方法最少成功四次,然后禁用所有其他测试

您也可以将 @RepeatedIfExceptionsTest 与 @DisplayName 混合使用

源码 -> github


你的扩展能够处理并行执行的测试吗?它能够与参数化测试一起使用吗? - Buzz
谢谢,我有一个不稳定的测试套件,这正是我需要的。 - InfernalRapture
当我包含这个依赖项rerunner-jupiter时,运行测试时就会收到这个错误,即使在测试中没有使用 @RepeatedIfExceptionsTest,错误如下:java.lang.NoClassDefFoundError: org/junit/jupiter/api/extension/ScriptEvaluationException。有什么想法如何解决这个问题吗? - user1207289
1
@Buzz 答案是否定的,根据这篇博客:https://www.swtestacademy.com/junit-5-how-to-repeat-failed-test/ - Abhilash Mandaliya

4
如果您正在通过Maven运行测试并使用Surefire,您可以使用rerunFailingTestsCount自动重新运行失败的测试。rerunFailingTestsCount
然而,从2.21.0版本开始,这对JUnit 5(仅限4.x)不起作用。但希望在以后的版本中能够支持。

有一个功能请求 - https://issues.apache.org/jira/browse/SUREFIRE-1584 - vanangelov

2
如果您正在使用构建工具Gradle运行测试,可以使用Test Retry Gradle插件。该插件将重新运行每个失败的测试一定次数,并且如果整体失败次数过多,则可以选择使构建失败。
plugins {
    id 'org.gradle.test-retry' version '1.2.0'
}

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

1
你的JUnit Pioneer已经实现了一个@RetryingTest(3),可以替代JUnit5中的@Test注解一起使用。
@RetryingTest(3)
void flakyTest() {
  // this works most of the time
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接