Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FailsAt annotation #814

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
url: /docs/disable-parameterized-tests/
- title: "Expected-to-Fail Tests"
url: /docs/expected-to-fail-tests/
- title: "Fail Test at a Date"
url: /docs/fail-at/
- title: "Injecting Resources"
url: /docs/resources/
- title: "Injecting Temporary Directories"
Expand Down
56 changes: 56 additions & 0 deletions docs/fail-at.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
:page-title: Fail test after certain date
:page-description: The JUnit 5 (Jupiter) extension `@FailAt` fails a test after a certain date
:xp-demo-dir: ../src/demo/java
:demo: {xp-demo-dir}/org/junitpioneer/jupiter/FailAtExtensionDemo.java

It's sometimes useful to fail a test after a certain date.
One can imagine many reasons for doing so, maybe a remote dependency of the test is not licenced anymore.

The `@FailAt` annotation is perfectly adequate to use in such cases.
The test will fail when the given date is reached.

[WARNING]
====
This annotation allows the user to move an https://junit.org/junit5/docs/current/user-guide/#writing-tests-assumptions[assumption] out of one or multiple test method's code into the annotation.
But this comes at a cost - applying `@FailAt` can make the test suite non-reproducible.
If a passing test is run again after the "failAt" date that build would fail.
A report entry is issued for every test that does not fail until a certain date.
====

== Usage

To mark a test to fail at a given date add the `@FailAt` annotation like so:

[source,java,indent=0]
----
include::{demo}[tag=fail_at_simple]
----

The `date` parameter must be a string in the date format specified by https://en.m.wikipedia.org/wiki/ISO_8601[ISO 8601], e.g. "1985-10-26".
Invalid or unparsable date strings lead to an `ExtensionConfigurationException`.

The `@FailAt annotation may optionally be declared with a reason to document why the annotated test class or test method fails as soon as the date is reached:

[source,java,indent=0]
----
include::{demo}[tag=fail_at_with_reason]
----

The `@FailAt` annotation can be used on the class and method level, it will be inherited from higher level containers:

[source,java,indent=0]
----
include::{demo}[tag=fail_at_at_class_level]
----

The `@FailAt` annotation can only be used once per class or method.

== Before and After

The test will be executed normally if the date specified by `date` is the future but a warning entry will be published to the https://junit-pioneer.org/docs/report-entries[test report] to indicate that there might be a problem in the future.

If `date` is today or in the past, the test will fail as the execution condition is not fulfilled anymore.

== Thread-Safety

This extension is safe to use during https://junit.org/junit5/docs/current/user-guide/#writing-tests-parallel-execution[parallel test execution].
47 changes: 47 additions & 0 deletions src/demo/java/org/junitpioneer/jupiter/FailAtExtensionDemo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2024 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 org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

public class FailAtExtensionDemo {

// tag::fail_at_simple[]
@Test
@FailAt(date = "2025-01-01")
void test() {
// Test will fail as soon as 1st of January 2025 is reached.
}
// end::fail_at_simple[]

// tag::fail_at_with_reason[]
@Test
@DisabledUntil(reason = "We are not allowed to call that method anymore", date = "2025-01-01")
void testWithReason() {
// Test will fail as soon as 1st of January 2025 is reached.
}
// end::fail_at_with_reason[]

@Nested
// tag::fail_at_at_class_level[]
@FailAt(date = "2025-01-01")
class TestClass {

@Test
void test() {
// Test will fail as soon as 1st of January 2025 is reached.
}

}
// end::fail_at_at_class_level[]

}
56 changes: 56 additions & 0 deletions src/main/java/org/junitpioneer/jupiter/FailAt.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2024 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 java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

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

/**
* {@code @FailAt} is a JUnit Jupiter extension to mark tests that shouldn't be executed after a given date,
* essentially failing a test when the date is reached. The date is given as an ISO 8601 string.
*
* <p>It may optionally be declared with a reason to document why the annotated test class or test
* method should fail at the given date.</p>
*
* <p>{@code @FailAt} can be used on the method and class level. It can only be used once per method or class,
* but is inherited from higher-level containers.</p>
*
* <p><strong>WARNING:</strong> This annotation allows the user to move an assumption out of one or multiple test
* method's code into an annotation. But this comes at a cost - applying {@code @FailAt} can make the test suite
* non-reproducible. If a passing test is run again after the "failAt" date that build would fail. A report entry is
* issued for every test that does not fail until a certain date.</p>
*
* @since 2.3.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.TYPE })
@Inherited
@ExtendWith(FailAtExtension.class)
public @interface FailAt {

/**
* The reason this annotated test class or test method should fail as soon as the given date is reached.
*/
String reason() default "";

/**
* The date from which this annotated test class or test method should fail as an ISO 8601 string in the
* format yyyy-MM-dd, e.g. 2023-05-28.
* The test will be executed regularly if that date is not yet reached.
*/
String date();

}
80 changes: 80 additions & 0 deletions src/main/java/org/junitpioneer/jupiter/FailAtExtension.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright 2024 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.String.format;
import static org.junit.jupiter.api.extension.ConditionEvaluationResult.enabled;
import static org.junitpioneer.internal.PioneerAnnotationUtils.findClosestEnclosingAnnotation;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Optional;

import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionConfigurationException;
import org.junit.jupiter.api.extension.ExtensionContext;

/**
* This class implements the functionality for the {@code @FailAt} annotation.
*
* @see FailAt
*/
class FailAtExtension implements ExecutionCondition {

private static final DateTimeFormatter ISO_8601 = DateTimeFormatter.ISO_DATE;

@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
return getFailAtDateFromAnnotation(context)
.map(failAtDate -> evaluateFailAtDate(context, failAtDate))
.orElse(enabled("No @FailAt annotation found on element"));
}

private Optional<LocalDate> getFailAtDateFromAnnotation(ExtensionContext context) {
return findClosestEnclosingAnnotation(context, FailAt.class).map(FailAt::date).map(this::parseDate);
}

private LocalDate parseDate(String dateString) {
try {
return LocalDate.parse(dateString, ISO_8601);
}
catch (DateTimeParseException ex) {
throw new ExtensionConfigurationException(
"The `failAtDate` string '" + dateString + "' is no valid ISO-8601 string.", ex);
}
}

private ConditionEvaluationResult evaluateFailAtDate(ExtensionContext context, LocalDate failAtDate) {
LocalDate today = LocalDate.now();
boolean isBefore = today.isBefore(failAtDate);

if (isBefore) {
String reportEntry = format(
"The `date` %s is after the current date %s, so `@FailAt` did not fails the test \"%s\". It will do so when the date is reached.",
failAtDate.format(ISO_8601), today.format(ISO_8601), context.getUniqueId());
context.publishReportEntry("FailAt", reportEntry);
return enabled(reportEntry);
} else {
String reportEntry = format(
"The current date %s is after or on the `date` %s, so `@FailAt` fails the test \"%s\". Please remove the annotation.",
failAtDate.format(ISO_8601), today.format(ISO_8601), context.getUniqueId());
context.publishReportEntry(FailAtExtension.class.getSimpleName(), reportEntry);

String message = format("The current date %s is after or on the `date` %s", today.format(ISO_8601),
failAtDate.format(ISO_8601));

throw new ExtensionConfigurationException(message);
}
}

}
1 change: 1 addition & 0 deletions src/main/java/org/junitpioneer/jupiter/package-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* <li>{@link org.junitpioneer.jupiter.DisabledUntil}</li>
* <li>{@link org.junitpioneer.jupiter.DisableIfTestFails}</li>
* <li>{@link org.junitpioneer.jupiter.ExpectedToFail}</li>
* <li>{@link org.junitpioneer.jupiter.FailAt}</li>
* <li>{@link org.junitpioneer.jupiter.ReportEntry}</li>
* <li>{@link org.junitpioneer.jupiter.RetryingTest}</li>
* <li>{@link org.junitpioneer.jupiter.StdIo}</li>
Expand Down
112 changes: 112 additions & 0 deletions src/test/java/org/junitpioneer/jupiter/FailAtExtensionTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright 2024 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.junitpioneer.testkit.assertion.PioneerAssert.assertThat;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junitpioneer.testkit.ExecutionResults;
import org.junitpioneer.testkit.PioneerTestKit;

@DisplayName("Tests for the FailAt extension")
class FailAtExtensionTests {

@Test
@DisplayName("Should not fail test without annotation")
void shouldNotFailTestWithoutAnnotation() {
final ExecutionResults results = PioneerTestKit.executeTestMethod(FailAtTestCases.class, "testNoAnnotation");
assertThat(results).hasSingleStartedTest();
assertThat(results).hasSingleSucceededTest();
assertThat(results).hasNumberOfSkippedTests(0);
assertThat(results).hasNoReportEntries();
}

@Test
@DisplayName("Should enable test with unparsable `date`` string")
void shouldEnableTestWithUnparsableUntilDateString() {
final ExecutionResults results = PioneerTestKit
.executeTestMethod(FailAtTestCases.class, "testUnparsableUntilDateString");
assertThat(results).hasSingleStartedTest();
assertThat(results).hasSingleFailedTest();
assertThat(results).hasNoReportEntries();
}

@Test
@DisplayName("Should fail test with `date` in the past")
void shouldFailTestWithFailAtDateInThePast() {
final ExecutionResults results = PioneerTestKit
.executeTestMethod(FailAtTestCases.class, "testIsAnnotatedWithDateInThePast");
assertThat(results).hasSingleStartedTest();
assertThat(results).hasSingleFailedTest();
assertThat(results).hasSingleReportEntry().firstValue().contains("is after or on the `date`");

}

@Test
@DisplayName("Should not fail a test with `date` in the future")
void shouldNotFailTestWithFailAtDateInTheFuture() {
final ExecutionResults results = PioneerTestKit
.executeTestMethod(FailAtTestCases.class, "testIsAnnotatedWithDateInTheFuture");
assertThat(results).hasSingleSucceededTest();
assertThat(results).hasSingleReportEntry().firstValue().contains("2199-01-01", "did not fails the test");
}

@Test
@DisplayName("Should fail nested test with `date` in the past when meta annotated by higher level container")
void shouldFailNestedTestWithFailAtDateInThPastWhenMetaAnnotated() {
final ExecutionResults results = PioneerTestKit
.executeTestMethod(FailAtTestCases.NestedTestCases.class, "shouldRetrieveFromClass");
assertThat(results).hasSingleFailedContainer();
assertThat(results).hasNumberOfStartedTests(0);
assertThat(results).hasSingleReportEntry().firstValue().contains("is after or on the `date`");
}

static class FailAtTestCases {

@Test
void testNoAnnotation() {

}

@Test
@FailAt(reason = "I don't know how to write dates!", date = "xxxx-yy-zz")
void testUnparsableUntilDateString() {

}

@Test
@FailAt(reason = "Everything was better in the past!", date = "1993-01-01")
void testIsAnnotatedWithDateInThePast() {

}

@Test
@FailAt(reason = "Keep on running!", date = "2199-01-01")
void testIsAnnotatedWithDateInTheFuture() {

}

@Nested
@FailAt(reason = "My child is younger than me, but still old!", date = "1993-01-01")
class NestedTestCases {

@Test
void shouldRetrieveFromClass() {

}

}

}

}