Coverage
========
Wir haben eine erste Liste von Testfällen erstellt. Die Tests im
:file:`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 :abbr:`sog. (sogenannte)` 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
:doc:`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.
.. tab:: Linux/macOS
.. code-block:: console
$ python -m pip install coverage pytest-cov
.. tab:: Windows
.. code-block:: ps1con
C:> python -m pip install coverage pytest-cov
.. note::
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 Coverage-Bericht, wie hier gezeigt:
.. code-block:: pytest
$ 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`` auf ``items`` zu setzen, während pytest mit den
Tests ausgeführt wird
* ``coverage report`` auszuführen für den Line-Coverage-Report
Ohne pytest-cov würden die Befehle wie folgt aussehen:
.. code-block:: console
$ coverage run --source=items -m pytest
$ coverage report
Die Dateien :file:`__init__.py` und :file:`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 :file:`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 :file:`cli.py` von :file:`__init__.py` importiert wird, so
dass alle Funktionsdefinitionen ausgeführt werden, aber keiner der
Funktionsinhalte.
Wirklich interessiert uns jedoch die :file:`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:
.. code-block:: pytest
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.
.. seealso::
* `pytest-cov’s documentation `_
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:
.. code-block:: console
$ 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
:file:`htmlcov/`-Verzeichnis zu erstellen. Öffnet :file:`htmlcov/index.html` mit
einem Browser und ihr solltet folgendes sehen:
.. image:: coverage.png
:alt: Coverage report: 92%
Wenn ihr auf die :file:`src/items/api.py:`-Datei klickt, wird ein Bericht für
diese Datei angezeigt:
.. image:: api.png
:alt: Coverage for src/items/api.py: 99%
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
:menuselection:`missing` , um die Zeilen hervorzuheben, die nicht ausgeführt
wurden:
.. image:: missing.png
:alt: raise MissingSummary
Es sieht so aus, als hätte die Funktion :func:`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, :abbr:`z.B. (zum Beispiel)` können Module,
die sowohl importiert wie auch direkt ausgeführt werden sollen, einen Block
enthalten, der so oder so ähnlich aussieht:
.. code-block:: python
if __name__ == "__main__":
main()
Dieser Befehl weist Python an, :func:`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:
.. code-block:: python
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:
.. tab:: :file:`.coveragerc`
.. code-block:: ini
[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
.. tab:: :file:`pyproject.toml`
.. code-block:: toml
[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"
.. tab:: :file:`setup.cfg`, :file:`tox.ini`
.. code-block:: ini
[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
.. seealso::
`Configuration reference
`_
Erweiterungen
-------------
In `Coverage.py plugins
`_ findet ihr
auch eine Reihe von Erweiterungen für Coverage.
.. _coverage-github-actions:
Testabdeckung aller Tests mit GitHub-Actions
--------------------------------------------
Nachdem ihr die Testabdeckung überprüft habt, könnt ihr die Dateien als
GitHub-Action :abbr:`z.B. (zum Beispiel)` in einer :download:`ci.yaml` als
Artefakte hochladen um sie später in weiteren Jobs wiederverwenden zu können:
.. literalinclude:: ci.yaml
:language: yaml
:lines: 45-51
:lineno-start: 45
``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:
.. literalinclude:: ci.yaml
:language: yaml
:lines: 53-91
:lineno-start: 53
``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 :menuselection:`YOUR_REPO --> Actions --> tests --> Combine and check
coverage`.
.. seealso::
* `How to Ditch Codecov for Python Projects
`_
* `structlog main.yml
`_
.. _coverage-badge:
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 :download:`ci.yaml` folgendermaßen:
.. literalinclude:: ci.yaml
:language: yaml
:lines: 93-
:lineno-start: 93
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
:abbr:`z.B. (zum Beispiel)` :file:`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 :menuselection:`YOUR_REPO --> Settings --> Secrets
--> Actions` und fügt dieses Token hinzu. Ihr könnt ihm einen beliebigen
Namen geben, :abbr:`z.B. (zum Beispiel)` :samp:`{GIST_SECRET}`.
Wenn ihr `Dependabot `_ verwendet, um die
Abhängigkeiten eures Repository automatisch zu aktualisieren, müsst ihr
das :samp:`{GIST_SECRET}` auch in :menuselection:`YOUR_REPO --> Settings
--> Secrets --> Dependabot` 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:
:samp:`https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/{YOUR_GITHUB_NAME}/{GIST_SECRET}/raw/covbadge.json`.
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_40.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_45.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_50.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_55.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_60.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_65.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_70.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_75.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_80.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_85.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_90.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_95.json
.. image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/nedbat/a27aaed4944c1f760a969a543fb52767/raw/covbadge2_100.json