Guided Walkthrough

12 min read

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-results

Verify 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 timingBeforeTestExecutionCallback 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.

// tip to track lessons you complete and pick up where you left off across devices.