Merge branch 'ReportPicameraTestsInCI' into 'v3'

Report Picamera tests in CI

See merge request openflexure/openflexure-microscope-server!337
This commit is contained in:
Julian Stirling 2025-07-30 16:31:47 +00:00
commit 59fd808fa9
6 changed files with 222 additions and 2 deletions

View file

@ -2,3 +2,4 @@
concurrency = multiprocessing, thread
parallel = true
sigterm = true
relative_files = True

3
.gitignore vendored
View file

@ -50,6 +50,9 @@ nosetests.xml
coverage.xml
report.xml
pytest_report.xml
.coverage.picamera
.coverage.main
picamera_test_hashes.json
# Translations
*.mo

View file

@ -130,15 +130,32 @@ pytest:
- git config --global user.name "Sir Unit of Test"
- git config --global user.email "fake@email.no"
- pytest
- mv .coverage .coverage.main
# --gitlab-code-quality-report=pytest-warnings.json # can restore when it installs ok
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
when: always
paths:
- report.xml
- .coverage.main
# Combine CI coverage with coverage from Picamera
combined-tests:
stage: testing
extends: .python
needs:
- job: pytest
artifacts: true
script:
- ./picamera_tests.py unzip
- coverage combine
# Use -i to ignore file changing between the two runs. We check the relevant ones.
- coverage report -i
- coverage xml -i
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
when: always
reports:
junit: report.xml
# codequality: pytest-warnings.json
coverage_report:
coverage_format: cobertura
path: coverage.xml

View file

@ -0,0 +1,79 @@
# Hardware specific tests
This directory contains unit tests that require specific hardware to run. Current they implement tests for code that interacts with the Raspberry Pi Camera.
## Hardware specific tests for the Pi Camera.
The OpenFlexure Microscope Server runs automated test in the cloud (via the GitLab CI) to test each version of the code as changes are proposed and merged. As these tests run in the cloud they do not have access to actual microscope hardware. As such, these tests either test specific blocks of code that don't need hardware access or make use of simulated hardware via the simulation camera and dummy stage.
To test the code that interacts with the hardware itself, hardware specific tests are needed and they must be run with the hardware available. This directory provides these tests. For now the only hardware specific tests are for the Pi Camera. They must be run on a Raspberry Pi with a Pi Camera attached.
In GitLab we track our "code coverage", this tells us which lines of code have been executed during testing. The `picamera_tests.py` script in the root of the repository can be used to report the coverage from these tests. See the section below on "Reporting the coverage to GitLab".
### Running these tests during development
These are very slow as they run on hardware. They can be run with:
pytest hardware-specific-tests
It is essential to stop the server first:
ofm stop
However, this will not archive the tests in the Git repository for reporting the coverage. For this see the section below on reporting the coverage.
When writing and debugging these unit tests it is often best to run a specific test and to use the `-s` flag to see the print statements. It is also often useful to use `--pdb` to drop you into a python debug session on any failure. For example, you might run:
pytest hardware-specific-tests/picamera2/test_exposure_time_drift.py::test_exposure_time_saves_and_loads -s --pdb
### Reporting the coverage to GitLab
To create a coverage report that will be included into the repository (and reported to GitLab) run:
./picamera_tests.py run
This will first run `pytest` on the hardware specific tests. This creates a `.coverage` database that can be used to analyse the code coverage of these tests. This will be copied to `.coverage.picamera`. The script will then create a zip of these test results along with hashes of source code for the Pi Camera:
* `picamera.py`
* `picamera_recalibrate_utils.py`
* `__init__.py` in the `camera` directory that defines `BaseCamera`
This zip should then be committed to the repository.
When the CI runs on GitLab to calculate code coverage. It will first run the tests in the `tests` directory, in one job and archive this as `.coverage.main`, and then it will run a second job that:
* Imports the archive of `.coverage.main` for the tests just run on the server
* Unzip the zip of the results of running tests on the Pi Camera (`.coverage.picamera`)
* Check that the hashes for the Pi Camera source code have not changed. If they have changed it will error and ask for the picamera tests to be re-run on a Raspberry Pi.
* It will then run `coverage combine` to create a single `.coverage` report that combines the coverage from both `.coverage.main` and `.coverage.picamera`.
* It then generates the information needed to display the coverage in GitLab
This ensures that:
* Hardware specific tests are re-run if the relevant source code changes.
* That the coverage for hardware specific tests is reported correctly
* That the hardware specific tests do not need re-running when other code that does not affect PiCamera interaction is updated.
### Creating a combined report locally
To create a combined coverage report locally (similar to the one created on CI) first run all normal tests with `pytest`.
Copy the `.coverage` file to `.coverage.main`
cp .coverage .coverage.main
Then run:
./picamera_tests.py unzip
to get the `.coverage.picamera` file.
To combine reports run:
coverage combine
It is then possible to run:
* `coverage report` to get the in-terminal report of the coverage.
* `coverage html` to get the HTML overview of covered lines.
* `coverage xml` to get an XML report any software that may need it.

BIN
picamera_coverage.zip Normal file

Binary file not shown.

120
picamera_tests.py Executable file
View file

@ -0,0 +1,120 @@
#! /usr/bin/env python3
"""Run the Piamera tests and archive the results in the git repository."""
import subprocess
import shutil
import os
import posixpath
import argparse
import json
import sys
import zipfile
CAM_DIR = os.path.join("src", "openflexure_microscope_server", "things", "camera")
ZIP_FILE = "picamera_coverage.zip"
COVERAGE_FILE = ".coverage.picamera"
HASH_FILE = "picamera_test_hashes.json"
# If any of the HASHED_FILES change, then the picamera tests need to be re-run.
# System independent can be checked on windows even if running tests must be on a Pi
HASHED_FILES = [
os.path.join(CAM_DIR, "__init__.py"),
os.path.join(CAM_DIR, "picamera.py"),
os.path.join(CAM_DIR, "picamera_recalibrate_utils.py"),
]
def _get_hashes(include_coverage: bool = False) -> dict[str, str]:
"""Return a dictionary of Git hashes for the HASHED FILES.
The key is the posixpath to the file so matching works even if the dictionary is
compared to a dictionary created on another system.
The coverage file is hashed when running for debug purposes.
Git hashes are so the files are line ending independent.
"""
hashes = {}
filepaths = HASHED_FILES
if include_coverage:
filepaths.append(COVERAGE_FILE)
for filepath in filepaths:
if not os.path.isfile(filepath):
# Sys exit rather than raise for better command line experience
print(f"ERROR: {filepath} does not exist!")
sys.exit(-1)
# Create git hashes for file system independence
file_hash = subprocess.check_output(["git", "hash-object", filepath])
hashes[posixpath.normpath(filepath)] = file_hash.decode("utf-8").strip()
return hashes
def run_tests() -> None:
"""Run the picamera tests, zip the coverage database, hash relevant source files."""
subprocess.run(["pytest", "hardware-specific-tests"], check=True)
shutil.copyfile(".coverage", COVERAGE_FILE)
hash_dict = _get_hashes(include_coverage=True)
with open(HASH_FILE, "w", encoding="utf-8") as json_file:
json.dump(hash_dict, json_file)
with zipfile.ZipFile(ZIP_FILE, "w") as zipf:
zipf.write(COVERAGE_FILE)
zipf.write(HASH_FILE)
def unzip_coverage() -> None:
"""Unzip coverage database if hashes match relevant source files."""
if zipfile.is_zipfile(ZIP_FILE):
with zipfile.ZipFile(ZIP_FILE, "r") as zip_obj:
zip_obj.extractall()
else:
print("ERROR: Zipfile with coverage results not found.")
sys.exit(-1)
hash_dict = _get_hashes()
if os.path.isfile(HASH_FILE):
with open(HASH_FILE, "r", encoding="utf-8") as json_file:
zipped_hash_dict = json.load(json_file)
else:
print("ERROR: JSON hashes not available. Zipfile is incomplete.")
sys.exit(-1)
del zipped_hash_dict[COVERAGE_FILE]
if hash_dict != zipped_hash_dict:
# Sys exit rather than raise for better command line experience
print(
"ERROR: hashes don't match. This probably means the source has changed "
"since the tests were last run on the Pi."
)
sys.exit(-1)
def main() -> None:
"""Either run tests or unzip previous results."""
parser = argparse.ArgumentParser(
description=(
"picamera_tests: run or validate PiCamera hardware test coverage.\n\n"
"Use 'run' on the Pi to execute tests and package coverage data.\n"
"Use 'unzip' on another machine to extract and verify that the test\n"
"data matches the source code before unzipping it."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("run", help="Run hardware-specific tests on the PiCamera")
subparsers.add_parser(
"unzip", help="Unzip and verify coverage data on another machine"
)
args = parser.parse_args()
if args.command == "run":
run_tests()
elif args.command == "unzip":
unzip_coverage()
if __name__ == "__main__":
main()