CI Extension Lab: Mocks, Coverage, and Quality Gates

This lab extends the Python and Java pipelines you built in the earlier session. You will keep both repositories, add mocks and coverage, and make the pipelines enforce quality gates in GitHub Actions and Jenkins.

Contents
  1. Starting point and what changes
  2. Part 1: Python extension (python-ci-demo)
  3. Part 2: Java extension (java-ci-demo)
  4. Part 3: Reading the coverage reports
  5. Part 4: Breaking the quality gates
  6. Part 5: Wrap-up and troubleshooting

Starting point and what changes

Before you begin

You need the two repositories from the earlier session, both with passing pipelines:

RepositoryFrom the earlier sessionTools already installed
python-ci-demoapp/calculator.py, app/main.py, tests/test_calculator.py, requirements.txt (pytest, pytest-cov, flake8), pytest.ini, .github/workflows/ci.yml, JenkinsfilePython 3.11+, Docker Jenkins
java-ci-demopom.xml (compiler with -Werror, surefire, JaCoCo report, jar), Calculator.java, App.java, CalculatorTest.java, .github/workflows/ci.yml, JenkinsfileJDK 17+, Maven 3.9+, Docker Jenkins
Check before starting. Run pytest in python-ci-demo and mvn -B verify in java-ci-demo. Both must pass. Fix any failure now, because this lab adds on top of the existing code.

What this extension adds

AreaPythonJava
New code under testapp/converter.py (depends on a rate provider)OrderService.java (depends on an InventoryRepository)
Mocksunittest.mock.Mock and patchMockito with @Mock and @InjectMocks
New dependencyNone (pytest-cov is already pinned)mockito-junit-jupiter
Coverage gate--cov-fail-under=90 in the test commandJaCoCo check rules in pom.xml: line 80%, branch 70%
CI changesCoverage step, job summary, and report upload in ci.yml; recordCoverage gate in the JenkinsfileLint and verify steps, PR coverage comment, and recordCoverage gate in the Jenkinsfile

Jenkins: install the Coverage plugin once

Both Jenkins jobs now use the recordCoverage step. Install the plugin one time for the whole Jenkins server:

  1. Open Jenkins at http://localhost:8080, go to Manage Jenkins, then Plugins, then Available plugins.
  2. Search for Coverage, select it, and click Install. Restart Jenkins if asked.
Checkpoint: Coverage appears under Installed plugins.

Part 1: Python extension python-ci-demo

Work in the python-ci-demo repository. Commands run from its root with the virtual environment active.

Step 1.1 Activate the environment and confirm the baseline

Goal: start from a known, passing state.

Do

cd python-ci-demo
source .venv/bin/activate          # Windows PowerShell: .venv\Scripts\Activate.ps1
git checkout main && git pull
pytest -v

Explanation

Checkpoint: the last line says 3 passed.

Step 1.2 Create the converter with an injected dependency

Goal: add code whose external rate service can be replaced by a mock in tests.

Do

app/converter.py
from typing import Callable

RateProvider = Callable[[str, str], float]


class CurrencyConverter:
    def __init__(self, rate_provider: RateProvider) -> None:
        self._rate_provider = rate_provider

    def convert(self, amount: float, src: str, dst: str) -> float:
        if amount < 0:
            raise ValueError("amount must be non-negative")
        if src == dst:
            return amount
        rate = self._rate_provider(src, dst)
        return round(amount * rate, 2)

Explanation

Step 1.3 Write tests with mocks

Goal: test every path without any network access.

Do

tests/test_converter.py
from unittest.mock import Mock

import pytest

from app.converter import CurrencyConverter


def test_convert_uses_provider_rate():
    provider = Mock(return_value=1.1)
    converter = CurrencyConverter(provider)

    assert converter.convert(10, "EUR", "USD") == 11.0
    provider.assert_called_once_with("EUR", "USD")


def test_same_currency_skips_provider():
    provider = Mock()

    assert CurrencyConverter(provider).convert(5, "USD", "USD") == 5
    provider.assert_not_called()


def test_negative_amount_rejected():
    converter = CurrencyConverter(Mock())

    with pytest.raises(ValueError):
        converter.convert(-1, "EUR", "USD")


def test_provider_failure_propagates():
    provider = Mock(side_effect=ConnectionError("rate service timeout"))

    with pytest.raises(ConnectionError):
        CurrencyConverter(provider).convert(1, "EUR", "USD")

Explanation

TestWhat it proves
test_convert_uses_provider_rateThe rate is applied and rounded, and the provider is called once with the right currencies. assert_called_once_with checks the interaction, not just the result.
test_same_currency_skips_providerThe early return works, and no network call is made. assert_not_called proves it.
test_negative_amount_rejectedInvalid input raises before any provider call.
test_provider_failure_propagatesA failing service does not get silently swallowed. side_effect makes the mock raise.
Mock methods used here: Mock(return_value=...) returns a fixed value, Mock(side_effect=...) raises, assert_called_once_with and assert_not_called check how the mock was used.

Step 1.4 Patch a function imported into a module

Goal: test run() without depending on the real add function.

Do

tests/test_main.py
from unittest.mock import patch

import app.main


def test_run_prints_result_from_add(capsys):
    with patch("app.main.add", return_value=99):
        app.main.run()

    assert "2 + 3 = 99" in capsys.readouterr().out

Explanation

Step 1.5 Exclude the script guard from coverage

Goal: keep the coverage number about logic, not about the script wrapper.

Do

app/main.py
from app.calculator import add, divide


def run() -> None:
    print(f"2 + 3 = {add(2, 3)}")
    print(f"10 / 4 = {divide(10, 4)}")


if __name__ == "__main__":  # pragma: no cover
    run()

Explanation

Step 1.6 Run the tests with the coverage gate

Goal: measure coverage and make the command fail below 90%.

Do

pytest -v --cov=app --cov-branch --cov-report=term-missing --cov-report=html:reports/htmlcov --cov-fail-under=90
echo "exit code: $?"

Explanation

FlagMeaning
--cov=appMeasure only the application package.
--cov-branchCount the if outcomes as well as the lines. This catches an untested else that line coverage would miss.
--cov-report=term-missingPrint the table and list uncovered lines.
--cov-report=html:reports/htmlcovWrite a browsable HTML report for drill-down.
--cov-fail-under=90The gate. Exit code 1 when total coverage is under 90%.
Checkpoint: 8 passed, the TOTAL row shows 90% or more (with these tests it should be 100%), and exit code: 0.

Step 1.7 Open the HTML report

open reports/htmlcov/index.html       # Windows: start reports\htmlcov\index.html

Click calculator.py and converter.py. Lines are green when they ran. Yellow means a branch ran only one way. Red means the line never ran.

Step 1.8 Update the GitHub Actions workflow

Goal: enforce the same gate in CI and show coverage on the job page.

Do

.github/workflows/ci.yml
name: Python CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Lint
        run: flake8 app tests

      - name: Test with coverage gate
        run: |
          pytest --junitxml=reports/junit.xml \
            --cov=app --cov-branch \
            --cov-report=term-missing \
            --cov-report=xml:reports/coverage.xml \
            --cov-report=html:reports/htmlcov \
            --cov-fail-under=90

      - name: Coverage summary in job page
        if: always()
        run: |
          {
            echo "### Coverage (Python ${{ matrix.python-version }})"
            echo '```'
            python -m coverage report
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Upload reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: python-reports-${{ matrix.python-version }}
          path: reports/

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Package application
        run: tar czf app-${{ github.sha }}.tar.gz app requirements.txt

      - name: Upload package
        uses: actions/upload-artifact@v4
        with:
          name: app-package
          path: app-${{ github.sha }}.tar.gz

Explanation: what changed from the earlier workflow

ChangeWhy
Test step now runs with --cov-fail-under=90The step fails when coverage drops, which fails the job and blocks the build job.
--junitxml and the coverage XML/HTML outputsThe JUnit file is for test results. The XML is the machine-readable coverage file. The HTML is for people.
Coverage summary with if: always()Writes the coverage table to the run page even when the gate fails, so you see the reason on the summary page.
Report upload with if: always()Keeps the HTML report for download on failed runs too.
The { ... } >> "$GITHUB_STEP_SUMMARY" construct groups several echo lines and appends them to the file GitHub uses for job summaries. The backticks are written as '```' so the shell treats them as text.

Step 1.9 Update the Jenkinsfile

Goal: run the same gate in Jenkins and record it with the Coverage plugin.

Do

Jenkinsfile
pipeline {
    agent any

    options {
        timestamps()
        disableConcurrentBuilds()
        buildDiscarder(logRotator(numToKeepStr: '10'))
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Lint and Test') {
            steps {
                sh '''
                    python3 -m venv .venv
                    . .venv/bin/activate
                    pip install -r requirements.txt
                    flake8 app tests
                    mkdir -p reports
                    pytest --junitxml=reports/junit.xml --cov=app --cov-branch --cov-report=term-missing --cov-report=xml:reports/coverage.xml --cov-report=html:reports/htmlcov --cov-fail-under=90
                '''
            }
            post {
                always {
                    junit testResults: 'reports/junit.xml', allowEmptyResults: true
                    recordCoverage(
                        tools: [[parser: 'COBERTURA', pattern: 'reports/coverage.xml']],
                        id: 'python',
                        name: 'Python coverage',
                        qualityGates: [[threshold: 90.0, metric: 'LINE', baseline: 'PROJECT', criticality: 'FAILURE']]
                    )
                    archiveArtifacts artifacts: 'reports/htmlcov/**', allowEmptyArchive: true
                }
            }
        }

        stage('Package') {
            steps {
                sh 'tar czf app-${BUILD_NUMBER}.tar.gz app requirements.txt'
                archiveArtifacts artifacts: 'app-*.tar.gz', fingerprint: true
            }
        }
    }

    post {
        always {
            cleanWs()
        }
    }
}

Explanation

Step 1.10 Commit, push, and observe both runs

Goal: see the Python pipelines run with the new gate.

Do

git checkout -b ext/python-coverage
git add . && git commit -m "Add converter with mocks, coverage gate, and CI updates"
git push -u origin ext/python-coverage

Open a pull request into main on GitHub. Then, in Jenkins, open the python-ci-demo job and click Build Now after merging, or wait for the next poll.

  1. GitHub: open the Actions run and read the Coverage summary at the bottom of the run page.
  2. Jenkins: open the build and then Python coverage in the left menu. Check the quality gate result and the per-file table.
Checkpoint: both pipelines are green, the GitHub run page shows the coverage table, and Jenkins shows a Python coverage page. Merge the pull request into main before continuing to Part 2.

Part 2: Java extension java-ci-demo

Work in the java-ci-demo repository. Commands run from its root.

Step 2.1 Confirm the baseline and create a branch

cd ../java-ci-demo
git checkout main && git pull
git checkout -b ext/java-mocks-coverage
mvn -B verify
Checkpoint: BUILD SUCCESS with Tests run: 3.

Step 2.2 Create the repository interface

Goal: define the dependency that the service will receive, so tests can replace it.

Do

src/main/java/com/example/calc/InventoryRepository.java
package com.example.calc;

public interface InventoryRepository {
    int stockOf(String sku);
}

Explanation

Step 2.3 Create the service with constructor injection

Goal: write logic that depends only on the interface.

Do

src/main/java/com/example/calc/OrderService.java
package com.example.calc;

public final class OrderService {

    private final InventoryRepository inventory;

    public OrderService(InventoryRepository inventory) {
        this.inventory = inventory;
    }

    public boolean canFulfil(String sku, int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive");
        }
        return inventory.stockOf(sku) >= quantity;
    }
}

Explanation

Step 2.4 Write Mockito tests

Goal: test each path, including the interaction checks.

Do

src/test/java/com/example/calc/OrderServiceTest.java
package com.example.calc;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private InventoryRepository inventory;

    @InjectMocks
    private OrderService service;

    @Test
    void canFulfilWhenStockIsSufficient() {
        when(inventory.stockOf("ABC")).thenReturn(10);

        assertTrue(service.canFulfil("ABC", 5));
        verify(inventory).stockOf("ABC");
    }

    @Test
    void cannotFulfilWhenStockIsShort() {
        when(inventory.stockOf("ABC")).thenReturn(2);

        assertFalse(service.canFulfil("ABC", 5));
    }

    @Test
    void rejectsNonPositiveQuantityWithoutTouchingInventory() {
        assertThrows(IllegalArgumentException.class, () -> service.canFulfil("ABC", 0));

        verifyNoInteractions(inventory);
    }
}

Explanation

Annotation or callMeaning
@ExtendWith(MockitoExtension.class)Connects Mockito to JUnit and creates the mocks before each test.
@MockA fake InventoryRepository. Unstubbed methods return 0, null, or false.
@InjectMocksBuilds the OrderService and passes the mock through its constructor.
when(...).thenReturn(...)Stubbing: sets the return value for a call.
verify(inventory).stockOf("ABC")Checks the call happened with that argument.
verifyNoInteractions(inventory)Checks the mock was never used.
Strict stubs. Mockito fails a test whose stub was never used, with UnnecessaryStubbingException. This keeps tests honest. You will see it in Part 4.

Step 2.5 Add Mockito and the coverage rule to pom.xml

Goal: make Mockito available and make the build fail when coverage is too low.

Do

(a) Inside <dependencies>, add after the JUnit dependency:

<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-junit-jupiter</artifactId>
  <version>5.11.0</version>
  <scope>test</scope>
</dependency>

(b) Inside the jacoco-maven-plugin element, add a plugin-level <configuration> before <executions>:

<configuration>
  <excludes>
    <exclude>com/example/calc/App.class</exclude>
  </excludes>
</configuration>

(c) Inside the same plugin's <executions>, add a check execution after the report execution:

<execution>
  <id>check</id>
  <goals><goal>check</goal></goals>
  <configuration>
    <rules>
      <rule>
        <element>BUNDLE</element>
        <limits>
          <limit>
            <counter>LINE</counter>
            <value>COVEREDRATIO</value>
            <minimum>0.80</minimum>
          </limit>
          <limit>
            <counter>BRANCH</counter>
            <value>COVEREDRATIO</value>
            <minimum>0.70</minimum>
          </limit>
        </limits>
      </rule>
    </rules>
  </configuration>
</execution>

Explanation

Step 2.6 Run the build with the gate

mvn -B verify
echo "exit code: $?"
Checkpoint: Tests run: 6, Failures: 0, Errors: 0, a JaCoCo line confirming that coverage checks were met, BUILD SUCCESS, and exit code: 0.

Step 2.7 Smoke-test the jar and read the JaCoCo report

java -jar target/calc-ci-demo-1.0.0.jar
open target/site/jacoco/index.html       # Windows: start target\site\jacoco\index.html

Explanation

Step 2.8 Update the GitHub Actions workflow

Goal: run lint and verify in CI, comment coverage on pull requests, and keep the jar build.

Do

.github/workflows/ci.yml
name: Java CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read
  pull-requests: write

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        java-version: ["17", "21"]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java-version }}
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: ${{ matrix.java-version }}
          cache: maven

      - name: Lint (compile with warnings as errors)
        run: mvn -B compile

      - name: Test and JaCoCo gate
        run: mvn -B verify

      - name: Coverage comment on pull request
        if: github.event_name == 'pull_request' && always()
        uses: madrapps/jacoco-report@v1.6.1
        with:
          paths: ${{ github.workspace }}/target/site/jacoco/jacoco.xml
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 80
          min-coverage-changed-files: 80
          title: Java coverage

      - name: Upload reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: java-reports-${{ matrix.java-version }}
          path: |
            target/surefire-reports/
            target/site/jacoco/

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
          cache: maven

      - name: Package application
        run: mvn -B -DskipTests package

      - name: Upload jar
        uses: actions/upload-artifact@v4
        with:
          name: calc-jar
          path: target/*.jar

Explanation: what changed

ChangeWhy
Separate mvn -B compile stepLint runs first and fails fast. Any compiler warning stops the job before the slower tests run.
mvn -B verify stepRuns the tests and applies the JaCoCo rule. A coverage failure fails the job.
Top-level permissionspull-requests: write lets the coverage comment step post on pull requests.
Coverage comment stepPosts overall and changed-file coverage on the pull request. Fails the check when either is below 80%.
if: github.event_name == 'pull_request' && always()Limits the comment to pull requests and keeps it running after a failed test step.
Upload reports with if: always()Keeps the surefire and JaCoCo reports for download, even when the build fails.

Step 2.9 Update the Jenkinsfile

Goal: record JaCoCo coverage in Jenkins with its own gate.

Do

Jenkinsfile
pipeline {
    agent any

    options {
        timestamps()
        disableConcurrentBuilds()
        buildDiscarder(logRotator(numToKeepStr: '10'))
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Lint') {
            steps {
                sh 'mvn -B compile'
            }
        }

        stage('Test') {
            steps {
                sh 'mvn -B verify'
            }
            post {
                always {
                    junit testResults: 'target/surefire-reports/*.xml', allowEmptyResults: true
                    recordCoverage(
                        tools: [[parser: 'JACOCO', pattern: 'target/site/jacoco/jacoco.xml']],
                        id: 'java',
                        name: 'Java coverage',
                        qualityGates: [[threshold: 80.0, metric: 'LINE', baseline: 'PROJECT', criticality: 'FAILURE']]
                    )
                    archiveArtifacts artifacts: 'target/site/jacoco/**', allowEmptyArchive: true
                }
            }
        }

        stage('Package') {
            steps {
                sh 'mvn -B -DskipTests package'
                archiveArtifacts artifacts: 'target/*.jar', fingerprint: true
            }
        }
    }

    post {
        always {
            cleanWs()
        }
    }
}

Explanation

Step 2.10 Commit, push, and observe both runs

git add . && git commit -m "Add OrderService with Mockito tests, JaCoCo gate, and CI updates"
git push -u origin ext/java-mocks-coverage

Open a pull request into main. Check the GitHub run and the coverage comment, then build in Jenkins with Build Now.

Checkpoint: the GitHub PR shows a Java coverage comment, the Jenkins build shows a Java coverage page, and both pipelines are green. Merge the pull request before Part 3.

Part 3: Reading the coverage reports

Step 3.1 Read the Python terminal table

The term-missing report looks like this:

Name                 Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------------
app/calculator.py        7      0      4      0   100%
app/converter.py        10      0      6      0   100%
app/main.py              4      0      0      0   100%
----------------------------------------------------------------
TOTAL                   21      0     10      0   100%

How to read it

Step 3.2 Read the JaCoCo summary

Open target/site/jacoco/index.html. The top table shows Missed and Covered for Instructions, Branches, Lines, Methods, and Classes. The Maven rule uses the Lines and Branches rows. In the class view, green is fully covered, yellow means some branches were missed (a diamond shows how many), and red means the code never ran.

Step 3.3 Connect the report to the pipeline

QuestionWhere to look
Why did the Python build fail?The Python job's Test with coverage gate step in GitHub, or the Lint and Test stage in Jenkins, then the FAIL Required test coverage line
Which Java line is missing a test?The yellow or red line in target/site/jacoco/index.html, or the PR coverage comment
Is coverage going down over time?The trend chart on the Jenkins Python coverage or Java coverage page
Which tests ran?The Jenkins Test Result page, or the reports/junit.xml and target/surefire-reports/ files

Part 4: Breaking the quality gates

Work on a new branch so main stays green. Make one change at a time, read the failure, then restore.

git checkout -b lab/break-gates

Exercise 4.1: Python coverage gate

Goal: see the coverage gate stop the Python pipeline.

  1. Change: delete test_negative_amount_rejected from tests/test_converter.py.
  2. Run:
pytest --cov=app --cov-branch --cov-report=term-missing --cov-fail-under=90
echo "exit code: $?"
  1. Expected: converter.py lists the raise line under Missing, and the last line reads FAIL Required test coverage of 90% not reached. The exit code is 1.
  2. Why: the branch if amount < 0 never took its true path, so the coverage total dropped below the gate.
  3. Push and check that the GitHub test job turns red and the Jenkins Lint and Test stage fails. Then restore the test and push again.

Exercise 4.2: Java JaCoCo rule

Goal: see the Maven rule stop the Java build.

  1. Change: delete rejectsNonPositiveQuantityWithoutTouchingInventory from OrderServiceTest.java.
  2. Run: mvn -B verify
  3. Expected: a warning similar to Rule violated for bundle calc-ci-demo: branches covered ratio is 0.50, but expected minimum is 0.70, then BUILD FAILURE.
  4. Find the cause: open target/site/jacoco/index.html, click OrderService, and look for the yellow line on if (quantity <= 0).
  5. Restore the test and confirm BUILD SUCCESS.

Exercise 4.3: Mockito strict stubs

Goal: understand why an unused stub is a failure.

  1. Change: in cannotFulfilWhenStockIsShort, delete the assertFalse(...) line and keep the when(...) line.
  2. Run: mvn -B test
  3. Expected: UnnecessaryStubbingException, even though no assertion fails.
  4. Why: the test no longer checks its result, so it would pass without testing anything. Restore the assertion.

Exercise 4.4: Java lint gate

Goal: see -Werror fail the build on a warning.

  1. Change: add this line inside main in App.java:
java.util.List raw = new java.util.ArrayList();
  1. Run: mvn -B compile
  2. Expected: a rawtypes warning, then warnings found and -Werror specified. The tests never run.
  3. Restore by deleting the line.
Checkpoint: you have seen each gate fail and can name the file and line that caused it. When you finish, delete the branch: git checkout main && git branch -D lab/break-gates.

Part 5: Wrap-up and troubleshooting

Final checklist

SkillDone
I can inject a dependency and replace it with a mock in Python and in Java☐
I can explain line coverage and branch coverage using a real example from my code☐
I can run the coverage gate locally and read the Missing column☐
I know which file sets each gate: pytest command, pom.xml, and the Jenkinsfile☐
I can read the GitHub job summary and the PR coverage comment☐
I can read the Jenkins Coverage pages and their quality gate results☐
I have broken and fixed each gate at least once☐

Troubleshooting

SymptomCause and fix
ModuleNotFoundError: app in pytestCheck that pytest.ini has pythonpath = . and run from the repository root.
pytest shows 0% or "No data was collected"Check --cov=app matches the package folder and that app/__init__.py exists.
Jenkins: No such DSL method 'recordCoverage'The Coverage plugin is not installed, or Jenkins was not restarted after installing it. See the plugin step at the start of this lab.
Jenkins coverage page is emptyThe pattern does not match the file path. For Python it must be reports/coverage.xml. For Java it must be target/site/jacoco/jacoco.xml.
invalid target release: 17The local JDK is older than 17. Install JDK 17 or later and set JAVA_HOME.
Rule violated for bundleCoverage is below the minimum. Open the JaCoCo report and find the missed lines and branches.
UnnecessaryStubbingExceptionA stub is never used. Remove it or add the assertion that uses it.
PR coverage comment missingThe workflow needs the top-level permissions block with pull-requests: write. Pull requests from forks get a read-only token.
Jenkins: python3: not found or mvn: not foundThe tools were not installed in the Jenkins container. Re-run the install command from the earlier session.

Next steps

  1. Raise each threshold by one or two points as you add tests. This is called ratcheting.
  2. Add a mutation testing tool (mutmut for Python, PIT for Java) to check that tests fail when code is changed.
  3. Protect main in GitHub: require the Python and Java checks to pass before a merge.
  4. Move coverage gating to changed lines only, so legacy code does not block new work.