2个月前 (07-28)  Java系列 |   抢沙发  74 
文章评分 0 次,平均分 0.0

防止实际系统退出

编写一个在测试期间实际退出JVM的单元测试绝对不是理想的。每当JVM执行有趣的操作(如退出或读取文件)时,它都会首先检查是否有权限这样做。这是通过咨询系统正在使用的SecurityManager来完成的。SecurityManager上的方法之一是checkExit()。如果该方法抛出SecurityException,则表示此时不允许系统退出。方便的是,checkExit()只接受一个参数——尝试的退出状态代码。

现在我们知道了,我们可以制定一个行动计划:

  1. 编写一个始终阻止System.exit()SecurityManager,并记录尝试的代码。
  2. 在测试开始之前,对SecurityManager的任何其他调用都应委托给系统正在使用的任何SecurityManager
  3. 通过扩展模型将其与JUnit5集成。

自定义安全管理器

编写一个SecurityManager来阻止System.exit()并记录所使用的代码是非常简单的,现在我们知道它是如何工作的了:

public class DisallowExitSecurityManager extends SecurityManager {
    private final SecurityManager delegatedSecurityManager;
    private Integer firstExitStatusCode;

    public DisallowExitSecurityManager(final SecurityManager originalSecurityManager) {
        this.delegatedSecurityManager = originalSecurityManager;
    }

    /**
     * This is the one method we truly override in this class, all others are delegated.
     *
     * @param statusCode the exit status
     */
    @Override
    public void checkExit(final int statusCode) {
        if (firstExitStatusCode == null) {
            this.firstExitStatusCode = statusCode;
        }
        throw new SystemExitPreventedException();
    }

    public Integer getFirstExitStatusCode() {
        return firstExitStatusCode;
    }

    // All other methods implemented and delegate to delegatedSecurityManager, if it is present. 
    // Otherwise, they do nothing and allow the check to pass.

    // Example:
    @Override
    public void checkPermission(Permission perm) {
        if (delegatedSecurityManager != null) {
            delegatedSecurityManager.checkPermission(perm);
        }
    }
}

如您所见,当系统检查退出权限时,我们会保存firstExitStatusCode中使用的代码,因为只记录第一次尝试才有意义。然后我们抛出一个SystemExitPreventedException,我们为此目的定义了它。我们定义自己的异常,因为我们需要检测到这种确切的情况,并且不想无意中处理可能意味着完全不同的更一般的SecurityException。简而言之,我们在这里定义自己的异常,因为我们以后可以识别它。

为了完整起见,这是我们的SystemExitPreventedException,它是SecurityException的一个简单子类。理论上,我们可以在这里记录安全代码,在捕获此异常时使用它,但我不想让此异常有状态。

class SystemExitPreventedException extends SecurityException {
}

JUnit5扩展

JUnit5的一个变化是引入了扩展模型。这个扩展点接管了@Rule@ClassRuleRunners。扩展本身只是一个标记接口,有几种方法可以使用它。

为了注册我们的扩展,我们将创建两个注释,我们将在测试用例中使用:一个用于断言某些代码应该用任何代码调用System.exit(),另一个用于指定代码。JUnit5支持元注释(注释上的注释)非常方便,因此我们可以引用代码的实际工作部分:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@ExtendWith(SystemExitExtension.class)
public @interface ExpectSystemExit {

}

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@ExtendWith(SystemExitExtension.class)
public @interface ExpectSystemExitWithStatus {
    int value();
}

如您所见,我们的每个注释本身都用@ExtendWith(SystemExitExtension.class)进行了注释。这告诉Jupiter运行时(JUnit5本身的名称)查找名为SystemExitExtension的类并注册它。

在创建此扩展之前,让我们决定它需要做什么:

  1. 在每次测试之前:用我们上面写的SecurityManager替换现有的SecurityManager(如果有的话)。
  2. 在每次测试中:捕获并吞下我们的自定义异常(SystemExitPreventedException),然后重新抛出所有其他异常。
  3. 每次测试后:将原始SecurityManager(如果有的话)返回服务,并删除我们的自定义SecurityManager

由于扩展模型非常精细,我们需要实现三个接口,每个接口都有一个方法:BeforeEachCallbackTestExecutionExceptionHandlerAfterEachCallback

我们还需要一个方法来查找ExpectSystemExitWithStatus注释(否则我们可以假设我们的扩展是由ExpectedsystemExit调用的,我们不需要担心特定的代码)。

public class SystemExitExtension implements BeforeEachCallback, AfterEachCallback, TestExecutionExceptionHandler {
    private Integer expectedStatusCode;  
    private final DisallowExitSecurityManager disallowExitSecurityManager = new DisallowExitSecurityManager(System.getSecurityManager());
    private SecurityManager originalSecurityManager;

    @Override
    public void beforeEach(final ExtensionContext context) {
        // TODO
    }

    @Override
    public void handleTestExecutionException(final ExtensionContext context, final Throwable throwable) 
        throws Throwable {
        // TODO
    }

    @Override
    public void afterEach(final ExtensionContext context) {
        // TODO
    }

    // Find the annotation on a method, or failing that, a class.
    private Optional<ExpectSystemExitWithStatus> getAnnotation(final ExtensionContext context) {
        final Optional<ExpectSystemExitWithStatus> method = 
            findAnnotation(context.getTestMethod(), ExpectSystemExitWithStatus.class);

        if (method.isPresent()) {
            return method;
        } else {
            return findAnnotation(context.getTestClass(), ExpectSystemExitWithStatus.class);
        }
    }
}

我们将遵循的查找注释的策略是搜索我们正在为其执行的测试方法,如果失败,则搜索测试类本身。这为我们提供了一些灵活性,可以注释一个特定的测试或一组测试(甚至是嵌套的),而System.exit()应该使用特定的代码来注释这些测试。在这个方法中,我们使用findAnnotation,它包含在JUnit5中并由JUnit5使用。

在上面的代码中,我们已经定义了所有需要的实例变量,这样我们就可以继续实现我们的三个生命周期方法(之前、期间、之后)。

测试执行前:BeforeEachCallback

@Override
public void beforeEach(final ExtensionContext context) {
    // Set aside the current SecurityManager
    originalSecurityManager = System.getSecurityManager();

    // Get the expected exit status code, if any
    getAnnotation(context).ifPresent(code -> expectedStatusCode = code.value());

    // Install our own SecurityManager
    System.setSecurityManager(disallowExitSecurityManager);
}

正如您所看到的,我们将当前运行的系统SecurityManager放在一边,即使它为空。然后我们找到我们的注释并捕获预期的Status Code(如果有的话)。最后,我们告诉Java在测试期间使用我们编写的DisallowExitSecurityManager

测试执行期间:TestExecutionExceptionHandler

public void handleTestExecutionException(final ExtensionContext context, final Throwable throwable) 
    throws Throwable {

    if (!(throwable instanceof SystemExitPreventedException)) {
        throw throwable;
    }
}

在此实现中,我们将吞下自定义异常(SystemExitPreventedException)并重新抛出其他任何异常,因为这些异常是原始SecurityManager抛出的合法异常。为什么我们抛出异常只是为了捕获并忽略它?因为JVM需要我们从checkExit()中抛出某种SecurityException,否则它实际上会退出(这很糟糕)。由于JUnit在任何异常情况下都会失败,我们需要吞下它,并防止它在调用堆栈中冒泡以结束我们的测试。

测试执行后:SystemExitPreventedException

@Override
public void afterEach(final ExtensionContext context) {
    // Return the original SecurityManager, if any, to service.
    System.setSecurityManager(originalSecurityManager);

    if (expectedStatusCode == null) {
        assertNotNull(
                disallowExitSecurityManager.getFirstExitStatusCode(),
                "Expected System.exit() to be called, but it was not"
        );
    } else {
        assertEquals(
                expectedStatusCode,
                disallowExitSecurityManager.getFirstExitStatusCode(),
                "Expected System.exit(" + expectedStatusCode + ") to be called, but it was not."
        );
    }
}

这个有点复杂,因为我们必须确定我们是否需要任何退出代码或特定的退出代码。首先,我们必须将原始SecurityManager恢复服务,替换我们的自定义实现。接下来,我们使用JUnit自己的断言来测试我们的条件——可能用特定的代码调用了System.exit()。我们可以从SecurityManager获得这两条信息,我们仍然保留着一个参考文件。

我真的很喜欢扩展模型如何将生命周期事件分解为简单的方法来实现或不实现。这个模型似乎比它旨在取代的概念更加连贯。是的,在短期内,我们将无法使用可能已经从JUnit4退出的任何@Rules,但我觉得这更有表现力。我急切地想看看社区在迁移到JUnit5时会开发什么来帮助测试!

使用扩展

现在我们的扩展已经编写(并经过测试!),我们可以编写自己的测试来使用它:

public class MyTestCases { 
    
    @Test
    @ExpectSystemExit
    public void thatSystemExitIsCalled() {
        System.exit(1);
    }

    @Test
    @ExpectSystemExitWithStatus(42)
    public void thatSystemExitIsCalledWithSpecificCode() {
        System.exit(42);
    }
}

如果条件相同,我们还可以对测试类本身进行注释,而不是单独对方法进行注释。

使用junit5-system-exit

正如我在这篇文章的into中提到的,我已经编写了这段代码,并将其作为您可以从Maven Central获取的依赖项发布,以便在您的项目中使用。要在构建中使用它,只需将其中一个添加到pom.xml或build.gradle中:

// Gradle build.gradle
testImplementation("com.ginsberg:junit5-system-exit:1.1.1")
<!-- Maven pom.xml -->
<dependency>
    <groupId>com.ginsberg</groupId>
    <artifactId>junit5-system-exit</artifactId>
    <version>1.1.1</version>
    <scope>test</scope>
</dependency>

文章来源: https://todd.ginsberg.com/post/testing-system-exit

  
 

除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/3020.html

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册