findById(String id) {
+ return Optional.ofNullable(store.get(id));
+ }
+ }
+}
diff --git a/DisplayNestedTag/src/test/java/com/ankurm/tutorials/junit/displaynestedtag/SentenceCaseDisplayNameGenerator.java b/DisplayNestedTag/src/test/java/com/ankurm/tutorials/junit/displaynestedtag/SentenceCaseDisplayNameGenerator.java
new file mode 100644
index 0000000..1f894c1
--- /dev/null
+++ b/DisplayNestedTag/src/test/java/com/ankurm/tutorials/junit/displaynestedtag/SentenceCaseDisplayNameGenerator.java
@@ -0,0 +1,65 @@
+package com.ankurm.tutorials.junit.displaynestedtag;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.DisplayNameGenerator;
+
+/**
+ * A {@link DisplayNameGenerator} that turns {@code camelCaseMethodNames} into plain sentences
+ * without touching a single {@code @DisplayName} annotation.
+ *
+ * This implements the current (JUnit 6 / 5.12+) three-argument overloads that
+ * take the list of enclosing {@code @Nested} classes, not the single-{@code Class} overloads that
+ * {@code javap} shows as {@code @Deprecated(since = "5.12")} on {@link DisplayNameGenerator}. The
+ * deprecated overloads are still called by the engine for a generator that does not override the
+ * new ones, which is a trap: override the new signature or your generator silently falls back to
+ * {@link Standard} behaviour for nested classes and parameterised methods.
+ *
+ *
Explained in the companion post:
+ * https://ankurm.com/organising-displaying-junit-tests-displayname-nested-tag/
+ */
+public class SentenceCaseDisplayNameGenerator implements DisplayNameGenerator {
+
+ private static final Pattern CAMEL_BOUNDARY = Pattern.compile("(?<=[a-z0-9])(?=[A-Z])");
+
+ @Override
+ public String generateDisplayNameForClass(Class> testClass) {
+ return toSentence(testClass.getSimpleName());
+ }
+
+ @Override
+ public String generateDisplayNameForNestedClass(List> enclosingInstanceTypes, Class> nestedClass) {
+ // enclosingInstanceTypes lets a generator see how deep it is nested; we use it only to
+ // indent, which is enough to show the hierarchy in a flat console run.
+ String indent = " ".repeat(enclosingInstanceTypes.size());
+ return indent + toSentence(nestedClass.getSimpleName());
+ }
+
+ @Override
+ public String generateDisplayNameForMethod(List> enclosingInstanceTypes, Class> testClass, Method testMethod) {
+ String indent = " ".repeat(enclosingInstanceTypes.size() + 1);
+ return indent + "→ " + toSentence(testMethod.getName());
+ }
+
+ private static String toSentence(String camelCaseName) {
+ String[] words = CAMEL_BOUNDARY.split(camelCaseName);
+ StringBuilder sentence = new StringBuilder();
+ for (int i = 0; i < words.length; i++) {
+ String word = words[i].toLowerCase();
+ sentence.append(i == 0 ? capitalise(word) : word);
+ if (i < words.length - 1) {
+ sentence.append(' ');
+ }
+ }
+ return sentence.toString();
+ }
+
+ private static String capitalise(String word) {
+ if (word.isEmpty()) {
+ return word;
+ }
+ return Character.toUpperCase(word.charAt(0)) + word.substring(1);
+ }
+}
diff --git a/DisplayNestedTag/src/test/java/com/ankurm/tutorials/junit/displaynestedtag/ValidationChecksTest.java b/DisplayNestedTag/src/test/java/com/ankurm/tutorials/junit/displaynestedtag/ValidationChecksTest.java
new file mode 100644
index 0000000..5c4771b
--- /dev/null
+++ b/DisplayNestedTag/src/test/java/com/ankurm/tutorials/junit/displaynestedtag/ValidationChecksTest.java
@@ -0,0 +1,62 @@
+package com.ankurm.tutorials.junit.displaynestedtag;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Class-level {@code @Tag("fast")} and {@code @Tag("unit")} apply to every test below, including
+ * the one written with the {@link FastUnitCheck} composed annotation - both routes produce the
+ * same two tags on that method, which {@code docs/output/04-tag-filtering-fast.txt} confirms by
+ * showing it selected in the "fast" run.
+ */
+@Tag("fast")
+@Tag("unit")
+class ValidationChecksTest {
+
+ @Test
+ @DisplayName("an email without an @ is rejected")
+ void emailWithoutAtIsRejected() {
+ assertFalse(isValidEmail("not-an-email"));
+ }
+
+ @Test
+ @DisplayName("an email with a local part, @, and domain is accepted")
+ void wellFormedEmailIsAccepted() {
+ assertTrue(isValidEmail("reader@ankurm.com"));
+ }
+
+ @FastUnitCheck
+ @DisplayName("a blank email is rejected")
+ void blankEmailIsRejected() {
+ assertFalse(isValidEmail(" "));
+ }
+
+ @Test
+ @Tag("slow") // method-level tag ADDS to the class-level ones: this test is fast+unit+slow
+ @DisplayName("validating ten thousand emails stays correct at volume")
+ void validatesLargeVolumeCorrectly() {
+ for (int i = 0; i < 10_000; i++) {
+ assertTrue(isValidEmail("user" + i + "@example.com"));
+ }
+ }
+
+ @Test
+ @Disabled("Waiting on RFC 6531 (internationalised addresses) support - tracked as TUT-142")
+ @DisplayName("an internationalised domain name is accepted")
+ void internationalisedDomainIsAccepted() {
+ assertTrue(isValidEmail("user@ünicöde.example"));
+ }
+
+ private static boolean isValidEmail(String candidate) {
+ if (candidate == null || candidate.isBlank()) {
+ return false;
+ }
+ int at = candidate.indexOf('@');
+ return at > 0 && at < candidate.length() - 1;
+ }
+}