Repository: optapy/optapy (language: python)

Issue:
Enable SonarCloud on this repository
Let's see how we do in terms of test coverage and code quality.
Take inspiration in the main OptaPlanner repo, where the integration works well.

Once we have data, we'll discuss where/how to focus next.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 00000000..3f5ec5b6
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,93 @@
+# Runs the SonarCloud analysis of the optapy main branch after a PR is merged.
+name: SonarCloud Analysis
+
+on:
+  push:
+    branches:
+      - main
+    paths-ignore:
+      - 'LICENSE*'
+      - '.gitignore'
+      - '**.md'
+      - '**.adoc'
+      - '*.txt'
+      - '.ci/**'
+
+defaults:
+  run:
+    shell: bash
+
+jobs:
+  sonarcloud-analysis:
+    strategy:
+      matrix:
+        os: [ ubuntu-latest ]
+        java-version: [ 11 ]
+        maven-version: [ '3.8.6' ]
+      fail-fast: false
+    runs-on: ${{ matrix.os }}
+
+    steps:
+      - name: Check out repository code
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: Java and Maven Setup
+        uses: kiegroup/kogito-pipelines/.ci/actions/maven@main
+        with:
+          java-version: ${{ matrix.java-version }}
+          maven-version: ${{ matrix.maven-version }}
+          cache-key-prefix: ${{ runner.os }}-${{ matrix.java-version }}-maven${{ matrix.maven-version }}
+
+      # Need to install both Python 3.9 and Python 3.10 for tox (has to be in the same run)
+      # Feature Request for setup action: https://github.com/actions/setup-python/issues/98
+      - name: Python 3.9 Setup
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.9'
+          cache: 'pip'
+
+      - name: Python 3.10 Setup
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.10'
+          cache: 'pip'
+
+      - name: Install tox
+        run:
+          python -m pip install --upgrade pip
+          pip install tox coverage pytest pytest-cov
+
+      - name: Build with Maven to measure code coverage
+        run: mvn -B --fail-at-end clean install -Prun-code-coverage
+
+      - name: Get JaCoCo Agent
+        run: mvn org.apache.maven.plugins:maven-dependency-plugin:2.8:get -Dartifact=org.jacoco:org.jacoco.agent:0.8.8:jar:runtime -Ddest=target/jacocoagent.jar
+
+      - name: Run tox to measure optapy code coverage from Python tests
+        run: python -m tox -- --cov=optapy --cov-report=xml:target/coverage.xml --cov-config=tox.ini --cov-branch --cov-append --jacoco-agent=./target/jacocoagent.jar
+
+      - name: Run tox to measure jpyinterpreter code coverage from Python tests
+        working-directory: ./jpyinterpreter
+        run: python -m tox -- --cov=jpyinterpreter --cov-report=xml:target/coverage.xml --cov-config=tox.ini --cov-branch --cov-append --jacoco-agent=../target/jacocoagent.jar --jacoco-output=../target/jacoco.exec
+
+      # Because we are using JPype, and JPype add it own import hook, we need to use --import-mode=importlib in pytest
+      # This seems to create an issue in test coverage, where it reports coverage inside the tox virtual environment,
+      # instead of coverage inside sources. For instance, for a package, it will report:
+      # package name=".tox.py39.lib.python3.9.site-packages.jpyinterpreter"
+      # and for a file it will report:
+      # class name="jvm_setup.py" filename=".tox/py39/lib/python3.9/site-packages/jpyinterpreter/jvm_setup.py"
+      # We need to convert the package and file names into their true names; i.e. for the example above:
+      # package name="jpyinterpreter"
+      # class name="jvm_setup.py" filename="jpyinterpreter/src/main/python/jvm_setup.py"
+      # fix-coverage-paths.py is a Python script that does the above transformation for us and merge the two
+      # separate coverage files into one.
+      - name: Fix Python test coverage paths
+        run: python fix-coverage-paths.py
+
+      - name: Run SonarCloud analysis
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          SONARCLOUD_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }}
+        run: mvn -B --fail-at-end validate -Psonarcloud-analysis -Dsonar.projectKey=optapy_optapy -Dsonar.login=${{ env.SONARCLOUD_TOKEN }}
\ No newline at end of file
diff --git a/fix-coverage-paths.py b/fix-coverage-paths.py
new file mode 100644
index 00000000..f291b603
--- /dev/null
+++ b/fix-coverage-paths.py
@@ -0,0 +1,220 @@
+import time
+import xml.etree.ElementTree as ET
+import pathlib
+from typing import List
+from dataclasses import dataclass
+
+
+@dataclass
+class CoverageInfo:
+    line_count: int
+    hit_lines: int
+    branch_count: int
+    hit_branches: int
+
+    def get_line_rate(self):
+        return f'{max(1, self.hit_lines) / max(1, self.line_count):.9g}'
+
+    def get_branch_rate(self):
+        return f'{max(1, self.hit_branches) / max(1, self.branch_count):.9g}'
+
+    def merge(self, other: 'CoverageInfo') -> 'CoverageInfo':
+        return CoverageInfo(self.line_count + other.line_count,
+                            self.hit_lines + other.hit_lines,
+                            self.branch_count + other.branch_count,
+                            self.hit_branches + other.hit_branches)
+
+
+def fix_package_name(name: str) -> str:
+    try:
+        site_package_index = name.index('.site-packages.')
+        index_offset = len('.site-packages.')
+        return name[site_package_index + index_offset:]
+    except ValueError:
+        return name
+
+
+def fix_file_name(file_name: str) -> str:
+    if 'optapy' in file_name:
+        optapy_index = file_name.index('optapy')
+        offset = optapy_index + len('optapy')
+        return str(pathlib.Path('optapy-core', 'src', 'main', 'python')) + file_name[offset:]
+    elif 'jpyinterpreter' in file_name:
+        jpyinterpreter_index = file_name.index('jpyinterpreter')
+        offset = jpyinterpreter_index + len('jpyinterpreter')
+        return str(pathlib.Path('jpyinterpreter', 'src', 'main', 'python')) + file_name[offset:]
+    else:
+        return file_name
+
+
+def merge_packages(canon_package: ET.Element, identical_packages: List[ET.Element]) -> CoverageInfo:
+    coverage_info = CoverageInfo(0, 0, 0, 0)
+    for canon_file in canon_package.find('classes').findall('class'):
+        file_id = canon_file.get('name')
+        identical_files = [package.find('classes').find(f'.//class[@name="{file_id}"]')
+                           for package in identical_packages]
+        coverage_info = coverage_info.merge(merge_files(canon_file, identical_files))
+
+    canon_package.set('line-rate', coverage_info.get_line_rate())
+    canon_package.set('branch-rate', coverage_info.get_branch_rate())
+    return coverage_info
+
+
+def merge_files(canon_file: ET.Element, identical_files: List[ET.Element]) -> CoverageInfo:
+    coverage_info = CoverageInfo(0, 0, 0, 0)
+    for canon_line in canon_file.find('lines').findall('line'):
+        line_id = canon_line.get('number')
+        identical_lines = [file.find('lines').find(f'.//line[@number="{line_id}"]')
+                           for file in identical_files]
+        coverage_info = coverage_info.merge(merge_lines(canon_line, identical_lines))
+
+    canon_file.set('line-rate', coverage_info.get_line_rate())
+    canon_file.set('branch-rate', coverage_info.get_branch_rate())
+    return coverage_info
+
+
+def merge_lines(canon_line: ET.Element, identical_lines: List[ET.Element]) -> CoverageInfo:
+    is_hit = False
+    for line in identical_lines:
+        if line.get('hits') == '1':
+            is_hit = True
+            break
+
+    canon_line.set('hits', '1' if is_hit else '0')
+    is_branch = canon_line.get('branch', 'false') == 'true'
+    if is_branch:
+        missed_branches = canon_line.get('missing-branches', '')
+        condition_coverage = canon_line.get('condition-coverage')
+        num_of_conditions = int(condition_coverage[condition_coverage.index('/') + 1:-1])
+        missing_branch_set = set(missed_branches.split(',')) if missed_branches != '' else set()
+        for line in identical_lines:
+            line_missed_branches = line.get('missing-branches', '')
+            line_missing_branch_set = set(line_missed_branches.split(',')) if line_missed_branches != '' else set()
+            missing_branch_set = missing_branch_set.intersection(line_missing_branch_set)
+
+        if len(missing_branch_set) == 0:
+            num_of_hit_branches = num_of_conditions
+            canon_line.attrib.pop('missing-branches', None)
+        else:
+            num_of_hit_branches = num_of_conditions - len(missing_branch_set)
+            canon_line.set('condition-coverage',
+                           f'{(num_of_hit_branches / num_of_conditions) * 100:.9g}% ({num_of_hit_branches}/{num_of_conditions})')
+            canon_line.set('missing-branches', ','.join(sorted(missing_branch_set)))
+
+        return CoverageInfo(1, 1 if is_hit else 0, num_of_conditions, num_of_hit_branches)
+    else:
+        return CoverageInfo(1, 1 if is_hit else 0, 0, 0)
+
+
+def is_test_package(package_name: str) -> bool:
+    if package_name == 'tests':
+        return True
+    elif package_name.startswith('tests.'):
+        return True
+    elif package_name.endswith('.tests'):
+        return True
+    elif '.tests.' in package_name:
+        return True
+    return False
+
+
+def is_ignored(package_name: str, ignore_jpyinterpreter: bool = False):
+    # optapy packages start with 'optapy.' (or just plain 'optapy'), not 'optapy-core'
+    return is_test_package(package_name) or (ignore_jpyinterpreter and package_name.startswith('jpyinterpreter')) or \
+           package_name.startswith('optapy-core')
+
+
+def update_coverage_xml(coverage_xml: ET.ElementTree, *, ignore_jpyinterpreter: bool = False):
+    # First, fix the paths that use venv paths
+    packages_element = coverage_xml.getroot().find('packages')
+    for package in packages_element.findall('package'):
+        package_name = package.get('name')
+        package.set('name', fix_package_name(package_name))
+        for file in package.find('classes').findall('class'):
+            file_name = file.get('filename')
+            file.set('filename', fix_file_name(file_name))
+
+    # Now merge all elements that represent the same packages/files
+    canonical_package_dict = dict()
+    package_name_to_packages = dict()
+    for package in packages_element.findall('package'):
+        package_name = package.get('name')
+        if package_name not in canonical_package_dict:
+            canonical_package_dict[package_name] = package
+            package_name_to_packages[package_name] = [package]
+        else:
+            package_name_to_packages[package_name].append(package)
+
+    coverage_info = CoverageInfo(0, 0, 0, 0)
+    for package_name, identical_packages in package_name_to_packages.items():
+        canon_package = canonical_package_dict[package_name]
+        merged_package_coverage = merge_packages(canon_package, identical_packages)
+        if not is_ignored(package_name, ignore_jpyinterpreter=ignore_jpyinterpreter):
+            # do not include tests in coverage
+            coverage_info = coverage_info.merge(merged_package_coverage)
+
+    coverage_element = coverage_xml.getroot()
+    coverage_element.set('lines-valid', str(coverage_info.line_count))
+    coverage_element.set('lines-covered', str(coverage_info.hit_lines))
+    coverage_element.set('line-rate', str(coverage_info.get_line_rate()))
+    coverage_element.set('branches-valid', str(coverage_info.branch_count))
+    coverage_element.set('branches-covered', str(coverage_info.hit_branches))
+    coverage_element.set('branch-rate', str(coverage_info.get_branch_rate()))
+
+    #  Remove uncanonical elements from the tree
+    for package_name, identical_packages in package_name_to_packages.items():
+        canon_package = canonical_package_dict[package_name]
+        for package in identical_packages:
+            if package != canon_package:
+                packages_element.remove(package)
+
+        if is_ignored(package_name, ignore_jpyinterpreter=ignore_jpyinterpreter):
+            #  We do not want to include the tests package in results
+            packages_element.remove(canon_package)
+
+
+def merge_coverage_xml(*coverage_xmls: ET.ElementTree):
+    root_element = ET.Element('coverage')
+    sources_element = ET.Element('sources')
+
+    source = ET.Element('source')
+    source.text = str(pathlib.Path('.'))
+    sources_element.append(source)
+
+    root_element.append(sources_element)
+    packages_element = ET.Element('packages')
+    root_element.append(packages_element)
+
+    coverage_info = CoverageInfo(0, 0, 0, 0)
+    for coverage_xml in coverage_xmls:
+        xml_coverage_info = CoverageInfo(int(coverage_xml.getroot().get('lines-valid')),
+                                         int(coverage_xml.getroot().get('lines-covered')),
+                                         int(coverage_xml.getroot().get('branches-valid')),
+                                         int(coverage_xml.getroot().get('branches-covered')))
+        xml_packages_element = coverage_xml.getroot().find('packages')
+        for package in xml_packages_element.findall('package'):
+            packages_element.append(package)
+
+        coverage_info = coverage_info.merge(xml_coverage_info)
+
+
+    root_element.set('version', '6.5.0')
+    root_element.set('timestamp', str(int(time.time())))
+    root_element.set('lines-valid', str(coverage_info.line_count))
+    root_element.set('lines-covered', str(coverage_info.hit_lines))
+    root_element.set('line-rate', str(coverage_info.get_line_rate()))
+    root_element.set('branches-valid', str(coverage_info.branch_count))
+    root_element.set('branches-covered', str(coverage_info.hit_branches))
+    root_element.set('branch-rate', str(coverage_info.get_branch_rate()))
+    root_element.set('complexity', '0')
+
+    return ET.ElementTree(root_element)
+
+if __name__ == '__main__':
+    optapy_coverage_xml = ET.parse('target/coverage.xml')
+    jpyinterpreter_coverage_xml = ET.parse('jpyinterpreter/target/coverage.xml')
+
+    update_coverage_xml(optapy_coverage_xml, ignore_jpyinterpreter=True)
+    update_coverage_xml(jpyinterpreter_coverage_xml)
+
+    merge_coverage_xml(optapy_coverage_xml, jpyinterpreter_coverage_xml).write('target/coverage.xml')
diff --git a/jpyinterpreter/pom.xml b/jpyinterpreter/pom.xml
index f9d35b4e..15e3346c 100644
--- a/jpyinterpreter/pom.xml
+++ b/jpyinterpreter/pom.xml
@@ -26,11 +26,6 @@
         <artifactId>asm-tree</artifactId>
         <version>${version.org.ow2.asm}</version>
       </dependency>
-      <dependency>
-        <groupId>org.apache.commons</groupId>
-        <artifactId>commons-collections4</artifactId>
-        <version>4.2</version>
-      </dependency>
     </dependencies>
   </dependencyManagement>
 
diff --git a/jpyinterpreter/tox.ini b/jpyinterpreter/tox.ini
index 2c82fb32..425bc435 100644
--- a/jpyinterpreter/tox.ini
+++ b/jpyinterpreter/tox.ini
@@ -9,6 +9,8 @@ envlist = py39,py310
 [testenv]
 deps =
     pytest
+    pytest-cov
+    coverage
     JPype1>=1.4.0
 commands =
-    pytest --import-mode=importlib
+    pytest --import-mode=importlib {posargs}
diff --git a/optapy-core/pom.xml b/optapy-core/pom.xml
index b649f55a..a755394b 100644
--- a/optapy-core/pom.xml
+++ b/optapy-core/pom.xml
@@ -50,6 +50,12 @@
       <artifactId>slf4j-api</artifactId>
     </dependency>
 
+    <!-- Utilities -->
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-collections4</artifactId>
+    </dependency>
+
     <!-- Testing -->
     <dependency>
       <groupId>org.junit.jupiter</groupId>
diff --git a/optapy-core/src/main/java/org/optaplanner/optapy/MirrorWithExtrasMap.java b/optapy-core/src/main/java/org/optaplanner/optapy/MirrorWithExtrasMap.java
index 685d7b8a..8d6b4517 100644
--- a/optapy-core/src/main/java/org/optaplanner/optapy/MirrorWithExtrasMap.java
+++ b/optapy-core/src/main/java/org/optaplanner/optapy/MirrorWithExtrasMap.java
@@ -1,11 +1,15 @@
 package org.optaplanner.optapy;
 
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
+import org.apache.commons.collections4.collection.CompositeCollection;
+import org.apache.commons.collections4.set.CompositeSet;
+
 /**
  * A Map that mirrors another Map, but allows new entries to be added without affecting
  * the mirrored map.
@@ -14,8 +18,8 @@
  * @param <Value_>
  */
 public class MirrorWithExtrasMap<Key_, Value_> implements Map<Key_, Value_> {
-    final Map<Key_, Value_> delegateMap;
-    final Map<Key_, Value_> extraEntriesMap = new HashMap<>();
+    private final Map<Key_, Value_> delegateMap;
+    private final Map<Key_, Value_> extraEntriesMap = new HashMap<>();
 
     public MirrorWithExtrasMap(Map<Key_, Value_> delegateMap) {
         this.delegateMap = delegateMap;
@@ -52,6 +56,10 @@ public Value_ get(Object o) {
 
     @Override
     public Value_ put(Key_ key, Value_ value) {
+        if (delegateMap.containsKey(key)) {
+            throw new IllegalArgumentException("Cannot set value for key (" + key + ") because it is a mirrored from "
+                    + " map (" + delegateMap + ").");
+        }
         return extraEntriesMap.put(key, value);
     }
 
@@ -62,6 +70,10 @@ public Value_ remove(Object o) {
 
     @Override
     public void putAll(Map<? extends Key_, ? extends Value_> map) {
+        if (!Collections.disjoint(delegateMap.keySet(), map.keySet())) {
+            throw new IllegalArgumentException("Cannot put all entries for map (" + map + ") because it shares "
+                    + " some keys with the mirrored map (" + delegateMap + ").");
+        }
         extraEntriesMap.putAll(map);
     }
 
@@ -72,25 +84,30 @@ public void clear() {
 
     @Override
     public Set<Key_> keySet() {
-        Set<Key_> out = new HashSet<>();
-        out.addAll(delegateMap.keySet());
-        out.addAll(extraEntriesMap.keySet());
-        return out;
+        return new CompositeSet<>(delegateMap.keySet(), extraEntriesMap.keySet());
     }
 
     @Override
     public Collection<Value_> values() {
-        Set<Value_> out = new HashSet<>();
-        out.addAll(delegateMap.values());
-        out.addAll(extraEntriesMap.values());
-        return out;
+        return new CompositeCollection<>(delegateMap.values(), extraEntriesMap.values());
     }
 
     @Override
     public Set<Entry<Key_, Value_>> entrySet() {
-        Set<Entry<Key_, Value_>> out = new HashSet<>();
-        out.addAll(delegateMap.entrySet());
-        out.addAll(extraEntriesMap.entrySet());
-        return out;
+        return new CompositeSet<>(delegateMap.entrySet(), extraEntriesMap.entrySet());
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (!(o instanceof Map)) {
+            return false;
+        }
+        Map<?, ?> other = (Map) o;
+        return entrySet().equals(other.entrySet());
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(delegateMap, extraEntriesMap);
     }
 }
diff --git a/pom.xml b/pom.xml
index 4b360aa4..29c79b89 100644
--- a/pom.xml
+++ b/pom.xml
@@ -20,6 +20,14 @@
     <version.ch.qos.logback>1.2.10</version.ch.qos.logback>
     <version.compiler.plugin>3.8.1</version.compiler.plugin>
     <version.surefire.plugin>2.22.2</version.surefire.plugin>
+    <version.apache.collections4>4.4</version.apache.collections4>
+
+    <sonar.moduleKey>${project.artifactId}</sonar.moduleKey>
+    <sonar.organization>optapy</sonar.organization>
+    <sonar.sources>src/main/java,src/main/python</sonar.sources>
+    <sonar.tests>src/test/java,tests</sonar.tests>
+    <sonar.python.version>3.9,3.10</sonar.python.version>
+    <sonar.python.coverage.reportPaths>target/coverage.xml</sonar.python.coverage.reportPaths>
   </properties>
 
   <dependencyManagement>
@@ -29,6 +37,11 @@
         <artifactId>jpyinterpreter</artifactId>
         <version>${project.version}</version>
       </dependency>
+      <dependency>
+        <groupId>org.apache.commons</groupId>
+        <artifactId>commons-collections4</artifactId>
+        <version>${version.apache.collections4}</version>
+      </dependency>
     </dependencies>
   </dependencyManagement>
 
diff --git a/tox.ini b/tox.ini
index 9317ac56..62453feb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -9,6 +9,8 @@ envlist = py39,py310
 [testenv]
 deps =
     pytest
+    pytest-cov
+    coverage
     JPype1>=1.4.0
 commands =
-    pytest --import-mode=importlib tests
+    pytest --import-mode=importlib {posargs} tests


Test patch:
diff --git a/jpyinterpreter/tests/conftest.py b/jpyinterpreter/tests/conftest.py
index 88718a36..4cd11a91 100644
--- a/jpyinterpreter/tests/conftest.py
+++ b/jpyinterpreter/tests/conftest.py
@@ -131,6 +131,15 @@ def verifier_for(the_function: Callable[..., Any]) -> FunctionVerifier:
     return FunctionVerifier(the_function, java_function, untyped_java_function)
 
 
+def pytest_addoption(parser):
+    """
+    Allows adding command line options to pytest
+    """
+    parser.addoption('--jacoco-agent', action='store', default='')
+    parser.addoption('--jacoco-output', action='store', default='target/jacoco.exec')
+    parser.addoption('--output-generated-classes', action='store', default='false')
+
+
 def pytest_configure(config):
     """
     Allows plugins and conftest files to perform initial configuration.
@@ -148,9 +157,19 @@ def pytest_sessionstart(session):
     import jpyinterpreter
     import pathlib
     import sys
-    class_output_path = pathlib.Path('target', 'tox-generated-classes', 'python',
-                                     f'{sys.version_info[0]}.{sys.version_info[1]}')
-    jpyinterpreter.init(class_output_path=class_output_path)
+
+    class_output_path = None
+    if session.config.getoption('--output-generated-classes') != 'false':
+        class_output_path = pathlib.Path('target', 'tox-generated-classes', 'python',
+                                         f'{sys.version_info[0]}.{sys.version_info[1]}')
+
+    jacoco_agent = session.config.getoption('--jacoco-agent')
+    if jacoco_agent != '':
+        jacoco_output = session.config.getoption('--jacoco-output')
+        jpyinterpreter.init(f'-javaagent:{jacoco_agent}=destfile={jacoco_output}',
+                            class_output_path=class_output_path)
+    else:
+        jpyinterpreter.init(class_output_path=class_output_path)
 
 
 def pytest_sessionfinish(session, exitstatus):
diff --git a/optapy-core/src/test/java/org/optaplanner/optapy/MirrorWithExtrasMapTest.java b/optapy-core/src/test/java/org/optaplanner/optapy/MirrorWithExtrasMapTest.java
new file mode 100644
index 00000000..b569ff9c
--- /dev/null
+++ b/optapy-core/src/test/java/org/optaplanner/optapy/MirrorWithExtrasMapTest.java
@@ -0,0 +1,135 @@
+package org.optaplanner.optapy;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+public class MirrorWithExtrasMapTest {
+
+    @Test
+    public void testGet() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        assertThat(mirrorMap.get("a")).isEqualTo("1");
+        assertThat(mirrorMap.get("b")).isEqualTo("2");
+        assertThat(mirrorMap.get("c")).isNull();
+    }
+
+    @Test
+    public void testPut() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        mirrorMap.put("c", "3");
+
+        assertThat(baseMap.containsKey("c")).isFalse();
+        assertThat(mirrorMap.get("a")).isEqualTo("1");
+        assertThat(mirrorMap.get("b")).isEqualTo("2");
+        assertThat(mirrorMap.get("c")).isEqualTo("3");
+    }
+
+    @Test
+    public void testInvalidPut() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        assertThatCode(() -> mirrorMap.put("a", "3")).isInstanceOf(IllegalArgumentException.class);
+        assertThat(baseMap).isEqualTo(Map.of("a", "1", "b", "2"));
+        assertThat(mirrorMap).isEqualTo(Map.of("a", "1", "b", "2"));
+    }
+
+    @Test
+    public void testPutAll() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        mirrorMap.putAll(Map.of("c", "3", "d", "4"));
+
+        assertThat(baseMap.containsKey("c")).isFalse();
+        assertThat(baseMap.containsKey("d")).isFalse();
+
+        assertThat(mirrorMap.get("a")).isEqualTo("1");
+        assertThat(mirrorMap.get("b")).isEqualTo("2");
+        assertThat(mirrorMap.get("c")).isEqualTo("3");
+        assertThat(mirrorMap.get("d")).isEqualTo("4");
+    }
+
+    @Test
+    public void testInvalidPutAll() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        assertThatCode(() -> mirrorMap.putAll(Map.of("c", "3", "a", "3"))).isInstanceOf(IllegalArgumentException.class);
+        assertThat(baseMap).isEqualTo(Map.of("a", "1", "b", "2"));
+        assertThat(mirrorMap).isEqualTo(Map.of("a", "1", "b", "2"));
+    }
+
+    @Test
+    public void testContainsKey() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        assertThat(mirrorMap.containsKey("a")).isTrue();
+        assertThat(mirrorMap.containsKey("1")).isFalse();
+        assertThat(mirrorMap.containsKey("c")).isFalse();
+        mirrorMap.put("c", "3");
+        assertThat(mirrorMap.containsKey("c")).isTrue();
+    }
+
+    @Test
+    public void testContainsValue() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        assertThat(mirrorMap.containsValue("1")).isTrue();
+        assertThat(mirrorMap.containsValue("a")).isFalse();
+        assertThat(mirrorMap.containsValue("3")).isFalse();
+        mirrorMap.put("c", "3");
+        assertThat(mirrorMap.containsValue("3")).isTrue();
+    }
+
+    @Test
+    public void testSize() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        assertThat(mirrorMap.size()).isEqualTo(2);
+        mirrorMap.put("c", "3");
+        assertThat(mirrorMap.size()).isEqualTo(3);
+    }
+
+    @Test
+    public void testIsEmpty() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        assertThat(mirrorMap.isEmpty()).isFalse();
+        mirrorMap.put("c", "3");
+        assertThat(mirrorMap.isEmpty()).isFalse();
+
+        baseMap = Map.of();
+        mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        assertThat(mirrorMap.isEmpty()).isTrue();
+        mirrorMap.put("a", "1");
+        assertThat(mirrorMap.isEmpty()).isFalse();
+    }
+
+    @Test
+    public void testEntrySet() {
+        Map<String, String> baseMap = Map.of("a", "1", "b", "2");
+        MirrorWithExtrasMap<String, String> mirrorMap = new MirrorWithExtrasMap<>(baseMap);
+
+        assertThat(mirrorMap.entrySet()).containsExactlyInAnyOrder(Map.entry("a", "1"),
+                Map.entry("b", "2"));
+        mirrorMap.put("c", "3");
+        assertThat(mirrorMap.entrySet()).containsExactlyInAnyOrder(Map.entry("a", "1"),
+                Map.entry("b", "2"),
+                Map.entry("c", "3"));
+    }
+
+}
diff --git a/tests/conftest.py b/tests/conftest.py
index 99ee1ee9..ee232506 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,15 @@
 import optapy
 
 
+def pytest_addoption(parser):
+    """
+    Allows adding command line options to pytest
+    """
+    parser.addoption('--jacoco-agent', action='store', default='')
+    parser.addoption('--jacoco-output', action='store', default='target/jacoco.exec')
+    parser.addoption('--output-generated-classes', action='store', default='false')
+
+
 def pytest_configure(config):
     """
     Allows plugins and conftest files to perform initial configuration.
@@ -17,8 +26,17 @@ def pytest_sessionstart(session):
     """
     import pathlib
     import sys
-    optapy.init()
-    optapy.set_class_output_directory(pathlib.Path('target', 'tox-generated-classes', 'python', f'{sys.version_info[0]}.{sys.version_info[1]}'))
+
+    jacoco_agent = session.config.getoption('--jacoco-agent')
+    if jacoco_agent != '':
+        jacoco_output = session.config.getoption('--jacoco-output')
+        optapy.init(f'-javaagent:{jacoco_agent}=destfile={jacoco_output}')
+    else:
+        optapy.init()
+
+    if session.config.getoption('--output-generated-classes') != 'false':
+        optapy.set_class_output_directory(pathlib.Path('target', 'tox-generated-classes', 'python', f'{sys.version_info[0]}.{sys.version_info[1]}'))
+
 
 
 def pytest_sessionfinish(session, exitstatus):
diff --git a/tests/test_setup.py b/tests/test_setup.py
deleted file mode 100644
index 97034784..00000000
--- a/tests/test_setup.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import atexit
-import jpype.imports
-import pytest
-
-
-@pytest.fixture(scope='session', autouse=True)
-def setup_jvm(request):
-    # Will be executed before the first test
-    yield None
-    # Will be executed after the last test
-    from java.lang import Runtime
-    exit_status = 0 if request.session.testsfailed == 0 else 1
-    atexit.register(lambda: Runtime.getRuntime().halt(exit_status))
-
-
-def test_setup(setup_jvm):
-    # required so teardown is executed
-    pass
\ No newline at end of file