diff --git a/README.md b/README.md
index 0a2148af8..195bf66b3 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,7 @@ The least we can do is to thank them and list some of their accomplishments here
#### 2022
* [Filip Hrisafov](https://github.com/filiphr) contributed the [JSON Argument Source](https://junit-pioneer.org/docs/json-argument-source/) support (#101 / #492)
+* [Marcono1234](https://github.com/Marcono1234) contributed the [`@ExpectedToFail` extension](https://junit-pioneer.org/docs/expected-to-fail-tests/) (#551 / #676)
* [Mathieu Fortin](https://github.com/mathieufortin01) contributed the `suspendForMs` attribute in [retrying tests](https://junit-pioneer.org/docs/retrying-test/) (#407 / #604)
* [Pankaj Kumar](https://github.com/p1729) contributed towards improving GitHub actions (#587 / #611)
* [Rob Spoor](https://github.com/robtimus) enabled non-static factory methods for `@CartesianTest.MethodFactory` (#628)
diff --git a/docs/docs-nav.yml b/docs/docs-nav.yml
index 1492235fb..3a1e9e68b 100644
--- a/docs/docs-nav.yml
+++ b/docs/docs-nav.yml
@@ -16,6 +16,8 @@
url: /docs/disable-if-test-fails/
- title: "Disable Parameterized Tests"
url: /docs/disable-parameterized-tests/
+ - title: "Expected-to-Fail Tests"
+ url: /docs/expected-to-fail-tests/
- title: "Issue information"
url: /docs/issue/
- title: "JSON Argument Source"
diff --git a/docs/expected-to-fail-tests.adoc b/docs/expected-to-fail-tests.adoc
new file mode 100644
index 000000000..cb4562387
--- /dev/null
+++ b/docs/expected-to-fail-tests.adoc
@@ -0,0 +1,46 @@
+:page-title: Expected to Fail Tests
+:page-description: Extends JUnit Jupiter with `@ExpectedToFail`, which marks a test method as temporarily 'expected to fail'
+:xp-demo-dir: ../src/demo/java
+:demo: {xp-demo-dir}/org/junitpioneer/jupiter/ExpectedToFailExtensionDemo.java
+
+Often tests fail due to a bug in the tested application or in dependencies.
+Traditionally such a test method would be annotated with JUnit's `@Disabled`.
+However, this has disadvantages when the bug that causes the test failure is fixed:
+
+* the developer might not notice the existing test method and create a new one
+* the existing test method might not be noticed and remains disabled for a long time after the bug has been fixed, adding no value for the project
+
+`@ExpectedToFail` solves these issues.
+Unlike `@Disabled` it still executes the annotated test method but aborts the test if a test failure or error occurs.
+However, if the test is executed successfully it will cause a test failure because the test _is working_.
+This lets the developer know that they have fixed the bug (possibly by accident) and that they can now remove the `@ExpectedToFail` annotation from the test method.
+
+The annotation can only be used on methods and as meta-annotation on other annotation types.
+Similar to `@Disabled`, it has to be used in addition to a "testable" annotation, such as `@Test`.
+Otherwise the annotation has no effect.
+
+IMPORTANT: This annotation is *not* intended as a way to mark test methods which intentionally cause exceptions.
+Such test methods should use https://junit.org/junit5/docs/current/api/org.junit.jupiter.api/org/junit/jupiter/api/Assertions.html#assertThrows(java.lang.Class,org.junit.jupiter.api.function.Executable)[JUnit's `assertThrows`] or similar means to explicitly test for a specific exception class being thrown by a specific action.
+
+== Basic Use
+
+The test is aborted because the tested method `brokenMethod()` returns an incorrect result.
+
+[source,java,indent=0]
+----
+include::{demo}[tag=expected_to_fail]
+----
+
+An aborted test is no failure and so the test suite passes (if all other tests pass, of course).
+Should `brokenMethod()` start returning the correct value, the test invocation passes, but `@ExpectedToFail` marks the test as failed to draw attention to that change in behavior.
+
+A custom message can be provided, explaining why the tested code is not working as intended at the moment.
+
+[source,java,indent=0]
+----
+include::{demo}[tag=expected_to_fail_message]
+----
+
+== Thread-Safety
+
+This extension is safe to use during https://junit.org/junit5/docs/current/user-guide/#writing-tests-parallel-execution[parallel test execution].
diff --git a/src/demo/java/org/junitpioneer/jupiter/ExpectedToFailExtensionDemo.java b/src/demo/java/org/junitpioneer/jupiter/ExpectedToFailExtensionDemo.java
new file mode 100644
index 000000000..78676c701
--- /dev/null
+++ b/src/demo/java/org/junitpioneer/jupiter/ExpectedToFailExtensionDemo.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2016-2022 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * http://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junitpioneer.jupiter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+public class ExpectedToFailExtensionDemo {
+
+ // tag::expected_to_fail[]
+ @Test
+ @ExpectedToFail
+ void test() {
+ int actual = brokenMethod();
+ assertEquals(10, actual);
+ }
+ // end::expected_to_fail[]
+
+ // tag::expected_to_fail_message[]
+ @Test
+ @ExpectedToFail("Implementation bug in brokenMethod()")
+ void doSomething() {
+ int actual = brokenMethod();
+ assertEquals(10, actual);
+ }
+ // end::expected_to_fail_message[]
+
+ private int brokenMethod() {
+ return 0;
+ }
+
+}
diff --git a/src/main/java/org/junitpioneer/jupiter/ExpectedToFail.java b/src/main/java/org/junitpioneer/jupiter/ExpectedToFail.java
new file mode 100644
index 000000000..7df0f60ff
--- /dev/null
+++ b/src/main/java/org/junitpioneer/jupiter/ExpectedToFail.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2016-2022 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * http://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junitpioneer.jupiter;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * {@code @ExpectedToFail} is a JUnit Jupiter extension to mark test methods as temporarily
+ * 'expected to fail'. Such test methods will still be executed but when they result in a test
+ * failure or error the test will be aborted. However, if the test method unexpectedly executes
+ * successfully, it is marked as failure to let the developer know that the test is now
+ * successful and that the {@code @ExpectedToFail} annotation can be removed.
+ *
+ *
The big difference compared to JUnit's {@link org.junit.jupiter.api.Disabled @Disabled}
+ * annotation is that the developer is informed as soon as a test is successful again.
+ * This helps to avoid creating duplicate tests by accident and counteracts the accumulation
+ * of disabled tests over time.
+ *
+ *
The annotation can only be used on methods and as meta-annotation on other annotation types.
+ * Similar to {@code @Disabled}, it has to be used in addition to a "testable" annotation, such
+ * as {@link org.junit.jupiter.api.Test @Test}. Otherwise the annotation has no effect.
+ *
+ *
Important: This annotation is not intended as a way to mark test methods
+ * which intentionally cause exceptions. Such test methods should use
+ * {@link org.junit.jupiter.api.Assertions#assertThrows(Class, org.junit.jupiter.api.function.Executable) assertThrows}
+ * or similar means to explicitly test for a specific exception class being thrown by a
+ * specific action.
+ *
+ *
For more details and examples, see
+ * the documentation on @ExpectedToFail
.
+ *
+ *
+ * @since 1.8.0
+ * @see org.junit.jupiter.api.Disabled
+ */
+@Documented
+@Retention(RUNTIME)
+/*
+ * Only supports METHOD and ANNOTATION_TYPE as targets but not test classes because there
+ * it is not clear what the 'correct' behavior would be when only a few test methods
+ * execute successfully. Would the developer then have to remove the @ExpectedToFail annotation
+ * from the test class and annotate methods individually?
+ */
+@Target({ METHOD, ANNOTATION_TYPE })
+@ExtendWith(ExpectedToFailExtension.class)
+public @interface ExpectedToFail {
+
+ /**
+ * Defines the message to show when a test is aborted because it is failing.
+ * This can be used for example to briefly explain why the tested code is not working
+ * as intended at the moment.
+ * An empty string (the default) causes a generic default message to be used.
+ */
+ String value() default "";
+
+}
diff --git a/src/main/java/org/junitpioneer/jupiter/ExpectedToFailExtension.java b/src/main/java/org/junitpioneer/jupiter/ExpectedToFailExtension.java
new file mode 100644
index 000000000..ed1930661
--- /dev/null
+++ b/src/main/java/org/junitpioneer/jupiter/ExpectedToFailExtension.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2016-2022 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * http://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junitpioneer.jupiter;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.lang.reflect.Method;
+
+import org.junit.jupiter.api.extension.Extension;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.InvocationInterceptor;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.junit.platform.commons.support.AnnotationSupport;
+import org.opentest4j.TestAbortedException;
+
+class ExpectedToFailExtension implements Extension, InvocationInterceptor {
+
+ @Override
+ public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext,
+ ExtensionContext extensionContext) throws Throwable {
+ invokeAndInvertResult(invocation, extensionContext);
+ }
+
+ private static T invokeAndInvertResult(Invocation invocation, ExtensionContext extensionContext)
+ throws Throwable {
+ try {
+ invocation.proceed();
+ // at this point, the invocation succeeded, so we'd want to call `fail(...)`,
+ // but that would get handled by the following `catch` and so it's easier
+ // to instead fall through to a `fail(...)` after the `catch` block
+ }
+ catch (Throwable t) {
+ if (shouldPreserveException(t)) {
+ throw t;
+ }
+
+ String message = getExpectedToFailAnnotation(extensionContext).value();
+ if (message.isEmpty()) {
+ message = "Test marked as temporarily 'expected to fail' failed as expected";
+ }
+
+ throw new TestAbortedException(message, t);
+ }
+
+ return fail("Test marked as 'expected to fail' succeeded; remove @ExpectedToFail from it");
+ }
+
+ /**
+ * Returns whether the exception should be preserved and reported as is instead
+ * of considering it an 'expected to fail' exception.
+ *
+ * This method is used for exceptions that abort test execution and should
+ * have higher precedence than aborted exceptions thrown by this extension.
+ */
+ private static boolean shouldPreserveException(Throwable t) {
+ // Note: Ideally would use the same logic JUnit uses to determine if exception is aborting
+ // execution, see its class OpenTest4JAndJUnit4AwareThrowableCollector
+ return TestAbortedException.class.isInstance(t);
+ }
+
+ private static ExpectedToFail getExpectedToFailAnnotation(ExtensionContext context) {
+ return AnnotationSupport
+ .findAnnotation(context.getRequiredTestMethod(), ExpectedToFail.class)
+ .orElseThrow(() -> new IllegalStateException("@ExpectedToFail is missing."));
+
+ }
+
+}
diff --git a/src/main/java/org/junitpioneer/jupiter/package-info.java b/src/main/java/org/junitpioneer/jupiter/package-info.java
index 614c84fd7..b9571e46e 100644
--- a/src/main/java/org/junitpioneer/jupiter/package-info.java
+++ b/src/main/java/org/junitpioneer/jupiter/package-info.java
@@ -8,6 +8,7 @@
*
{@link org.junitpioneer.jupiter.DefaultLocale} and {@link org.junitpioneer.jupiter.DefaultTimeZone}
* {@link org.junitpioneer.jupiter.DisabledUntil}
* {@link org.junitpioneer.jupiter.DisableIfTestFails}
+ * {@link org.junitpioneer.jupiter.ExpectedToFail}
* {@link org.junitpioneer.jupiter.ReportEntry}
* {@link org.junitpioneer.jupiter.RetryingTest}
* {@link org.junitpioneer.jupiter.StdIo}
diff --git a/src/test/java/org/junitpioneer/jupiter/ExpectedToFailExtensionTests.java b/src/test/java/org/junitpioneer/jupiter/ExpectedToFailExtensionTests.java
new file mode 100644
index 000000000..d3dba337c
--- /dev/null
+++ b/src/test/java/org/junitpioneer/jupiter/ExpectedToFailExtensionTests.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright 2016-2022 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * http://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junitpioneer.jupiter;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.junitpioneer.testkit.assertion.PioneerAssert.assertThat;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.assertj.core.api.Condition;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junitpioneer.testkit.ExecutionResults;
+import org.junitpioneer.testkit.PioneerTestKit;
+import org.opentest4j.AssertionFailedError;
+import org.opentest4j.TestAbortedException;
+
+@DisplayName("ExpectedToFail extension")
+public class ExpectedToFailExtensionTests {
+
+ @Test
+ void abortsOnTestFailure() {
+ ExecutionResults results = PioneerTestKit.executeTestMethod(ExpectedToFailTestCases.class, "failure");
+ assertThat(results)
+ .hasSingleStartedTest()
+ .whichAborted()
+ .withExceptionInstanceOf(TestAbortedException.class)
+ .hasMessage("Test marked as temporarily 'expected to fail' failed as expected")
+ .hasCause(new AssertionFailedError("failed"));
+ }
+
+ @Test
+ void abortsOnTestFailureWithMetaAnnotation() {
+ ExecutionResults results = PioneerTestKit
+ .executeTestMethod(ExpectedToFailTestCases.class, "metaAnnotationFailure");
+ assertThat(results)
+ .hasSingleStartedTest()
+ .whichAborted()
+ .withExceptionInstanceOf(TestAbortedException.class)
+ .hasMessage("Test marked as temporarily 'expected to fail' failed as expected")
+ .hasCause(new AssertionFailedError("failed"));
+ }
+
+ @Test
+ void abortsOnTestFailureWithMessage() {
+ ExecutionResults results = PioneerTestKit
+ .executeTestMethod(ExpectedToFailTestCases.class, "failureWithMessage");
+ assertThat(results)
+ .hasSingleStartedTest()
+ .whichAborted()
+ .withExceptionInstanceOf(TestAbortedException.class)
+ .hasMessage("Custom message")
+ .hasCause(new AssertionFailedError("failed"));
+ }
+
+ @Test
+ void abortsOnException() {
+ ExecutionResults results = PioneerTestKit.executeTestMethod(ExpectedToFailTestCases.class, "exception");
+ assertThat(results)
+ .hasSingleStartedTest()
+ .whichAborted()
+ .withExceptionInstanceOf(TestAbortedException.class)
+ .hasMessage("Test marked as temporarily 'expected to fail' failed as expected")
+ .hasCause(new RuntimeException("test"));
+ }
+
+ @Test
+ void preservesTestAbort() {
+ ExecutionResults results = PioneerTestKit.executeTestMethod(ExpectedToFailTestCases.class, "aborted");
+ assertThat(results)
+ .hasSingleStartedTest()
+ .whichAborted()
+ .withExceptionInstanceOf(TestAbortedException.class)
+ // Ignore message prefix generated by JUnit
+ .hasMessageEndingWith("custom assumption message");
+ }
+
+ @Test
+ void failsOnWorkingTest() {
+ ExecutionResults results = PioneerTestKit.executeTestMethod(ExpectedToFailTestCases.class, "working");
+ assertThat(results)
+ .hasSingleStartedTest()
+ .whichFailed()
+ .withExceptionInstanceOf(AssertionError.class)
+ .hasMessage("Test marked as 'expected to fail' succeeded; remove @ExpectedToFail from it");
+ }
+
+ @Test
+ void doesNotAbortOnBeforeEachTestFailure() {
+ ExecutionResults results = PioneerTestKit
+ .executeTestMethod(ExpectedToFailFailureBeforeEachTestCases.class, "test");
+ assertThat(results)
+ .hasSingleFailedTest()
+ .withExceptionInstanceOf(AssertionError.class)
+ .hasMessage("before each");
+ }
+
+ @Test
+ void doesNotAbortOnAfterEachTestFailure() {
+ ExecutionResults results = PioneerTestKit
+ .executeTestMethod(ExpectedToFailFailureAfterEachTestCases.class, "test");
+ assertThat(results)
+ .hasSingleFailedTest()
+ .withExceptionInstanceOf(AssertionError.class)
+ .hasMessage("Test marked as 'expected to fail' succeeded; remove @ExpectedToFail from it")
+ // Note: This check for suppressed exception actually tests JUnit platform behavior
+ .hasSuppressedException(new AssertionFailedError("after each"));
+ }
+
+ @Test
+ void doesNotAbortOnAfterEachTestFailureAfterTestAbort() {
+ ExecutionResults results = PioneerTestKit
+ .executeTestMethod(ExpectedToFailAbortThenFailureAfterEachTestCases.class, "test");
+ assertThat(results)
+ .hasSingleFailedTest()
+ .withExceptionInstanceOf(AssertionError.class)
+ .hasMessage("after each");
+ }
+
+ @Test
+ void afterEachAbortAfterTestFailure() {
+ ExecutionResults results = PioneerTestKit
+ .executeTestMethod(ExpectedToFailFailureThenAbortAfterEachTestCases.class, "test");
+ assertThat(results)
+ .hasSingleStartedTest()
+ .whichAborted()
+ .withExceptionInstanceOf(TestAbortedException.class)
+ .hasMessage("Test marked as temporarily 'expected to fail' failed as expected")
+ .hasCause(new AssertionFailedError("failed"))
+ // Note: This check for suppressed exception actually tests JUnit platform behavior
+ .has(new Condition<>((Throwable throwable) -> {
+ Throwable[] suppressed = throwable.getSuppressed();
+ return suppressed.length == 1 && suppressed[0] instanceof TestAbortedException
+ // Ignore message prefix generated by JUnit
+ && suppressed[0].getMessage().endsWith("custom assumption message");
+ }, "suppressed JUnit abort exception"));
+ }
+
+ @Test
+ void doesNotAbortOnBeforeAllTestFailure() {
+ ExecutionResults results = PioneerTestKit
+ .executeTestMethod(ExpectedToFailFailureBeforeAllTestCases.class, "test");
+ assertThat(results).hasNumberOfStartedTests(0);
+ assertThat(results)
+ .hasSingleFailedContainer()
+ .withExceptionInstanceOf(AssertionError.class)
+ .hasMessage("before all");
+ }
+
+ @Test
+ void doesNotAbortOnAfterAllTestFailure() {
+ ExecutionResults results = PioneerTestKit
+ .executeTestMethod(ExpectedToFailFailureAfterAllTestCases.class, "test");
+ assertThat(results)
+ .hasSingleStartedTest()
+ .whichFailed()
+ .withExceptionInstanceOf(AssertionError.class)
+ .hasMessage("Test marked as 'expected to fail' succeeded; remove @ExpectedToFail from it");
+ }
+
+ static class ExpectedToFailTestCases {
+
+ @ExpectedToFail
+ @Retention(RUNTIME)
+ @Target(METHOD)
+ @interface ExpectedToFailMetaAnnotation {
+ }
+
+ @Test
+ @ExpectedToFail
+ void failure() {
+ fail("failed");
+ }
+
+ @Test
+ @ExpectedToFailMetaAnnotation
+ void metaAnnotationFailure() {
+ fail("failed");
+ }
+
+ @Test
+ @ExpectedToFail("Custom message")
+ void failureWithMessage() {
+ fail("failed");
+ }
+
+ @Test
+ @ExpectedToFail
+ void exception() {
+ throw new RuntimeException("test");
+ }
+
+ @Test
+ @ExpectedToFail
+ void aborted() {
+ // Assumption should have higher precedence than @ExpectedToFail
+ Assumptions.assumeTrue(false, "custom assumption message");
+ }
+
+ @Test
+ @ExpectedToFail
+ void working() {
+ // Does not cause failure or error
+ }
+
+ }
+
+ /**
+ * {@link BeforeEach} should not be considered by {@link ExpectedToFail} because it
+ * is not specific to the annotated test method.
+ */
+ static class ExpectedToFailFailureBeforeEachTestCases {
+
+ @BeforeEach
+ void beforeEach() {
+ fail("before each");
+ }
+
+ @Test
+ @ExpectedToFail
+ void test() {
+ }
+
+ }
+
+ /**
+ * {@link AfterEach} should be considered by {@link ExpectedToFail} because it
+ * might fail due to changes made to the test instance by the test method.
+ */
+ static class ExpectedToFailFailureAfterEachTestCases {
+
+ @AfterEach
+ void afterEach() {
+ fail("after each");
+ }
+
+ @Test
+ @ExpectedToFail
+ void test() {
+ }
+
+ }
+
+ /**
+ * Abort in test method followed by failure in {@link AfterEach} method should
+ * be treated as expected failure.
+ */
+ static class ExpectedToFailAbortThenFailureAfterEachTestCases {
+
+ @AfterEach
+ void afterEach() {
+ fail("after each");
+ }
+
+ @Test
+ @ExpectedToFail
+ void test() {
+ Assumptions.assumeTrue(false, "custom assumption message");
+ }
+
+ }
+
+ static class ExpectedToFailFailureThenAbortAfterEachTestCases {
+
+ @AfterEach
+ void afterEach() {
+ Assumptions.assumeTrue(false, "custom assumption message");
+ }
+
+ @Test
+ @ExpectedToFail
+ void test() {
+ fail("failed");
+ }
+
+ }
+
+ /**
+ * {@link BeforeAll} should not be considered by {@link ExpectedToFail}.
+ */
+ static class ExpectedToFailFailureBeforeAllTestCases {
+
+ @BeforeAll
+ static void beforeAll() {
+ fail("before all");
+ }
+
+ @Test
+ @ExpectedToFail
+ void test() {
+ }
+
+ }
+
+ /**
+ * {@link AfterAll} should not be considered by {@link ExpectedToFail}.
+ */
+ static class ExpectedToFailFailureAfterAllTestCases {
+
+ @AfterAll
+ static void afterAll() {
+ fail("after all");
+ }
+
+ @Test
+ @ExpectedToFail
+ void test() {
+ }
+
+ }
+
+}
diff --git a/src/test/java/org/junitpioneer/testkit/assertion/PioneerAssert.java b/src/test/java/org/junitpioneer/testkit/assertion/PioneerAssert.java
index 4eaba06f8..c1496e905 100644
--- a/src/test/java/org/junitpioneer/testkit/assertion/PioneerAssert.java
+++ b/src/test/java/org/junitpioneer/testkit/assertion/PioneerAssert.java
@@ -88,84 +88,100 @@ public void hasNoReportEntries() {
@Override
public TestCaseStartedAssert hasSingleStartedTest() {
- return assertSingleTest(actual.testEvents().started().count());
+ Events events = actual.testEvents();
+ assertSingleTest(events.started());
+ // Don't filter started() here; this would prevent further assertions on test outcome on
+ // returned assert because outcome is reported for the FINISHED event, not the STARTED one
+ return new TestCaseAssertBase(events);
}
@Override
public TestCaseFailureAssert hasSingleFailedTest() {
- return assertSingleTest(actual.testEvents().failed().count());
+ return assertSingleTest(actual.testEvents().failed());
}
@Override
public void hasSingleAbortedTest() {
- assertSingleTest(actual.testEvents().aborted().count());
+ assertSingleTest(actual.testEvents().aborted());
}
@Override
public void hasSingleSucceededTest() {
- assertSingleTest(actual.testEvents().succeeded().count());
+ assertSingleTest(actual.testEvents().succeeded());
}
@Override
public void hasSingleSkippedTest() {
- assertSingleTest(actual.testEvents().skipped().count());
+ assertSingleTest(actual.testEvents().skipped());
}
@Override
public TestCaseStartedAssert hasSingleDynamicallyRegisteredTest() {
- return assertSingleTest(actual.testEvents().dynamicallyRegistered().count());
+ Events events = actual.testEvents();
+ assertSingleTest(events.dynamicallyRegistered());
+ // Don't filter dynamicallyRegistered() here; this would prevent further assertions on test outcome on
+ // returned assert because outcome is reported for the FINISHED event, not the DYNAMIC_TEST_REGISTERED one
+ return new TestCaseAssertBase(events);
}
- private TestCaseAssertBase assertSingleTest(long numberOfTestsWithOutcome) {
+ private TestCaseAssertBase assertSingleTest(Events events) {
try {
- Assertions.assertThat(numberOfTestsWithOutcome).isEqualTo(1);
- return new TestCaseAssertBase(actual.testEvents());
+ Assertions.assertThat(events.count()).isEqualTo(1);
}
catch (AssertionError error) {
getAllExceptions(actual.allEvents()).forEach(error::addSuppressed);
throw error;
}
+ return new TestCaseAssertBase(events);
}
@Override
public TestCaseStartedAssert hasSingleStartedContainer() {
- return assertSingleContainer(actual.containerEvents().started().count());
+ Events events = actual.containerEvents();
+ assertSingleTest(events.started());
+ // Don't filter started() here; this would prevent further assertions on container outcome on
+ // returned assert because outcome is reported for the FINISHED event, not the STARTED one
+ return new TestCaseAssertBase(events);
}
@Override
public TestCaseFailureAssert hasSingleFailedContainer() {
- return assertSingleContainer(actual.containerEvents().failed().count());
+ return assertSingleContainer(actual.containerEvents().failed());
}
@Override
public void hasSingleAbortedContainer() {
- assertSingleContainer(actual.containerEvents().aborted().count());
+ assertSingleContainer(actual.containerEvents().aborted());
}
@Override
public void hasSingleSucceededContainer() {
- assertSingleContainer(actual.containerEvents().succeeded().count());
+ assertSingleContainer(actual.containerEvents().succeeded());
}
@Override
public void hasSingleSkippedContainer() {
- assertSingleContainer(actual.containerEvents().skipped().count());
+ assertSingleContainer(actual.containerEvents().skipped());
}
@Override
public TestCaseStartedAssert hasSingleDynamicallyRegisteredContainer() {
- return assertSingleContainer(actual.containerEvents().dynamicallyRegistered().count());
+ Events events = actual.containerEvents();
+ assertSingleContainer(events.dynamicallyRegistered());
+ // Don't filter dynamicallyRegistered() here; this would prevent further assertions on container outcome on
+ // returned assert because outcome is reported for the FINISHED event, not the DYNAMIC_TEST_REGISTERED one
+ return new TestCaseAssertBase(events);
}
- private TestCaseAssertBase assertSingleContainer(long numberOfContainersWithOutcome) {
+ private TestCaseAssertBase assertSingleContainer(Events events) {
try {
- Assertions.assertThat(numberOfContainersWithOutcome).isEqualTo(1);
- return new TestCaseAssertBase(actual.containerEvents());
+ Assertions.assertThat(events.count()).isEqualTo(1);
}
catch (AssertionError error) {
getAllExceptions(actual.allEvents()).forEach(error::addSuppressed);
throw error;
}
+ return new TestCaseAssertBase(events);
}
@Override
diff --git a/src/test/java/org/junitpioneer/testkit/assertion/TestCaseAssertBase.java b/src/test/java/org/junitpioneer/testkit/assertion/TestCaseAssertBase.java
index d2d32692d..ba7b2c763 100644
--- a/src/test/java/org/junitpioneer/testkit/assertion/TestCaseAssertBase.java
+++ b/src/test/java/org/junitpioneer/testkit/assertion/TestCaseAssertBase.java
@@ -20,11 +20,12 @@
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.testkit.engine.Events;
+import org.junitpioneer.testkit.assertion.single.TestCaseAbortedAssert;
import org.junitpioneer.testkit.assertion.single.TestCaseFailureAssert;
import org.junitpioneer.testkit.assertion.single.TestCaseStartedAssert;
class TestCaseAssertBase extends AbstractPioneerAssert
- implements TestCaseStartedAssert, TestCaseFailureAssert {
+ implements TestCaseStartedAssert, TestCaseFailureAssert, TestCaseAbortedAssert {
TestCaseAssertBase(Events events) {
super(events, TestCaseAssertBase.class, 1);
@@ -45,7 +46,7 @@ public AbstractThrowableAssert, T> withExceptionInstance
@Override
public TestCaseFailureAssert whichFailed() {
assertThat(actual.failed().count()).isEqualTo(1);
- return this;
+ return new TestCaseAssertBase(actual.failed());
}
@Override
@@ -54,8 +55,9 @@ public void whichSucceeded() {
}
@Override
- public void whichAborted() {
+ public TestCaseAbortedAssert whichAborted() {
assertThat(actual.aborted().count()).isEqualTo(1);
+ return new TestCaseAssertBase(actual.aborted());
}
@Override
@@ -78,7 +80,6 @@ private Throwable getRequiredThrowable() {
private Optional extends Throwable> throwable() {
return actual
- .failed()
.stream()
.findFirst()
.flatMap(fail -> fail.getPayload(TestExecutionResult.class))
diff --git a/src/test/java/org/junitpioneer/testkit/assertion/single/TestCaseAbortedAssert.java b/src/test/java/org/junitpioneer/testkit/assertion/single/TestCaseAbortedAssert.java
new file mode 100644
index 000000000..dfe41cda2
--- /dev/null
+++ b/src/test/java/org/junitpioneer/testkit/assertion/single/TestCaseAbortedAssert.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2016-2022 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * http://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junitpioneer.testkit.assertion.single;
+
+import org.assertj.core.api.AbstractThrowableAssert;
+
+/**
+ * Used to assert a single aborted container or test.
+ */
+public interface TestCaseAbortedAssert {
+
+ /**
+ * Asserts that the test/container was aborted because of a specific type of exception.
+ *
+ * @param exceptionType the expected type of the thrown exception
+ * @return an {@link AbstractThrowableAssert} for further assertions
+ */
+ AbstractThrowableAssert, T> withExceptionInstanceOf(Class exceptionType);
+
+}
diff --git a/src/test/java/org/junitpioneer/testkit/assertion/single/TestCaseStartedAssert.java b/src/test/java/org/junitpioneer/testkit/assertion/single/TestCaseStartedAssert.java
index 5cd695aed..685fc92ca 100644
--- a/src/test/java/org/junitpioneer/testkit/assertion/single/TestCaseStartedAssert.java
+++ b/src/test/java/org/junitpioneer/testkit/assertion/single/TestCaseStartedAssert.java
@@ -23,8 +23,10 @@ public interface TestCaseStartedAssert {
/**
* Asserts that the test/container was aborted.
+ *
+ * @return a {@link TestCaseAbortedAssert} for further assertions.
*/
- void whichAborted();
+ TestCaseAbortedAssert whichAborted();
/**
* Asserts that the test/container has failed.