许多公司遵循的做法之一是重复不稳定测试,直到通过x次(连续或总共)。如果执行n次并且失败至少x次,则标记为失败。
TestNG 通过以下注释支持这一点:
@Test(invocationCount = 5, successPercentage = 40)
如何用JUnit5实现类似的功能?
JUnit5 中有类似的注释,称为
@RepeatedTest(5)
但它不是有条件执行的。
TestTemplateInvocationContextProvider
、ExecutionCondition
和 TestExecutionExceptionHandler
扩展点来执行此操作。
我能够处理失败测试的方法是将它们标记为“已中止”,而不是让它们完全失败(这样整个测试执行就不会认为它是失败),并且只有当我们无法获得成功运行的最小数量。如果最小数量的测试已经成功,那么我们还将剩余的测试标记为“已禁用”。测试失败会在
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)
Exception
用于表示失败的重试:
import java.lang.Exception
internal class RetryingTestFailure(invocation: Int, cause: Throwable) : Exception("Failed test execution at invocation #$invocation", cause)
主要扩展实现
ExecutionCondition
、ParameterResolver
和 TestExecutionExceptionHandler
。
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 中的测试输出如下所示:
我不知道从
TestAbortedException
抛出 TestExecutionExceptionHandler.handleTestExecutionException
是否应该中止测试,但我在这里使用它。
你可以在 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查看:
至少成功四次,然后禁用所有其他:
您还可以将@RepeatedIfExceptionsTest与@DisplayName混合使用
如果您通过 Maven 运行测试,则使用 Surefire 您可以使用
rerunFailingTestsCount
自动重新运行失败的测试。
但是,在 Surefire 插件版本 3.0.0 之前,它不适用于 JUnit 5(仅 4.x)。 从 2019 年发布的 3.0.0-M4 开始,JUnit 5 支持它。
如果您碰巧使用 Gradle 构建工具运行测试,则可以使用 Test Retry Gradle 插件。 这将重新运行每个失败的测试一定次数,如果总体发生太多失败,则可以选择使构建失败。
plugins {
id 'org.gradle.test-retry' version '1.2.0'
}
test {
retry {
maxRetries = 3
maxFailures = 20 // Optional attribute
}
}
您junit-pioneer已经实现了@RetryingTest(3),它可以与JUnit5一起用来代替@Test注释。
@RetryingTest(3)
void flakyTest() {
// this works most of the time
}