This lesson walks through the capstone project end-to-end. Each step builds on the previous one. The code here is complete and runnable — copy it into your project, make it compile, and move to the next step. By the end you will have a working test suite with unit tests, an extension, a Maven build, and a report.
Step 1 — pom.xml
Start with the project definition. Every dependency and plugin the suite needs is declared here:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.mycompany.calculator</groupId>
<artifactId>calculator-tests</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<baseUrl>http://localhost:8080</baseUrl>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-junit5</artifactId>
<version>2.25.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<groups>unit</groups>
<systemPropertyVariables>
<baseUrl>${baseUrl}</baseUrl>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.2.5</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>Step 2 — CalculatorService
This is the system under test. Write it in src/main/java:
package com.mycompany.calculator;
public class CalculatorService {
public double add(double a, double b) { return a + b; }
public double subtract(double a, double b) { return a - b; }
public double multiply(double a, double b) { return a * b; }
public double divide(double a, double b) {
if (b == 0) throw new ArithmeticException("Division by zero");
return a / b;
}
public double power(double base, int exponent) {
if (exponent < 0) throw new IllegalArgumentException("Negative exponent not supported");
return Math.pow(base, exponent);
}
public double sqrt(double value) {
if (value < 0) throw new IllegalArgumentException("Square root of negative number");
return Math.sqrt(value);
}
}Compile it: mvn compile. Fix any errors before adding tests.
Step 3 — CalculatorTestExtension
Write this next — before the tests — so every test in the suite gets timing output from day one:
package com.mycompany.calculator.extensions;
import org.junit.jupiter.api.extension.*;
import java.util.Optional;
public class CalculatorTestExtension
implements BeforeTestExecutionCallback, AfterTestExecutionCallback, TestWatcher {
private static final String START_TIME = "startTime";
private static final ExtensionContext.Namespace NS =
ExtensionContext.Namespace.create(CalculatorTestExtension.class);
@Override
public void beforeTestExecution(ExtensionContext ctx) {
ctx.getStore(NS).put(START_TIME, System.currentTimeMillis());
}
@Override
public void afterTestExecution(ExtensionContext ctx) {
long start = ctx.getStore(NS).get(START_TIME, long.class);
long ms = System.currentTimeMillis() - start;
System.out.printf("[TIMING] %s: %dms%n", ctx.getDisplayName(), ms);
}
@Override
public void testSuccessful(ExtensionContext ctx) {
System.out.println("✅ PASSED: " + ctx.getDisplayName());
}
@Override
public void testFailed(ExtensionContext ctx, Throwable cause) {
System.out.println("❌ FAILED: " + ctx.getDisplayName() + " — " + cause.getMessage());
}
@Override
public void testAborted(ExtensionContext ctx, Throwable cause) {
System.out.println("⏭ SKIPPED: " + ctx.getDisplayName());
}
@Override
public void testDisabled(ExtensionContext ctx, Optional<String> reason) {
System.out.println("🚫 DISABLED: " + ctx.getDisplayName()
+ reason.map(r -> " — " + r).orElse(""));
}
}Register it globally. Create src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension with the single line:
com.mycompany.calculator.extensions.CalculatorTestExtension
Step 4 — CalculatorServiceTest
The main unit test class. Notice the @Nested structure and the @CsvSource parameterisation — this is the pattern that makes the report read as a specification:
package com.mycompany.calculator;
import com.mycompany.calculator.extensions.CalculatorTestExtension;
import io.qameta.allure.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
@Tag("unit")
@Feature("Calculator Operations")
@DisplayName("CalculatorService")
class CalculatorServiceTest {
CalculatorService calculator;
@BeforeEach
void setUp() {
calculator = new CalculatorService();
}
@Nested
@DisplayName("Addition")
class Addition {
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"0, 0, 0",
"1, 1, 2",
"-5, 5, 0",
"1.5, 2.5, 4.0",
"100, -50, 50"
})
@Story("add() computes the correct sum")
void add(double a, double b, double expected) {
assertEquals(expected, calculator.add(a, b), 1e-9);
}
}
@Nested
@DisplayName("Subtraction")
class Subtraction {
@ParameterizedTest(name = "{0} - {1} = {2}")
@CsvSource({
"10, 3, 7",
"0, 0, 0",
"-1, -1, 0",
"5.5, 2.5, 3.0"
})
void subtract(double a, double b, double expected) {
assertEquals(expected, calculator.subtract(a, b), 1e-9);
}
}
@Nested
@DisplayName("Division")
class Division {
@ParameterizedTest(name = "{0} / {1} = {2}")
@CsvSource({
"10, 2, 5.0",
"-9, 3, -3.0",
"7, 2, 3.5",
"1, 3, 0.333"
})
void divide(double a, double b, double expected) {
assertEquals(expected, calculator.divide(a, b), 0.001);
}
@ParameterizedTest
@MethodSource("divisionByZeroCases")
@DisplayName("should throw ArithmeticException for division by zero")
void divideByZero(double a, double b) {
ArithmeticException ex = assertThrows(ArithmeticException.class,
() -> calculator.divide(a, b));
assertEquals("Division by zero", ex.getMessage());
}
static Stream<Arguments> divisionByZeroCases() {
return Stream.of(
Arguments.of(1.0, 0.0),
Arguments.of(0.0, 0.0),
Arguments.of(-5.0, 0.0)
);
}
}
@Nested
@DisplayName("Square root")
class SquareRoot {
@ParameterizedTest(name = "sqrt({0}) = {1}")
@CsvSource({
"0, 0.0",
"1, 1.0",
"4, 2.0",
"16, 4.0",
"2, 1.414"
})
void sqrt(double input, double expected) {
assertEquals(expected, calculator.sqrt(input), 0.001);
}
@RepeatedTest(10)
@DisplayName("sqrt(16) is consistent across 10 calls")
void sqrtIsConsistent() {
assertEquals(4.0, calculator.sqrt(16.0), 1e-9);
}
@Test
@DisplayName("should throw for negative input")
void sqrtNegative() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> calculator.sqrt(-1.0));
assertTrue(ex.getMessage().contains("negative"));
}
}
@Nested
@DisplayName("Multiple field validation with assertAll")
class MultipleAssertions {
@Test
@DisplayName("operation results satisfy all invariants simultaneously")
void allOperationsHoldInvariants() {
assertAll("Calculator invariants",
() -> assertEquals(0, calculator.add(5, -5), 1e-9),
() -> assertEquals(0, calculator.subtract(5, 5), 1e-9),
() -> assertEquals(1, calculator.multiply(1, 1), 1e-9),
() -> assertEquals(1, calculator.divide(5, 5), 1e-9),
() -> assertEquals(1, calculator.power(10, 0), 1e-9),
() -> assertEquals(0, calculator.sqrt(0), 1e-9)
);
}
}
}Run mvn test. You should see the @Nested structure in the IntelliJ test tree and the [TIMING] lines in the console output.
Step 5 — Allure report
Run mvn test to generate target/allure-results/. Then:
allure serve target/allure-resultsVerify the Behaviors view shows "Calculator Operations" as a feature, with the nested stories beneath it. If the feature is not visible, confirm the @Feature("Calculator Operations") annotation is on the test class and allure-junit5 is on the test classpath.
Step 6 — GitHub Actions CI
Create .github/workflows/test.yml:
name: JUnit 5 Calculator Tests
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Run unit tests
run: mvn test
- name: Upload Surefire reports
uses: actions/upload-artifact@v4
if: always()
with:
name: surefire-reports
path: target/surefire-reports/
- name: Upload Allure results
uses: actions/upload-artifact@v4
if: always()
with:
name: allure-results
path: target/allure-results/Push the project. Watch the Actions run. Confirm the Surefire report artifact appears in the run summary even if any test fails.
Build-time walkthrough
Step 1 of 6
pom.xml foundation
junit-jupiter 5.10.2, allure-junit5 2.25.0, Surefire 3.2.5 with <groups>unit</groups>, Failsafe for integration tests. Compile: mvn compile — zero errors before writing a single test.
Troubleshooting checklist
Tests run: 0 from Surefire — Surefire is filtering to <groups>unit</groups> but your test class is not tagged @Tag("unit"). Either add the tag or temporarily remove the <groups> filter to confirm the tests are discovered.
Extension not firing — Check the META-INF/services file path: it must be src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension (note the directory structure). Confirm the file contains the fully qualified class name with no trailing whitespace.
@Feature not appearing in Allure — The allure-junit5 dependency must be on the classpath at test runtime. Run mvn dependency:tree | grep allure and confirm allure-junit5 appears with scope:test. If it is missing, check the dependency declaration in pom.xml.
Allure command not found — Install the Allure CLI: brew install allure (macOS) or scoop install allure (Windows). Or use the Maven plugin: mvn allure:serve.
@RepeatedTest repetitions not showing timing — BeforeTestExecutionCallback fires per test method, including each repetition. If you see timing for the first repetition only, check the store key — each repetition gets a fresh ExtensionContext, so the store is clean per repetition by design.
Next lesson: review, self-assessment, and where to take your JUnit 5 skills next.