Emit JUnit XML from tests + mutation, consume it in CI
Produce machine-readable results and surface them in the Gitea pipeline:
- ctest: run with --output-junit to write ctest-junit.xml. The path must be
absolute ("$(pwd)/...") because --output-junit otherwise resolves relative to
the --test-dir build directory.
- mutation_test.py: new --junit FILE option writes a JUnit report where each
mutant is a test case and a surviving mutant is a <failure> (so gaps show up
as failing tests).
- .gitea/workflows/ci.yaml: both jobs generate their XML and feed it to
mikepenz/action-junit-report with `if: always()`, so results publish even
when a gate fails. Mutation publishing is display-only (fail_on_failure:
false); the --threshold flag remains the gate.
- .gitignore: ignore the generated *-junit.xml artifacts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,13 +13,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update -y
|
sudo apt-get update -y
|
||||||
sudo apt-get install -y cmake gcc moreutils
|
sudo apt-get install -y cmake gcc moreutils
|
||||||
- name: build and test
|
- name: build and install
|
||||||
run: |
|
run: |
|
||||||
mkdir installdir
|
mkdir installdir
|
||||||
cmake -S . -B build -DCMAKE_INSTALL_PREFIX=installdir
|
cmake -S . -B build -DCMAKE_INSTALL_PREFIX=installdir
|
||||||
cmake --build build
|
cmake --build build
|
||||||
cmake --install build
|
cmake --install build
|
||||||
cmake --build build --target test
|
# --output-junit resolves relative to the test dir, so give an absolute
|
||||||
|
# path to land the report in the workspace root for the reporter below.
|
||||||
|
- name: test (JUnit)
|
||||||
|
run: ctest --test-dir build --output-on-failure --output-junit "$(pwd)/ctest-junit.xml"
|
||||||
|
- name: publish test results
|
||||||
|
if: always()
|
||||||
|
uses: mikepenz/action-junit-report@v4
|
||||||
|
with:
|
||||||
|
report_paths: 'ctest-junit.xml'
|
||||||
|
check_name: 'ctest results'
|
||||||
|
fail_on_failure: 'true'
|
||||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
- run: echo "🍏 This job's status is ${{ job.status }}."
|
||||||
|
|
||||||
mutation_test:
|
mutation_test:
|
||||||
@@ -38,5 +48,14 @@ jobs:
|
|||||||
# deeper (slower) coverage including the macro header.
|
# deeper (slower) coverage including the macro header.
|
||||||
- name: mutation testing
|
- name: mutation testing
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/mutation_test.py --target src/error.c --threshold 65
|
python3 scripts/mutation_test.py --target src/error.c --junit mutation-junit.xml --threshold 65
|
||||||
|
# Publish even when the threshold gate fails, so survivors are visible.
|
||||||
|
# Display-only (fail_on_failure: false); the --threshold above is the gate.
|
||||||
|
- name: publish mutation results
|
||||||
|
if: always()
|
||||||
|
uses: mikepenz/action-junit-report@v4
|
||||||
|
with:
|
||||||
|
report_paths: 'mutation-junit.xml'
|
||||||
|
check_name: 'mutation results'
|
||||||
|
fail_on_failure: 'false'
|
||||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
- run: echo "🍏 This job's status is ${{ job.status }}."
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
include/akerror.h
|
include/akerror.h
|
||||||
build/
|
build/
|
||||||
|
*-junit.xml
|
||||||
|
|||||||
@@ -234,6 +234,48 @@ class Runner:
|
|||||||
return "survived" if t.returncode == 0 else "killed-test"
|
return "survived" if t.returncode == 0 else "killed-test"
|
||||||
|
|
||||||
|
|
||||||
|
def _xml_escape(s):
|
||||||
|
return (s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
.replace('"', """))
|
||||||
|
|
||||||
|
|
||||||
|
def write_junit(path, records, targets):
|
||||||
|
"""Write a JUnit XML report. One <testcase> per mutant; a surviving mutant
|
||||||
|
is a <failure> (test-suite gap), a killed mutant is a passing case."""
|
||||||
|
by_file = {t: [] for t in targets}
|
||||||
|
for m, result in records:
|
||||||
|
by_file.setdefault(m.path, []).append((m, result))
|
||||||
|
|
||||||
|
total = len(records)
|
||||||
|
total_fail = sum(1 for _, r in records if r == "survived")
|
||||||
|
out = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
f'<testsuites name="mutation" tests="{total}" failures="{total_fail}">']
|
||||||
|
for f, items in by_file.items():
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
fails = sum(1 for _, r in items if r == "survived")
|
||||||
|
out.append(f' <testsuite name="mutation:{_xml_escape(f)}" '
|
||||||
|
f'tests="{len(items)}" failures="{fails}">')
|
||||||
|
for m, result in items:
|
||||||
|
name = _xml_escape(m.describe())
|
||||||
|
cls = "mutation." + _xml_escape(m.path)
|
||||||
|
if result == "survived":
|
||||||
|
detail = _xml_escape(f"{m.before.strip()} -> {m.after.strip()}")
|
||||||
|
out.append(f' <testcase name="{name}" classname="{cls}" time="0">')
|
||||||
|
out.append(f' <failure message="survived mutant '
|
||||||
|
f'({_xml_escape(m.op)} at {_xml_escape(m.path)}:'
|
||||||
|
f'{m.lineno})">{detail}</failure>')
|
||||||
|
out.append(' </testcase>')
|
||||||
|
else:
|
||||||
|
out.append(f' <testcase name="{name}" classname="{cls}" '
|
||||||
|
f'time="0"><system-out>{_xml_escape(result)}'
|
||||||
|
'</system-out></testcase>')
|
||||||
|
out.append(' </testsuite>')
|
||||||
|
out.append('</testsuites>')
|
||||||
|
with open(path, "w") as fh:
|
||||||
|
fh.write("\n".join(out) + "\n")
|
||||||
|
|
||||||
|
|
||||||
def copy_tree(src, dst):
|
def copy_tree(src, dst):
|
||||||
ignore = shutil.ignore_patterns("build", ".git", "*.o", "*.so", "*~",
|
ignore = shutil.ignore_patterns("build", ".git", "*.o", "*.so", "*~",
|
||||||
"#*#", "*.iso", "*.png")
|
"#*#", "*.iso", "*.png")
|
||||||
@@ -265,6 +307,8 @@ def main():
|
|||||||
ap.add_argument("--work", default=None)
|
ap.add_argument("--work", default=None)
|
||||||
ap.add_argument("--timeout", type=int, default=120)
|
ap.add_argument("--timeout", type=int, default=120)
|
||||||
ap.add_argument("--threshold", type=float, default=0.0)
|
ap.add_argument("--threshold", type=float, default=0.0)
|
||||||
|
ap.add_argument("--junit", default=None,
|
||||||
|
help="write a JUnit XML report to this path")
|
||||||
ap.add_argument("--max-mutants", type=int, default=0,
|
ap.add_argument("--max-mutants", type=int, default=0,
|
||||||
help="cap the run at N evenly-sampled mutants (0 = all)")
|
help="cap the run at N evenly-sampled mutants (0 = all)")
|
||||||
ap.add_argument("--list", action="store_true")
|
ap.add_argument("--list", action="store_true")
|
||||||
@@ -330,6 +374,7 @@ def main():
|
|||||||
# Group mutants by file so we mutate one file at a time and restore it.
|
# Group mutants by file so we mutate one file at a time and restore it.
|
||||||
killed = {"killed-compile": 0, "killed-test": 0, "killed-timeout": 0}
|
killed = {"killed-compile": 0, "killed-test": 0, "killed-timeout": 0}
|
||||||
survivors = []
|
survivors = []
|
||||||
|
records = []
|
||||||
total = len(all_mutants)
|
total = len(all_mutants)
|
||||||
|
|
||||||
# Cache pristine contents per target.
|
# Cache pristine contents per target.
|
||||||
@@ -345,6 +390,7 @@ def main():
|
|||||||
finally:
|
finally:
|
||||||
write_lines(tgt_abs, pristine[m.path]) # always restore
|
write_lines(tgt_abs, pristine[m.path]) # always restore
|
||||||
|
|
||||||
|
records.append((m, result))
|
||||||
if result == "survived":
|
if result == "survived":
|
||||||
survivors.append(m)
|
survivors.append(m)
|
||||||
mark = "SURVIVED"
|
mark = "SURVIVED"
|
||||||
@@ -370,6 +416,11 @@ def main():
|
|||||||
for m in survivors:
|
for m in survivors:
|
||||||
print(" " + m.describe())
|
print(" " + m.describe())
|
||||||
|
|
||||||
|
if args.junit:
|
||||||
|
junit_path = os.path.abspath(args.junit)
|
||||||
|
write_junit(junit_path, records, targets)
|
||||||
|
print(f"\nJUnit report written to: {junit_path}")
|
||||||
|
|
||||||
if not args.keep and not args.work:
|
if not args.keep and not args.work:
|
||||||
shutil.rmtree(work_parent, ignore_errors=True)
|
shutil.rmtree(work_parent, ignore_errors=True)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -42,7 +42,20 @@ cmake --build build --target mutation
|
|||||||
|
|
||||||
Useful flags: `--timeout SECONDS` (per-suite build+test cap; a mutant that
|
Useful flags: `--timeout SECONDS` (per-suite build+test cap; a mutant that
|
||||||
hangs is counted as killed), `--keep` (retain the scratch copy for debugging),
|
hangs is counted as killed), `--keep` (retain the scratch copy for debugging),
|
||||||
`--work DIR` (use a specific scratch directory).
|
`--work DIR` (use a specific scratch directory), `--junit FILE` (write a JUnit
|
||||||
|
XML report — surviving mutants appear as failing test cases).
|
||||||
|
|
||||||
|
## CI reporting
|
||||||
|
|
||||||
|
Both the unit tests and the mutation run emit JUnit XML that CI consumes:
|
||||||
|
|
||||||
|
* `ctest --test-dir build --output-junit "$(pwd)/ctest-junit.xml"` — note the
|
||||||
|
absolute path; `--output-junit` otherwise resolves relative to the test dir.
|
||||||
|
* `scripts/mutation_test.py --junit mutation-junit.xml`
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yaml` runs both and feeds the XML to
|
||||||
|
`mikepenz/action-junit-report` (with `if: always()`, so results publish even
|
||||||
|
when a gate fails). The generated `*-junit.xml` files are git-ignored.
|
||||||
|
|
||||||
## Mutation operators
|
## Mutation operators
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user