unittest - Ambiente per l'automazione dei test

Scopo Ambiente per l'automazione dei test
Versione Python 2.1

Il modulo unittest, a cui talvolta ci si riferisce come PyUnit, è basato sul progetto dell'ambiente XUnit, di Kent Beck ed Erich Gamma. Lo stesso modello viene ripetuto in molti altri linguaggi, incluso C, perl, Java e Smalltalk. L'ambiente implementato da unittest supporta fixtures (impianti di test), test suites (raccolte di test) ed un test runner (esecutore di test) per consentire l'automazione del test per il proprio codice.

Struttura di Test Base

I test, così come definiti da unittest, sono composti da due parti: il codice per gestire "l'impianto" di test, ed il test stesso. Test individuali sono creati subclassando TestCase e riscrivendo od aggiungendo i metodi appropriati. Ad esempio:

import unittest

class SimplisticTest(unittest.TestCase):

    def test(self):
        self.failUnless(True)

if __name__ == '__main__':
    unittest.main()

In questo caso, SimplisticTest ha un singolo metodo test(), che fallirebbe se True fosse mai False.

Eseguire i Test

Il modo più semplice per eseguire i test di unittest è di includere:

if __name__ == '__main__':
    unittest.main()

alla fine di ogni file di test, poi semplicemente eseguire lo script direttamente da riga di comando:

$ python unittest_simple.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

L'output abbreviato comprende il tempo impiegato per il test, assieme ad un indicatore di stato per ogni test (il puntino nella prima riga dell'output significa che un test è stato superato). Per maggiori dettagli nel risultato del test si include l'opzione -v:

$ python unittest_simple.py -v
test (__main__.SimplisticTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Esiti del Test

I test hanno 3 risultati possibili:

ok
il test viene superato
FAIL
il test non viene superato e viene sollevata una eccezione AssertionError.
ERROR
il test solleva una eccezione diversa da AssertionError.

Non esiste un modo esplicito per "far superare" un test, quindi lo stato del test dipende dalla presenza (od assenza) di una eccezione.

import unittest

class OutcomesTest(unittest.TestCase):

    def testPass(self):
        return

    def testFail(self):
        self.failIf(True)

    def testError(self):
        raise RuntimeError('Errore nel test!')

if __name__ == '__main__':
    unittest.main()

Quando un test fallisce o genera un errore, nell'ouput viene incluso anche il traceback.

$ python unittest_outcomes.py
EF.
======================================================================
ERROR: testError (__main__.OutcomesTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest_outcomes.py", line 15, in testError
    raise RuntimeError('Errore nel test!')
RuntimeError: Errore nel test!

======================================================================
FAIL: testFail (__main__.OutcomesTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest_outcomes.py", line 12, in testFail
    self.failIf(True)
AssertionError

----------------------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (failures=1, errors=1)

Nell'esempio di cui sopra, testFail() fallisce, ed il traceback mostra la riga che comprende il codice che ha fallito. E' comunque compito di colui che legge il risultato del test di verificare il codice per desumere il significato semantico del fallimento del test. Per facilitare la comprensione della natura del fallimento del test, i metodi fail*() ed assert*() accettano tutti un parametro msg, che può essere usato per produrre un messaggio di errore più dettagliato.

import unittest

class FailureMessageTest(unittest.TestCase):

    def testFail(self):
        self.failIf(True, 'Il messaggio di fallimento va qui')

if __name__ == '__main__':
    unittest.main()
$ python unittest_failwithmessage.py -v
testFail (__main__.FailureMessageTest) ... FAIL

======================================================================
FAIL: testFail (__main__.FailureMessageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest_failwithmessage.py", line 9, in testFail
    self.failIf(True, 'Il messaggio di fallimento va qui')
AssertionError: Il messaggio di fallimento va qui

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)

Affermare la Verità

La maggior parte dei test affermano la verità di una qualche condizione. Ci sono alcuni diversi modi di scrivere dei test che verifichino una verità, a seconda della prospettiva dell'autore del test ed del risultato voluto del codice che si sta verificando. Se il codice produce un valore che può essere valutato come vero, dovrebbero essere usati i metodi failUnless() ed assertTrue(). Se il codice produce un valore falso, ha più senso usare i metodi failIf() ed assertFalse().

import unittest

class TruthTest(unittest.TestCase):

    def testFailUnless(self):
        self.failUnless(True)

    def testAssertTrue(self):
        self.assertTrue(True)

    def testFailIf(self):
        self.failIf(False)

    def testAssertFalse(self):
        self.assertFalse(False)

if __name__ == '__main__':
    unittest.main()
$ python unittest_failwithmessage.py -v
testFail (__main__.FailureMessageTest) ... FAIL

======================================================================
FAIL: testFail (__main__.FailureMessageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest_failwithmessage.py", line 9, in testFail
    self.failIf(True, 'Il messaggio di fallimento va qui')
AssertionError: Il messaggio di fallimento va qui

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
robby@robby-desktop:~/pydev/pymotw-it/dumpscripts$ python unittest_truth.py -v
testAssertFalse (__main__.TruthTest) ... ok
testAssertTrue (__main__.TruthTest) ... ok
testFailIf (__main__.TruthTest) ... ok
testFailUnless (__main__.TruthTest) ... ok

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

OK

Verificare una Eguaglianza

Come caso speciale, unittest comprende metodi per verificare l'eguaglianza di due valori.

import unittest

class EqualityTest(unittest.TestCase):

    def testEqual(self):
        self.failUnlessEqual(1, 3-2)

    def testNotEqual(self):
        self.failIfEqual(2, 3-2)

if __name__ == '__main__':
    unittest.main()
$ python unittest_failwithmessage.py -v
testFail (__main__.FailureMessageTest) ... FAIL

======================================================================
FAIL: testFail (__main__.FailureMessageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest_failwithmessage.py", line 9, in testFail
    self.failIf(True, 'Il messaggio di fallimento va qui')
AssertionError: Il messaggio di fallimento va qui

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
robby@robby-desktop:~/pydev/pymotw-it/dumpscripts$ python unittest_truth.py -v
testAssertFalse (__main__.TruthTest) ... ok
testAssertTrue (__main__.TruthTest) ... ok
testFailIf (__main__.TruthTest) ... ok
testFailUnless (__main__.TruthTest) ... ok

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

OK
robby@robby-desktop:~/pydev/pymotw-it/dumpscripts$ python unittest_equality.py -v
testEqual (__main__.EqualityTest) ... ok
testNotEqual (__main__.EqualityTest) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Questi test speciali fanno comodo, visto che i valori confrontati appaiono nel messaggio di fallimento, quando un test fallisce.

import unittest

class InequalityTest(unittest.TestCase):

    def testEqual(self):
        self.failIfEqual(1, 3-2)

    def testNotEqual(self):
        self.failUnlessEqual(2, 3-2)

if __name__ == '__main__':
    unittest.main()

E quando questi test vengono eseguiti:

$ python unittest_notequal.py -v
testEqual (__main__.InequalityTest) ... FAIL
testNotEqual (__main__.InequalityTest) ... FAIL

======================================================================
FAIL: testEqual (__main__.InequalityTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest_notequal.py", line 9, in testEqual
    self.failIfEqual(1, 3-2)
AssertionError: 1 == 1

======================================================================
FAIL: testNotEqual (__main__.InequalityTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest_notequal.py", line 12, in testNotEqual
    self.failUnlessEqual(2, 3-2)
AssertionError: 2 != 1

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (failures=2)

Quasi Uguali?

Oltre alla stretta eguaglianza, è possibile verificare una "quasi" eguaglianza di numeri a virgola mobile usando failIfAlmostEqual() e failUnlessAlmostEqual().

import unittest

class AlmostEqualTest(unittest.TestCase):

    def testNotAlmostEqual(self):
        self.failIfAlmostEqual(1.1, 3.3-2.0, places=1)

    def testAlmostEqual(self):
        self.failUnlessAlmostEqual(1.1, 3.3-2.0, places=0)

if __name__ == '__main__':
    unittest.main()

I parametri sono i valori da confrontare, ed il numero di posizioni decimali da utilizzare per il test.

$ python unittest_almostequal.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Verificare le Eccezioni

Come detto precedentemente, se un test solleva una eccezione diversa da AssertionError, viene trattata come un errore. Questo è molto utile per scoprire errori che si compiono mentre si sta modificando del codice per il quale già esiste un test abbinato. Ci sono circostanze, comunque, nelle quali si vuole eseguire il test per verificare che un certo codice effettivamente produca una eccezione. Ad esempio se un valore non valido viene passato come attributo di un oggetto. In tali casi, failUnlessRaises() rende il codice più chiaro che catturare l'eccezione nel proprio codice. Si confrontino questi due test:

import unittest

def raises_error(*args, **kwds):
    print args, kwds
    raise ValueError('Valore non valido: ' + str(args) + str(kwds))

class ExceptionTest(unittest.TestCase):

    def testTrapLocally(self):
        try:
            raises_error('a', b='c')
        except ValueError:
            pass
        else:
            self.fail('Non si vede ValueError')

    def testFailUnlessRaises(self):
        self.failUnlessRaises(ValueError, raises_error, 'a', b='c')

if __name__ == '__main__':
    unittest.main()

I risultati per entrambi sono gli stessi, tuttavia il secondo test, che usa failUnlessRaises() è più succinto.

$ python unittest_exception.py -v
testFailUnlessRaises (__main__.ExceptionTest) ... ('a',) {'b': 'c'}
ok
testTrapLocally (__main__.ExceptionTest) ... ('a',) {'b': 'c'}
ok

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Test Fixtures (Impianti di Test)

Fixtures sono risorse necessarie per un test. Ad esempio, se si stanno scrivendo parecchi test per la stessa classe, questi test necessitano tutti una istanza di quella classe da usere per il test. Altri impianti includono connessioni a database e file temporanei (molta gente potrebbe argomentare che usando risorse esterne i test non sono più considerabili a livello di unità, ma sono comunque test, e sono comunque utili). TestCase include uno speciale aggancio per configurare e ripulire un qualsivoglia impianto che sia necessario per i propri test. Per configurare gli impianti, si riscrive setup(). Per ripulire, si riscrive tearDown().

import unittest

class FixturesTest(unittest.TestCase):

    def setUp(self):
        print 'In setUp()'
        self.fixture = range(1, 10)

    def tearDown(self):
        print 'In tearDown()'
        del self.fixture

    def test(self):
        print 'in test()'
        self.failUnlessEqual(self.fixture, range(1, 10))

if __name__ == '__main__':
    unittest.main()

Quando il test di esempio viene eseguito, si può vedere l'ordine di esecuzione dell'impianto e dei metodi di test:

$ python unittest_fixtures.py
In setUp()
in test()
In tearDown()
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Test Suite (Raccolte di Test)

La documentazione della libreria standard descrive come organizzare manualmente le raccolte di test. Io (Doug Hellmann - n.d.t.) generalmente non uso raccolte di test direttamente, poichè preferisco costruire le raccolte automaticamente (si tratta dopo tutto di test automatizzati). Automatizzare la costruzione di raccolte di test è specialmente utile per vaste basi di codice, nelle quali i test collegati non sono tutti nello stesso posto. Strumenti tipo nose facilitano la gestione dei test quando essi sono sparsi attraverso file e directory multiple.

Vedere anche:

unittest
La documentazione della libreria standard per questo modulo.
doctest
Un modo alternativo di eseguire test incorporati in docstring e file di documentazione esterni
nose
Un gestore di test più sofisticato
unittest2
Miglioramenti in corso di elaborazione per unittest