Coverage¶
Wir haben eine erste Liste von Testfällen erstellt. Die Tests im
tests/api
-Verzeichnis testen die Items über die API. Aber woher wissen
wir, ob diese Tests unseren Code umfassend testen? An dieser Stelle kommt die
Codeabdeckung (engl.: Coverage) ins Spiel.
Tools, die die Codeabdeckung messen, beobachten euren Code, während eine Testsuite ausgeführt wird, und halten fest, welche Zeilen durchlaufen werden und welche nicht. Dieses Maß – die sog. line coverage – wird berechnet, indem die Gesamtzahl der ausgeführten Zeilen durch die Gesamtanzahl der Codezeilen geteilt wird. Code-Coverage-Tools können euch auch sagen, ob alle Pfade in Control-Statements durchlaufen werden, eine Messung, die als Branch-Coverage bezeichnet wird.
Die Codeabdeckung kann euch jedoch nicht sagen, ob eure Testsuite gut ist; sie kann euch nur darüber informieren, wie viel des Anwendungscodes von eurer Testsuite durchlaufen wird.
Coverage.py ist das bevorzugte Python-Tool, das die Codeabdeckung misst. Und pytest-cov ist ein beliebtes Pytest-Plugin, das oft in Verbindung mit Coverage.py verwendet wird.
Coverage.py mit pytest-cov verwenden¶
Sowohl Coverage.py als auch pytest-cov sind Third-Party-Packages, die vor der Verwendung installiert werden müssen:
Ihr könnt einen Report für die Testabdeckung erstellen mit Coverage.py.
$ python -m pip install coverage pytest-cov
C:> python -m pip install coverage pytest-cov
Bemerkung
Wollt ihr die Testabdeckung für Python 2 and Python<3.6 ermitteln, müsst ihr Coverage<6.0 verwenden.
Um Tests mit Coverage.py auszuführen, müsst ihr die Option --cov
hinzufügen
und entweder einen Pfad zu dem Code angeben, den ihr messen wollt, oder das
installierte Paket, das ihr testet. In unserem Fall ist das Projekt Items ein
installiertes Paket, so dass wir es mit --cov=items
testen werden.
Auf die normale pytest-Ausgabe folgt der Abdeckungsbericht, wie hier gezeigt:
$ cd /PATH/TO/items
$ python3 -m venv .venv
$ . .venv/bin/activate
$ python -m pip install ".[dev]"
$ pytest --cov=items
============================= test session starts ==============================
...
rootdir: /Users/veit/cusy/prj/items
configfile: pyproject.toml
testpaths: tests
plugins: cov-4.1.0, Faker-19.11.0
collected 35 items
tests/api/test_add.py .... [ 11%]
tests/api/test_config.py . [ 14%]
tests/api/test_count.py ... [ 22%]
tests/api/test_delete.py ... [ 31%]
tests/api/test_finish.py .... [ 42%]
tests/api/test_list.py ......... [ 68%]
tests/api/test_start.py .... [ 80%]
tests/api/test_update.py .... [ 91%]
tests/api/test_version.py . [ 94%]
tests/cli/test_add.py .. [100%]
---------- coverage: platform darwin, python 3.11.5-final-0 ----------
Name Stmts Miss Cover
-------------------------------------------
src/items/__init__.py 3 0 100%
src/items/api.py 70 1 99%
src/items/cli.py 38 9 76%
src/items/db.py 23 0 100%
-------------------------------------------
TOTAL 134 10 93%
============================== 35 passed in 0.11s ==============================
Die vorherige Ausgabe wurde von den Berichtsfunktionen von coverage erzeugt, obwohl wir coverage nicht direkt aufgerufen haben.
pytest --cov=items
wies das pytest-cov
-Plugin an
coverage
mit--source
aufitems
zu setzen, während pytest mit den Tests ausgeführt wirdcoverage report
auszuführen für den Line-Coverage-Report
Ohne pytest-cov würden die Befehle wie folgt aussehen:
$ coverage run --source=items -m pytest
$ coverage report
Die Dateien __init__.py
und db.py
haben eine Abdeckung von 100%,
was bedeutet, dass unsere Testsuite auf jede Zeile in diesen Dateien trifft. Das
sagt uns jedoch nicht, dass sie ausreichend getestet ist oder dass die Tests
mögliche Fehler erkennen; aber es sagt uns zumindest, dass jede Zeile während der
Testsuite ausgeführt wurde.
Die Datei cli.py
hat eine Abdeckung von 76%. Dies mag überraschend hoch
erscheinen, da wir die CLI noch gar nicht getestet haben. Dies hängt jedoch
damit zusammen, dass cli.py
von __init__.py
importiert wird, so
dass alle Funktionsdefinitionen ausgeführt werden, aber keiner der
Funktionsinhalte.
Wirklich interessiert uns jedoch die api.py
-Datei mit 99% Testabdeckung.
Wir können herausfinden, was übersehen wurde, indem wir die Tests erneut ausführen und die Option --cov-report=term-missing
hinzufügen:
pytest --cov=items --cov-report=term-missing
============================= test session starts ==============================
...
rootdir: /Users/veit/cusy/prj/items
configfile: pyproject.toml
testpaths: tests
plugins: cov-4.1.0, Faker-19.11.0
collected 35 items
tests/api/test_add.py .... [ 11%]
tests/api/test_config.py . [ 14%]
tests/api/test_count.py ... [ 22%]
tests/api/test_delete.py ... [ 31%]
tests/api/test_finish.py .... [ 42%]
tests/api/test_list.py ......... [ 68%]
tests/api/test_start.py .... [ 80%]
tests/api/test_update.py .... [ 91%]
tests/api/test_version.py . [ 94%]
tests/cli/test_add.py .. [100%]
---------- coverage: platform darwin, python 3.11.5-final-0 ----------
Name Stmts Miss Cover Missing
-----------------------------------------------------
src/items/__init__.py 3 0 100%
src/items/api.py 68 1 99% 52
src/items/cli.py 38 9 76% 18-19, 25, 39-43, 51
src/items/db.py 23 0 100%
-----------------------------------------------------
TOTAL 132 10 92%
============================== 35 passed in 0.11s ==============================
Da wir nun die Zeilennummern der nicht getesteten Zeilen haben, können wir die Dateien in einem Editor öffnen und die fehlenden Zeilen betrachten. Einfacher ist es jedoch, sich den HTML-Bericht anzusehen.
Siehe auch
HTML-Berichte generieren¶
Mit Coverage.py können wir HTML-Berichte erstellen, um die Coverage-Daten
detaillierter betrachten zu können. Der Bericht wird entweder mit der Option
--cov-report=html
oder durch die Ausführung von coverage html
nach einem
vorherigen Coverage-Run erstellt:
$ cd /PATH/TO/items
$ python3 -m venv .venv
$ . .venv/bin/activate
$ python -m pip install ".[dev]"
$ pytest --cov=items --cov-report=html
Bei beiden Befehlen wird Coverage.py aufgefordert, einen HTML-Bericht im
htmlcov/
-Verzeichnis zu erstellen. Öffnet htmlcov/index.html
mit
einem Browser und ihr solltet folgendes sehen:
Wenn ihr auf die src/items/api.py:
-Datei klickt, wird ein Bericht für
diese Datei angezeigt:
Der obere Teil des Berichts zeigt den Prozentsatz der abgedeckten Zeilen (99%), die Gesamtzahl der Statements (68) und wie viele Statements ausgeführt (67), übersehen (1) und ausgeschlossen (0) wurden. Klickt auf
, um die Zeilen hervorzuheben, die nicht ausgeführt wurden:Es sieht so aus, als hätte die Funktion add_item()
eine Exception
MissingSummary
, die bisher nicht getestet wird.
Code von der Testabdeckung ausschließen¶
In den HTML-Berichten findet ihr eine Spalte mit der Angabe 0 excluded. Dies bezieht sich auf eine Funktion von Coverage.py, die es uns ermöglicht, einige Zeilen von der Prüfung auszuschließen. In Items schließen wir nichts aus. Es ist jedoch nicht ungewöhnlich, dass einige Codezeilen von der Berechnung der Testabdeckung ausgeschlossen werden, z.B. können Module, die sowohl importiert wie auch direkt ausgeführt werden sollen, einen Block enthalten, der so oder so ähnlich aussieht:
if __name__ == "__main__":
main()
Dieser Befehl weist Python an, main()
auszuführen, wenn wir das Modul
direkt aufrufen mit python my_module.py
, aber den Code nicht auszuführen,
wenn das Modul importiert wird. Diese Arten von Code-Blöcken werden häufig mit
einer einfachen Pragma-Anweisung vom Testen ausgeschlossen:
if __name__ == "__main__": # pragma: no cover
main()
Damit wird Coverage.py angewiesen, entweder eine einzelne Zeile oder einen Code-Block auszuschließen. Wenn, wie in diesem Fall, das Pragma in der if-Anweisung steht, müsst ihr es nicht in beide Codezeilen einfügen.
Alternativ kann dies auch für alle Vorkommen konfiguriert werden:
[run]
branch = True
[report]
; Regexes for lines to exclude from consideration
exclude_also =
; Don’t complain if tests don’t hit defensive assertion code:
raise AssertionError
raise NotImplementedError
; Don't complain if non-runnable code isn’t run:
if __name__ == .__main__.:
ignore_errors = True
[html]
directory = coverage_html_report
[tool.coverage.run]
branch = true
[tool.coverage.report]
# Regexes for lines to exclude from consideration
exclude_also = [
# Don’t complain if tests don’t hit defensive assertion code:
"raise AssertionError",
"raise NotImplementedError",
# Don’t complain if non-runnable code isn’t run:
"if __name__ == .__main__.:",
]
ignore_errors = true
[tool.coverage.html]
directory = "coverage_html_report"
[coverage:run]
branch = True
[coverage:report]
; Regexes for lines to exclude from consideration
exclude_also =
; Don’t complain if tests don’t hit defensive assertion code:
raise AssertionError
raise NotImplementedError
; Don’t complain if non-runnable code isn’t run:
if __name__ == .__main__.:
ignore_errors = True
[coverage:html]
directory = coverage_html_report
Siehe auch
Erweiterungen¶
In Coverage.py plugins findet ihr auch eine Reihe von Erweiterungen für Coverage.
Testabdeckung aller Tests mit GitHub-Actions¶
Nachdem ihr die Testabdeckung überprüft habt, könnt ihr die Dateien als
GitHub-Action z.B. in einer ci.yaml
als
Artefakte hochladen um sie später in weiteren Jobs wiederverwenden zu können:
45 - name: Upload coverage data
46 uses: actions/upload-artifact@v4
47 with:
48 name: coverage-data
49 path: .coverage.*
50 include-hidden-files: true
51 if-no-files-found: ignore
include-hidden-files
ist mit actions/upload-artifact v4.4.0 notwendig geworden.
if-no-files-found: ignore
ist sinnvoll, wenn nicht für alle Python-Versionen die Testabdeckung gemessen werden soll um schneller zum Ergebnis zu kommen. Daher solltet ihr nur für diejenigen Elemente eurer Matrix, die ihr berücksichtigen wollt, die Daten hochladen.
Nachdem alle Tests durchlaufen wurden, könnt ihr einen weiteren Job definieren, der die Ergebnisse zusammenführt:
53 coverage:
54 name: Combine and check coverage
55 needs: tests
56 runs-on: ubuntu-latest
57 steps:
58 - name: Check out the repo
59 uses: actions/checkout@v4
60
61 - name: Set up Python
62 uses: actions/setup-python@v5
63 with:
64 python-version: 3.12
65
66 - name: Install dependencies
67 run: |
68 python -m pip install --upgrade coverage[toml]
69
70 - name: Download coverage data
71 uses: actions/download-artifact@v4
72 with:
73 name: coverage-data
74
75 - name: Combine coverage and fail it it’s under 100 %
76 run: |
77 python -m coverage combine
78 python -m coverage html --skip-covered --skip-empty
79
80 # Report and write to summary.
81 python -m coverage report | sed 's/^/ /' >> $GITHUB_STEP_SUMMARY
82
83 # Report again and fail if under 100%.
84 python -Im coverage report --fail-under=100
85
86 - name: Upload HTML report if check failed
87 uses: actions/upload-artifact@v4
88 with:
89 name: html-report
90 path: htmlcov
91 if: ${{ failure() }}
needs: tests
stellt sicher, dass alle Tests durchgeführt werden. Wenn euer Job, der die Tests ausführt, einen anderen Namen hat, müsst ihr ihn hier anpassen.
name: "Download coverage data"
lädt die Daten der Testabdeckung herunter, die zuvor mit
name: "Upload coverage data"
hochgeladen wurden.name: "Combine coverage and fail it it’s under 100 %"
kombiniert die Testabdeckung und erstellt einen HTML-Bericht, wenn die Bedingung
--fail-under=100
erfüllt ist.
Sobald der Workflow abgeschlossen ist, könnt ihr den HTML-Bericht herunterladen unter
.Badge¶
Ihr könnt GitHub Actions verwenden, um ein Badge mit eurer Code-Coverage zu
erstellen. Dabei wird zusätzlich ein GitHub Gist benötigt um die Parameter für
das Badge, das von shields.io gerendert wird, zu
speichern. Hierfür erweitern wir unsere ci.yaml
folgendermaßen:
93 - name: Create badge
94 uses: schneegans/dynamic-badges-action@v1.7.0
95 with:
96 auth: ${{ secrets.GIST_TOKEN }}
97 gistID: YOUR_GIST_ID
98 filename: covbadge.json
99 label: Coverage
100 message: ${{ env.total }}%
101 minColorRange: 50
102 maxColorRange: 90
103 valColorRange: ${{ env.total }}
- Zeile 97
GIST_TOKEN
ist ein persönliches GitHub-Zugangs-Token.- Zeile 98
YOUR_GIST_ID
solltet ihr durch eure eigene Gist-ID ersetzen. Falls ihr noch keine Gist-ID habt, könnt ihr diese erstellen mit:Ruft https://gist.github.com auf und erstellt einen neuen Gist, den ihr z.B.
test.json
nennen könnt. Die ID des Gist ist der lange alphanumerische Teil der URL, den ihr hier benötigt.Anschließend geht ihr zu https://github.com/settings/tokens und erstellt ein neues Token mit dem Gist-Bereich.
Geht schließlich zu
und fügt dieses Token hinzu. Ihr könnt ihm einen beliebigen Namen geben, z.B.GIST_SECRET
.Wenn ihr Dependabot verwendet, um die Abhängigkeiten eures Repository automatisch zu aktualisieren, müsst ihr das
GIST_SECRET
auch in hinzufügen.
- Zeilen 102-104
Das Badge wird automatisch eingefärbt:
≤ 50 % in rot
≥ 90 % in grün
mit einem Farbverlauf zwischen den beiden
Jetzt kann das Badge mit einer URL wie dieser angezeigt werden:
https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/YOUR_GITHUB_NAME/GIST_SECRET/raw/covbadge.json
.