Coverage¶
Wir haben eine erste Liste von Testfällen erstellt. Die Tests im
tests/api-Verzeichnis testen die Tasks ü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.
$ uv add --group tests pytest-cov
C:> uv add --group tests 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
cusy.task ein installiertes Paket, so dass wir es mit --cov=cusy.task
testen werden.
Auf die normale pytest-Ausgabe folgt der Coverage-Bericht, wie hier gezeigt:
$ cd /PATH/TO/cusy.tasks
$ uv sync --group tests
$ uv run pytest --cov=cusy.tasks
============================= test session starts ==============================
...
rootdir: /Users/veit/cusy/prj/cusy.tasks
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/cusy.tasks/__init__.py 3 0 100%
src/cusy.tasks/api.py 70 1 99%
src/cusy.tasks/cli.py 38 9 76%
src/cusy.tasks/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=cusy.tasks wies das pytest-cov-Plugin an
coveragemit--sourceaufcusy.taskszu setzen, während pytest mit den Tests ausgeführt wirdcoverage reportauszuführen für den Line-Coverage-Report
Ohne pytest-cov würden die Befehle wie folgt aussehen:
$ coverage run --source=cusy.tasks -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=cusy.tasks --cov-report=term-missing
============================= test session starts ==============================
...
rootdir: /Users/veit/cusy/prj/cusy.tasks
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/cusy.tasks/__init__.py 3 0 100%
src/cusy.tasks/api.py 68 1 99% 52
src/cusy.tasks/cli.py 38 9 76% 18-19, 25, 39-43, 51
src/cusy.tasks/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/cusy.tasks
$ uv sync --group tests
$ uv run pytest --cov=cusy.tasks --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/cusy/tasks/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_task() 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 cusy.tasks 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
covdefaults: A coverage plugin to provide sensible default settings
coverage-conditional-plugin: Conditional coverage based on any rules you define
Tipp
Viele schließen Tests von der Testabdeckung aus: omit tests path:**/pyproject.toml. Dies ist jedoch eine schlechte Idee. Eure Tests sind echter Code, und der ganze Sinn der Testabdeckung besteht darin, euch Informationen über euren Code zu geben. Warum solltet ihr diese Informationen über eure Tests nicht wollen?
Ihr könntet sagen: „Alle meine Tests führen den ganzen Code aus, also sind es nutzlose Informationen.“ Wenn ihr jedoch einen neuen Test schreibt und hierfür einen bestehenden Test kopiert und nur die Ausführung ändert, nicht jedoch den Funktionsnamen, so wird nur einer der beiden Testfunktionen ausgeführt. Und seid ihr euch sicher, dass jeder Hilfscode in eurer Testsuite noch benötigt wird? Coverage würde euch in beiden Fällen auf dieses Problem aufmerksam machen.
Ein Argument gegen die Coverage von Tests ist, dass sie die Reports künstlich aufbläst. Aber ihr könnt mit [report] skip_covered diese Dateien auch einfach aus dem Report ausschließen.
Erweiterungen¶
- diff-cover
ermittelt automatisch den Prozentsatz neuer oder geänderter Zeilen, die durch Tests abgedeckt sind.
- Sphinx-Test-Reports
zeigt Testergebnisse innerhalb von Sphinx-Dokumentationen an.
Siehe auch
In Coverage.py plugins findet ihr auch noch andere 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:
72 - name: Upload coverage data
73 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
74 with:
75 name: coverage-data
76 path: .coverage.*
77 include-hidden-files: true
78 if-no-files-found: ignore
include-hidden-filesist mit actions/upload-artifact v4.4.0 notwendig geworden.
if-no-files-found: ignoreist 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:
80 coverage:
81 name: Combine and check coverage
82 needs: tests
83 runs-on: ubuntu-latest
84 steps:
85 - name: Download pre-built packages
86 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
87 with:
88 name: Packages
89 path: dist
90 - run: tar xf dist/*.tar.gz --strip-components=1
91 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
92 with:
93 python-version-file: .python-version
94 - uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0
95
96 - name: Download coverage data
97 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
98 with:
99 pattern: coverage-data-*
100 merge-multiple: true
101
102 - name: Combine coverage and fail if it's <100%.
103 run: |
104 uv tool install coverage
105
106 coverage combine
107 coverage html --skip-covered --skip-empty
108
109 # Report and write to summary.
110 coverage report --format=markdown >> $GITHUB_STEP_SUMMARY
111
112 # Report again and fail if under 100%.
113 coverage report --fail-under=100
114
115 - name: Upload HTML report if check failed.
116 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
117 with:
118 name: html-report
119 path: htmlcov
120 if: ${{ failure() }}
needs: testsstellt 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=100erfü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:
122 - name: Create badge
123 uses: schneegans/dynamic-badges-action@0e50b8bad39e7e1afd3e4e9c2b7dd145fad07501 # v1.8.0
124 with:
125 auth: ${{ secrets.GIST_TOKEN }}
126 gistID: YOUR_GIST_ID
127 filename: covbadge.json
128 label: Coverage
129 message: ${{ env.total }}%
130 minColorRange: 50
131 maxColorRange: 90
132 valColorRange: ${{ env.total }}
GIST_TOKENist ein persönliches GitHub-Zugangs-Token.
YOUR_GIST_IDsolltet 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.jsonnennen 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_SECRETauch in hinzufügen.
minColorRange,maxColorRange,valColorRangeDas 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.