arrow for title

Come scrivere i test in Python e Django usando il mocking

14 Mag, 2020 Sviluppo
Come scrivere i test in Python e Django usando il mocking

In 20tab sviluppiamo software testato con una percentuale di copertura del codice (coverage) molto alta: durante la fase di sviluppo non è mai sotto al 95%, alla fine del progetto il coverage atteso è sempre al 100%.

Una delle traduzioni del verbo to mock dall’inglese all’italiano è “canzonare”. Ovviamente non è proprio la parola giusta per definire il mocking, ma mi piace molto la sua definizione:

canzonare
    /can·zo·nà·re/
    verbo transitivo
    Prendere in giro, spec. a parole; burlare, deridere.
    "smettila di canzonarmi!"

In effetti quando si fa mocking, durante un test, stiamo in qualche modo prendendo in giro il nostro software o la funzione che stiamo testando, simulando il comportamento di una determinata funzionalità esterna.

In Python esiste un package, nella standard library, che ci aiuta ad applicare i mock durante i nostri test.

Ma quando e perché è necessario fare tutto ciò?

Quando scriviamo un software, molto spesso avremo bisogno di fare delle chiamate a funzioni esterne al nostro codice: ad esempio chiamate al sistema operativo, richieste HTTP verso API esterne, chiamate a funzioni sulle quali non abbiamo il diretto controllo ma dalle quali il nostro software dipende.

Bene, per ognuno di questi casi (e non solo) è necessario simulare l’output delle funzioni esterne per poter effettuare test coerenti del nostro software.

In questo articolo non voglio addentrarmi sull’utilizzo dettagliato della standard library “unittest.mock”. Per quello ti rimando alla documentazione ufficiale!

Approfitto invece per appuntare qualche esperienza utile che potrebbe farti risparmiare tempo durante la scrittura dei test.

Mocking su oggetti immutabili

Uno dei problemi che spesso incontro è quello di effettuare dei test di funzionalità che usano la classe datetime.date e, nel caso specifico, ho bisogno di testare una funzionalità che dipende da una certa data.

Il primo approccio è quello di fare il mock della funzione today della classe datetime.date come illustrato di seguito:

exampleapp/functions.py

from datetime import date
 
def myfunc_using_date():
    print("do something")
    day = date.today()
    print("do something else")
    return day

exampleapp/tests.py

from datetime import date
from unittest.mock import patch
 
 
from django.test import TestCase
from exampleapp.functions import myfunc_using_date


def mocked_today():
    return date(year=2020, month=1, day=1)
 
 
class TestImmutableObj(TestCase):
    @patch("exampleapp.functions.date.today", mocked_today)
    def test_myfunc_using_date(self):
        self.assertEqual(
            myfunc_using_date().strftime("%Y-%m-%d"), 
            "2020-01-01"
        )

Peccato però che, eseguendo questo test, avremo un errore di esecuzione:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E
======================================================================
ERROR: test_myfunc_using_date (exampleapp.tests.TestImmutableObject)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/user/.pyenv/versions/3.7.5/lib/python3.7/unittest/mock.py", line 1247, in patched
	arg = patching.__enter__()
  File "/Users/user/.pyenv/versions/3.7.5/lib/python3.7/unittest/mock.py", line 1410, in __enter__
	setattr(self.target, self.attribute, new_attr)
TypeError: can't set attributes of built-in/extension type 'datetime.date'

Esistono diversi modi per aggirare questo problema, ma quello che ho trovato più comodo è stato scrivere una funzione custom che restituisse il valore di today e usare questa funzione nel codice al fine di poterne fare un mock agevole. Te lo mostro qui di seguito:

exampleapp/functions.py

from datetime import date
 
def get_today():
   return date.today()
 
def myfunc_using_date():
   print("do something")
   day = get_today()
   print("do something else")
   return day

exampleapp/tests.py

from datetime import date
from unittest.mock import patch
 
 
from django.test import TestCase


from exampleapp.functions import myfunc_using_date
 
def mocked_today():
    return date(year=2020, month=1, day=1)
 
class TestImmutableObject(TestCase):
    @patch("exampleapp.functions.get_today", mocked_today)
    def test_myfunc_using_date(self):
        self.assertEqual(myfunc_using_date().strftime("%Y-%m-%d"), "2020-01-01")

Ovviamente, in questo caso, il test viene eseguito correttamente e non avremo più impedimenti durante la scrittura del nostro codice, effettuando delle verifiche coerenti sulle nostre funzionalità.

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
do something
do something else
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...

Il codice che ho presentato serve a titolo di esempio. Per fare mocking quando si usano datetime, questa è una buona libreria.

Mocking di richieste http

Un altro caso molto comune, dove è necessario applicare il mocking, lo incontriamo quando abbiamo delle funzionalità che effettuano chiamate ad API di servizi esterni. Il nostro software dipende dalla risposta che riceviamo, ma della quale non abbiamo nessun controllo.

La cosa che però a noi importa di più è che il nostro codice sorgente sia ben testato, indipendentemente dal mondo esterno. 

In questo contesto ovviamente parliamo sempre di test unitari. Qualora volessimo essere certi del buon funzionamento del nostro software, dovremo scrivere dei test di integrazione, ma questo è oggetto di un altro argomento molto vasto.

Vediamo allora come risolvere il problema della dipendenza da API esterne nei test unitari.

Immaginiamo di avere un endpoint che ci restituisce, tra i tanti valori, anche dei valori che cambiano ad ogni chiamata. Un esempio? L’esatto orario della richiesta. 

È evidente che questo orario cambierà ad ogni chiamata e quindi, anche quando eseguiremo il test, sarà impossibile che il risultato sia coerente con quello che ci aspettiamo, come si può vedere dal codice seguente:

exampleapp/functions.py

import requests
 
def call_external_api():
    response = requests.get("http://worldtimeapi.org/api/timezone/Europe/Rome")
    data = response.json()
    currenttime = data.get("datetime")
    return currenttime

exampleapp/tests.py

from django.test import TestCase


from exampleapp.functions import call_external_api
 
 
class TestExternalAPI(TestCase):
    def test_call_external_api(self):
        self.assertEqual(
            call_external_api(),
            "2020-04-30T12:52:25.020721+02:00"
        )

Naturalmente l’esito sarà un fallimento del test:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_call_external_api (exampleapp.tests.TestExternalAPI)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/user/www/howtomock/exampleapp/tests.py", line 12, in test_call_external_api
	"2020-04-30T12:52:25.020721+02:00"
AssertionError: '2020-04-30T12:52:58.341965+02:00' != '2020-04-30T12:52:25.020721+02:00'
- 2020-04-30T12:52:58.341965+02:00
?               	- ^^ ---
+ 2020-04-30T12:52:25.020721+02:00
?              	+  ^^^^^


----------------------------------------------------------------------
Ran 1 test in 7.131s

FAILED (failures=1)
Destroying test database for alias 'default'...

Per risolvere questo problema, uno dei modi che ho usato e che ritengo essere molto comodo, è quello di fare il mock della classe Response, restituendo un vero json che avremo prontamente confezionato al momento del test e che serviremo al momento della chiamata http.

exampleapp/tests.json

from unittest.mock import patch
 
from django.test import TestCase
 
from exampleapp.functions import call_external_api
 
 
class MockResponse:
 
    def __init__(self):
        self.status_code = 200
 
    def json(self):
        return {
            "week_number": 18,
            "utc_offset": "+02:00",
            "utc_datetime": "2020-04-30T10:48:54.398875+00:00",
            "unixtime": 1588243734,
            "timezone": "Europe/Rome",
            "raw_offset": 3600,
            "dst_until": "2020-10-25T01:00:00+00:00",
            "dst_offset": 3600,
            "dst_from": "2020-03-29T01:00:00+00:00",
            "dst": True,
            "day_of_year": 121,
            "day_of_week": 4,
            "datetime": "2020-04-30T12:48:54.398875+02:00",
            "client_ip": "91.252.18.0",
            "abbreviation": "CEST"
        }
 
 
class TestExternalAPI(TestCase):
 
    @patch("requests.get", return_value=MockResponse())
    def test_call_external_api(self, mocked):
        self.assertEqual(
            call_external_api(),
            "2020-04-30T12:48:54.398875+02:00"
        )

In questo modo non ci dovremo mai più preoccupare del mondo esterno e ci potremo concentrare sul testing delle funzionalità da noi scritte.

Anche in questo caso, l’esempio ha un semplice scopo didattico. 

2 risorse per te

Raffaele Colace

Blog

Potrebbe interessarti anche:

contattaci

Hai una buona idea ma non sai che pesci prendere?

Parlaci del tuo progetto