Defining Requirements with Annotations
Serenity BDD organises test results into a requirements hierarchy, which provides the structure for the Living Documentation reports. By default, this hierarchy is derived from the package structure (for JUnit tests) or from the directory structure (for Cucumber feature files).
Starting with Serenity 5.3.1, you can define the requirements hierarchy directly using annotations on your test classes. This provides a simple, explicit, and declarative way to organise your tests into a meaningful structure, without having to rely on package naming conventions.
The Requirements Hierarchyβ
Serenity supports a three-level requirements hierarchy:
Epic > Feature > Story
Each level is represented by an annotation:
| Annotation | Level | Purpose |
|---|---|---|
@Epic | Highest | A large body of work, spanning multiple features |
@Feature | Middle | A coherent piece of functionality that users value |
@Story | Lowest | A specific user story or scenario group within a feature |
You can use any combination of these levels. Not all three are required β a two-level structure (@Feature > @Story) or even a single level (@Feature alone) works just as well.
Basic Usageβ
Feature and Storyβ
The most common pattern is to annotate a test class with @Feature and @Story:
import net.serenitybdd.annotations.Feature;
import net.serenitybdd.annotations.Story;
@Feature("Managing Todos")
@Story("Complete todo items")
class WhenCompletingTodosTest extends SerenityPlaywrightTest {
@Test
void shouldMarkTodoAsCompleted() {
// ...
}
@Test
void shouldToggleAllTodosToCompleted() {
// ...
}
}
This produces the following hierarchy in the requirements report:
Managing Todos (feature)
βββ Complete todo items (story)
βββ Should mark todo as completed
βββ Should toggle all todos to completed
Multiple Stories Under the Same Featureβ
Several test classes can share the same feature while defining different stories:
@Feature("Managing Todos")
@Story("Complete todo items")
class WhenCompletingTodosTest { /* ... */ }
@Feature("Managing Todos")
@Story("Delete todo items")
class WhenDeletingTodosTest { /* ... */ }
@Feature("Managing Todos")
@Story("Filter todo items")
class WhenFilteringTodosTest { /* ... */ }
This creates a single feature in the report with three child stories:
Managing Todos (feature)
βββ Complete todo items (story)
βββ Delete todo items (story)
βββ Filter todo items (story)
Multiple Featuresβ
Different test classes can belong to different features:
@Feature("Creating Todos")
@Story("Add todo items")
class WhenAddingTodosTest { /* ... */ }
@Feature("Managing Todos")
@Story("Complete todo items")
class WhenCompletingTodosTest { /* ... */ }
This produces two separate features in the report:
Creating Todos (feature)
βββ Add todo items (story)
Managing Todos (feature)
βββ Complete todo items (story)
The Full Three-Level Hierarchyβ
For larger projects, you can use @Epic to group related features:
import net.serenitybdd.annotations.Epic;
import net.serenitybdd.annotations.Feature;
import net.serenitybdd.annotations.Story;
@Epic("E-Commerce Platform")
@Feature("Shopping Cart")
@Story("Add item to cart")
class WhenAddingItemsToCartTest { /* ... */ }
@Epic("E-Commerce Platform")
@Feature("Shopping Cart")
@Story("Remove item from cart")
class WhenRemovingItemsFromCartTest { /* ... */ }
@Epic("E-Commerce Platform")
@Feature("Checkout")
@Story("Pay with credit card")
class WhenPayingWithCreditCardTest { /* ... */ }
This produces a three-level hierarchy:
E-Commerce Platform (epic)
βββ Shopping Cart (feature)
β βββ Add item to cart (story)
β βββ Remove item from cart (story)
βββ Checkout (feature)
βββ Pay with credit card (story)
Using @DisplayName as the Story Nameβ
When a test class has @Feature (or @Epic) but no @Story annotation, Serenity uses the JUnit 5 @DisplayName value as the story name. This is convenient when you want the display name in JUnit and the story name in Serenity to be the same:
@Feature("Managing Todos")
@DisplayName("Complete todo items")
class WhenCompletingTodosTest {
@Test
@DisplayName("should mark a todo as completed")
void shouldMarkTodoAsCompleted() {
// ...
}
}
This produces the same hierarchy as if you had written @Story("Complete todo items"):
Managing Todos (feature)
βββ Complete todo items (story)
βββ should mark a todo as completed
When both @Story and @DisplayName are present, @Story takes precedence for the requirements hierarchy, and @DisplayName is used only for the JUnit test runner display:
@Feature("Managing Todos")
@Story("Complete todo items")
@DisplayName("When completing todos")
class WhenCompletingTodosTest { /* ... */ }
Here, the story name in the report is "Complete todo items" (from @Story), not "When completing todos" (from @DisplayName).
Use @Story when you want the requirements hierarchy name to differ from the JUnit display name. For example, you might want a business-oriented name in the report ("Complete todo items") while using a BDD-style name in the test runner ("When completing todos").
Use @DisplayName alone (without @Story) when the same name works for both purposes.
Using @Feature Without @Story or @DisplayNameβ
If a test class has only @Feature with no @Story and no @DisplayName, the test class is placed directly under the feature. The humanised class name is used as the story name:
@Feature("Managing Todos")
class WhenCompletingTodos {
// Story name in report: "When completing todos" (from class name)
}
Partial Hierarchiesβ
You don't need to use all three levels. Any subset works:
Feature Onlyβ
@Feature("User Authentication")
@Story("Login with valid credentials")
class WhenLoggingInTest { /* ... */ }
User Authentication (feature)
βββ Login with valid credentials (story)
Epic and Feature (No Story)β
@Epic("Security")
@Feature("User Authentication")
@DisplayName("Login with valid credentials")
class WhenLoggingInTest { /* ... */ }
Security (epic)
βββ User Authentication (feature)
βββ Login with valid credentials (story)
Epic Onlyβ
@Epic("Security")
@DisplayName("Login with valid credentials")
class WhenLoggingInTest { /* ... */ }
Security (epic)
βββ Login with valid credentials (story)
Inheriting Annotations from Superclassesβ
Annotations are inherited from parent classes. This is useful when you have a common base class for a group of tests:
@Epic("TodoMVC Application")
@Feature("Managing Todos")
abstract class TodoManagementTest extends SerenityPlaywrightTest {
// Common setup and utilities
}
@Story("Complete todo items")
class WhenCompletingTodosTest extends TodoManagementTest {
// Inherits @Epic and @Feature from parent
}
@Story("Delete todo items")
class WhenDeletingTodosTest extends TodoManagementTest {
// Inherits @Epic and @Feature from parent
}
The most specific annotation wins. If a subclass redefines an annotation that is already present on the parent, the subclass annotation takes precedence:
@Feature("General Feature")
abstract class BaseTest { }
@Feature("Specific Feature") // Overrides parent's @Feature
@Story("My Story")
class SpecificTest extends BaseTest { }
Inheriting Annotations in JUnit 5 @Nested Classesβ
When using JUnit 5 @Nested inner classes, annotations on the enclosing (outer) class are inherited by the nested class. This is a natural way to define the feature or epic once on the outer class and then define individual stories in each nested class:
@ExtendWith(SerenityJUnit5Extension.class)
@Feature("Product Catalog")
class ProductCatalogTests {
@Nested
@Story("Searching by keyword")
class WhenSearchingByKeyword {
@Test
void shouldFindProductsByName() { /* ... */ }
@Test
void shouldReturnEmptyResultsForUnknownKeyword() { /* ... */ }
}
@Nested
@Story("Browsing by category")
class WhenBrowsingByCategory {
@Test
void shouldListProductsInCategory() { /* ... */ }
}
}
This produces the following hierarchy in the requirements report:
Product Catalog (feature)
βββ Searching by keyword (story)
β βββ Should find products by name
β βββ Should return empty results for unknown keyword
βββ Browsing by category (story)
βββ Should list products in category
Full Three-Level Hierarchy with Nested Classesβ
You can combine @Epic and @Feature on the outer class with @Story on nested classes for the full hierarchy:
@ExtendWith(SerenityJUnit5Extension.class)
@Epic("E-Commerce Platform")
@Feature("Shopping Cart")
class ShoppingCartTests {
@Nested
@Story("Add item to cart")
class WhenAddingItems {
@Test
void shouldAddSingleItem() { /* ... */ }
}
@Nested
@Story("Remove item from cart")
class WhenRemovingItems {
@Test
void shouldRemoveSelectedItem() { /* ... */ }
}
}
This produces:
E-Commerce Platform (epic)
βββ Shopping Cart (feature)
βββ Add item to cart (story)
β βββ Should add single item
βββ Remove item from cart (story)
βββ Should remove selected item
Overriding Annotations in Nested Classesβ
If a nested class redefines an annotation that is already present on the outer class, the nested class annotation takes precedence:
@Feature("Product Catalog")
class ProductTests {
@Nested
@Feature("Checkout") // Overrides "Product Catalog"
@Story("Express checkout")
class WhenUsingExpressCheckout { /* ... */ }
}
Using @DisplayName as Story Name in Nested Classesβ
When a nested class has no @Story annotation, its @DisplayName is used as the story name, just as it is for top-level classes:
@Feature("Product Catalog")
class ProductCatalogTests {
@Nested
@DisplayName("Searching by keyword") // Used as the story name
class WhenSearchingByKeyword { /* ... */ }
}
Use @Nested classes when you want to group related stories under a single outer class and share setup code. Use separate top-level test classes when the stories are more independent. Both approaches produce the same requirements hierarchy in the reports.
Annotations vs Package-Based Requirementsβ
Annotation-based requirements override the default package-based hierarchy for the annotated test class. This means you can mix both approaches in the same project:
- Test classes with
@Feature,@Story, or@Epicannotations use the annotation-based hierarchy - Test classes without these annotations fall back to the package-based hierarchy
When annotation-based and package-based hierarchies coexist in the same project, they appear as separate branches in the requirements tree. There is no merging between the two.
Annotations and Cucumberβ
If you use both JUnit tests and Cucumber feature files in the same project, each group will have its own place in the requirements hierarchy:
- JUnit tests with
@Feature/@Storyannotations create requirements based on the annotation values - Cucumber features create requirements based on the directory structure and feature file names
To avoid confusion, use distinct names for annotation-based features and Cucumber features, or organise them into separate sections of your requirements hierarchy using @Epic.
Working with the Screenplay Patternβ
The annotations work the same way with Screenplay-based tests:
import net.serenitybdd.annotations.Feature;
import net.serenitybdd.annotations.Story;
import net.serenitybdd.screenplay.ensure.Ensure;
@Feature("Managing Todos")
@Story("Filter todo items")
@DisplayName("When filtering todos (Screenplay)")
class WhenFilteringTodosScreenplayTest extends ScreenplayPlaywrightTest {
@Test
@DisplayName("should filter to show only active todos")
void shouldFilterToShowOnlyActiveTodos() {
toby.attemptsTo(
FilterTodos.toShowActive(),
Ensure.that(TheCurrentFilter.selected()).isEqualTo("Active"),
Ensure.that(TheVisibleTodos.displayed())
.containsExactly("Buy milk", "Do laundry")
);
}
}
Tagsβ
In addition to organising the requirements hierarchy, the @Epic, @Feature, and @Story annotations also generate tags in the Serenity reports. A test annotated with:
@Epic("E-Commerce Platform")
@Feature("Shopping Cart")
@Story("Add item to cart")
will have the following tags in the report:
epic:E-Commerce Platformfeature:Shopping Cartstory:Add item to cart
These tags can be used for filtering test results in the Serenity reports.
Summary of Annotation Behaviourβ
| Annotations Present | Story Name in Report | Requirements Path |
|---|---|---|
@Feature + @Story | @Story value | Feature / Story |
@Feature + @DisplayName (no @Story) | @DisplayName value | Feature / DisplayName |
@Feature only | Humanised class name | Feature / class name |
@Epic + @Feature + @Story | @Story value | Epic / Feature / Story |
@Epic + @Feature + @DisplayName | @DisplayName value | Epic / Feature / DisplayName |
@Epic + @Story | @Story value | Epic / Story |
@Epic + @DisplayName | @DisplayName value | Epic / DisplayName |
@Story only | @Story value | Story |
No annotations + @DisplayName | @DisplayName value | Package-based path |
Migration from Class-Based Annotationsβ
In earlier versions of Serenity, the @Story annotation required a class reference to define the requirements hierarchy:
// Legacy approach (still supported)
public class MyApp {
@Feature
public class ShoppingCart {
public class AddItem {}
}
}
@Story(storyClass = MyApp.ShoppingCart.AddItem.class)
class WhenAddingItemsTest { /* ... */ }
This approach required creating boilerplate marker classes and only supported a two-level hierarchy (Feature > Story).
The new string-based annotations replace this with a simpler, more flexible approach:
// New approach
@Feature("Shopping Cart")
@Story("Add item to cart")
class WhenAddingItemsTest { /* ... */ }
The legacy @Story(storyClass = ...) syntax is still supported for backwards compatibility.