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