14. Jednotkové testy#

Piště testy, hned a pořádně - vyplatí se to!

14.1. Jednotkové testy#

Axiom 1: Každý program obsahuje alespoň jednu chybu.

Axiom 2: Každý program lze při zachování funkčnosti zkrátit o jednu instrukci.

Věta: Každý program lze zkrátit na jednu chybnou instrukci. Důkaz matematickou indukcí přenecháváme čtenáři.

  • Jednotkové testy slouží k automatizovanému ověření funkčnosti programu.

    • při vývoji jeho částí (v extrémním případě, tzv. test-driven development, dokonce nejdříve napíšeme testy, které popisují chování nějakého kódu pomocí podmínek, jež má splňovat, a pak vyvíjíme kód, dokud neprojde.

    • v udržování kódu: opětovné puštění testu nás ujistí, že jsme při dalším vývoji nezanesli chybu do něčeho, co už fungovalo.

  • Jednotkový test se zaměřuje vždy na nějakou “jednotku” (obvykle to bývá metoda, či jedno z jejích použití). Pokud to jde, snažíme se snížit výsledek testu na aktuálně netestovaných částech kódu.

V kompilovaných jazycích (C++, Java) za nás část (jen část!) kontroly kódu udělá kompilátor, který zkontroluje, že nepoužíváme nedeklarované proměnné, že jsou všechny proměnné správného typu, … Sice se tak nevyhneme chybám v logice programu, ale obvykle se tak odchytí alespoň překlepy. V Pythonu (a jiných skriptovacích jazycích) žádná kontrola předem neexistuje - jediná kontrola při načítání kódu se týká syntaktické správnosti. Z toho vyplývá, že kód v Pythonu je mnohem náchylnějším vůči chybám a o to více bychom ho měli kontrolovat. Jednotkové testování je v tomto nejmocnějším nástrojem.

14.2. Použití v praxi#

Ve standardní knihovně Pythonu je modul unittest, v němž jsou všechny potřebné třídy a metody. Naše testy by měly dědit unittest.TestCase (typicky v jedné třídě několik testů patřících k sobě)

Příklad: Mějme pěknou třídu Auto, o které si myslím, že musí fungovat. Je s ní nějaký problém?

%%file auto.py
# -*- coding: utf8 -*-
class Auto(object):
    def __init__(self, spotreba, rychlost):
        self.spotreba = spotreba
        self.rychlost = rychlost
        self.cas = 0
        self.vzdalenost = 0
        self.nadrz = 50

    def ujed(self, vzdalenost):
        self.vzdalenost += vzdalenost
        self.cas += vzdalenost / self.rychlost
        self.nadrz -= vzdalenost * self.spotreba
Overwriting auto.py

Napíšeme si několik testů, protože si auto chceme “proklepnout”, a najednou uvidíme, jak je naše třída děravá!

%%file auto_test.py

# -*- coding: utf8 -*-
from auto import Auto
import unittest

class AutoTest(unittest.TestCase):             # Dědíme z třídy unittest.TestCase
    def test_vypocet_spotreby(self):
        auto = Auto(10, 200)                   # Dost žere, ale je rychlé
        nadrz1 = auto.nadrz
        auto.ujed(100)
        nadrz2 = auto.nadrz
        self.assertEqual(10, nadrz1 - nadrz2)  # Víme, že auto mělo spotřebovat 10 litrů

    def test_neprazdna_nadrz(self):
        auto = Auto(8, 100)
        with self.assertRaises(Exception):
            auto.ujed(1000)                    # Dojde benzín
        self.assertTrue(auto.nadrz == 0)       # I poté musí nádrž být nejhůře prázdná

    def test_nesmyslnych_aut(self):
        with self.assertRaises(Exception):
            auto = Auto(0, 100)                # Auto bez spotřeby!
        with self.assertRaises(Exception):
            auto = Auto(10, 0)                 # Auto, které neumí jezdit!
        with self.assertRaises(Exception):
            auto = Auto(-10, 100)              # Auto, které vyrábí benzín.

    def test_nezaporna_vzdalenost(self):       # Metody začínající na "test_" jsou automaticky spuštěny
        auto = Auto(8, 100)
        with self.assertRaises(Exception):
            auto.ujed(-1)

if __name__ == "__main__":
    unittest.main()                 # Tímto pustíme testy
Overwriting auto_test.py
!python auto_test.py
FFFF
======================================================================
FAIL: test_neprazdna_nadrz (__main__.AutoTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/kuba/workspace/fjfi/python-fjfi/numerical_python_course/lecture_notes.cz/auto_test.py", line 17, in test_neprazdna_nadrz
    auto.ujed(1000)                    # Dojde benzín
AssertionError: Exception not raised

======================================================================
FAIL: test_nesmyslnych_aut (__main__.AutoTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/kuba/workspace/fjfi/python-fjfi/numerical_python_course/lecture_notes.cz/auto_test.py", line 22, in test_nesmyslnych_aut
    auto = Auto(0, 100)                # Auto bez spotřeby!
AssertionError: Exception not raised

======================================================================
FAIL: test_nezaporna_vzdalenost (__main__.AutoTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/kuba/workspace/fjfi/python-fjfi/numerical_python_course/lecture_notes.cz/auto_test.py", line 31, in test_nezaporna_vzdalenost
    auto.ujed(-1)
AssertionError: Exception not raised

======================================================================
FAIL: test_vypocet_spotreby (__main__.AutoTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/kuba/workspace/fjfi/python-fjfi/numerical_python_course/lecture_notes.cz/auto_test.py", line 12, in test_vypocet_spotreby
    self.assertEqual(10, nadrz1 - nadrz2)  # Víme, že auto mělo spotřebovat 10 litrů
AssertionError: 10 != 1000

----------------------------------------------------------------------
Ran 4 tests in 0.000s

FAILED (failures=4)
  • Neřešili jsme, v jakých jednotkách počítáme spotřebu.

  • Dovolili jsme si vytvořit nesmyslná auta!

  • Dovolili jsme přečerpání nádrže.

  • Nevadilo nám, že auto ujetím záporné vzdálenosti vyrábí benzín.

Zkusíme tedy opravit…

%%file auto.py

# -*- coding: utf8 -*-
from __future__ import division

class Auto(object):
    def __init__(self, spotreba, rychlost):
        if spotreba <= 0:
            raise Exception("Auto musí mít nezápornou spotřebu.")
        if rychlost <= 0:
            raise Exception("Auto musí jezdit nezápornou rychlostí.")
        self.spotreba = spotreba
        self.rychlost = rychlost
        self.cas = 0
        self.vzdalenost = 0
        self.nadrz = 50

    def ujed(self, vzdalenost):
        if vzdalenost < 0:
            raise Exception("Vzdálenost musí být nezáporná.")
        if (vzdalenost * self.spotreba / 100) > self.nadrz:
            # Auto ujede, kolik může a pak vyhodí výjimku
            skutecna_vzdalenost = 100 * (self.nadrz / self.spotreba)
            self.ujed(skutecna_vzdalenost)    # Rekurze :-)
            raise Exception("Došel benzín")
        self.vzdalenost += vzdalenost
        self.cas += vzdalenost / self.rychlost
        self.nadrz -= (vzdalenost * self.spotreba / 100)
Overwriting auto.py
!python auto_test.py
....
----------------------------------------------------------------------
Ran 4 tests in 0.000s

OK

A je to. Hurá!

14.3. pytest#

Pokud vám přišla konstrukce pomocí tříd (TestCase) poněkud krkolomná, nejste zdaleka sami. Ve většině případů se používá pro testování nějaké rozšíření (framework), psaní a spouštění testů zjednoduší. Nejlepší volbou je v tuto chvíli pytest.

Předchozí test by vypadal asi takto:

%%file test_auto_pytest.py

# -*- coding: utf8 -*-
from auto import Auto
import pytest

def test_vypocet_spotreby():
    auto = Auto(10, 200)                   # Dost žere, ale je rychlé
    nadrz1 = auto.nadrz
    auto.ujed(100)
    nadrz2 = auto.nadrz
    assert 10 == nadrz1 - nadrz2           # Víme, že auto mělo spotřebovat 10 litrů

def test_neprazdna_nadrz():
    auto = Auto(8, 100)
    with pytest.raises(Exception):
        auto.ujed(1000)                    # Dojde benzín
    assert auto.nadrz == 0                 # I poté musí nádrž být nejhůře prázdná

def test_nesmyslnych_aut():
    with pytest.raises(Exception):
        auto = Auto(0, 100)                # Auto bez spotřeby!
    with pytest.raises(Exception):
        auto = Auto(10, 0)                 # Auto, které neumí jezdit!
    with pytest.raises(Exception):
        auto = Auto(-10, 100)              # Auto, které vyrábí benzín.

def test_nezaporna_vzdalenost():       # Metody začínající na "test_" jsou automaticky spuštěny
    auto = Auto(8, 100)
    with pytest.raises(Exception):
        auto.ujed(-1)
Overwriting test_auto_pytest.py

Testy z tohoto souboru pak můžeme spustit takto:

!pytest -vv test_auto_pytest.py
============================= test session starts ==============================
platform darwin -- Python 3.9.16, pytest-7.3.1, pluggy-1.0.0 -- /Users/kuba/mambaforge/envs/python-fjfi/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/kuba/workspace/fjfi/python-fjfi/numerical_python_course/lecture_notes.cz
plugins: anyio-3.6.2
collected 4 items                                                              

test_auto_pytest.py::test_vypocet_spotreby PASSED                        [ 25%]
test_auto_pytest.py::test_neprazdna_nadrz PASSED                         [ 50%]
test_auto_pytest.py::test_nesmyslnych_aut PASSED                         [ 75%]
test_auto_pytest.py::test_nezaporna_vzdalenost PASSED                    [100%]

============================== 4 passed in 0.01s ===============================

Ještě jednodušší je nechat pytest prohledat aktuální adresář a spustit všechny testy (včetně tradičních unittest testů).

!pytest -vv .
============================= test session starts ==============================
platform darwin -- Python 3.9.16, pytest-7.3.1, pluggy-1.0.0 -- /Users/kuba/mambaforge/envs/python-fjfi/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/kuba/workspace/fjfi/python-fjfi/numerical_python_course/lecture_notes.cz
plugins: anyio-3.6.2
collected 8 items                                                              

auto_test.py::AutoTest::test_neprazdna_nadrz PASSED                      [ 12%]
auto_test.py::AutoTest::test_nesmyslnych_aut PASSED                      [ 25%]
auto_test.py::AutoTest::test_nezaporna_vzdalenost PASSED                 [ 37%]
auto_test.py::AutoTest::test_vypocet_spotreby PASSED                     [ 50%]
test_auto_pytest.py::test_vypocet_spotreby PASSED                        [ 62%]
test_auto_pytest.py::test_neprazdna_nadrz PASSED                         [ 75%]
test_auto_pytest.py::test_nesmyslnych_aut PASSED                         [ 87%]
test_auto_pytest.py::test_nezaporna_vzdalenost PASSED                    [100%]

============================== 8 passed in 0.02s ===============================