Jednotkové testy

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

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.

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?

In [1]:
%%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á!

In [2]:
%%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
In [4]:
!python auto_test.py
FFFF
======================================================================
FAIL: test_neprazdna_nadrz (__main__.AutoTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "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 "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 "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 "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.001s

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...

In [5]:
%%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
In [6]:
!python auto_test.py
....
----------------------------------------------------------------------
Ran 4 tests in 0.000s

OK

A je to. Hurá!

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:

In [7]:
%%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:

In [8]:
!pytest -vv test_auto_pytest.py
============================= test session starts ==============================
platform linux -- Python 3.6.5, pytest-3.6.1, py-1.5.3, pluggy-0.6.0 -- /home/kuba/opt/conda/envs/fjfi/bin/python
cachedir: .pytest_cache
rootdir: /home/kuba/workspace/python_fjfi-git/numerical_python_course/lecture_notes.cz, inifile:
plugins: remotedata-0.3.0, openfiles-0.3.0, doctestplus-0.1.3, arraydiff-0.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.07 seconds ===========================

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

In [9]:
!pytest -vv .
============================= test session starts ==============================
platform linux -- Python 3.6.5, pytest-3.6.1, py-1.5.3, pluggy-0.6.0 -- /home/kuba/opt/conda/envs/fjfi/bin/python
cachedir: .pytest_cache
rootdir: /home/kuba/workspace/python_fjfi-git/numerical_python_course/lecture_notes.cz, inifile:
plugins: remotedata-0.3.0, openfiles-0.3.0, doctestplus-0.1.3, arraydiff-0.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.09 seconds ===========================

Komentáře

Comments powered by Disqus