Scrivere la tua prima applicazione in Django, parte 5

Questo tutoria inizia dove Tutorial 4 si è concluso. Abbiamo creato una applicazione Web chiamata sondaggi, e ora creeremo dei test automatici per la nostra applicazione.

Dove trovare aiuto:

Se hai difficoltà a completare questo tutorial, per favore vai alla sezione Getting Help delle FAQ.

Introduzione ai test automatici

Cosa sono i test automatici?

I test sono routine che controllano il funzionamento del codice.

Il testing opera a livelli differenti. Alcuni test si posso applicare a piccoli dettagli (un modello particolare restituisce i valori come ci si aspetta?) mentre altri esaminano le operazioni del software a linee più generali (la sequenza di input dell’utente sul sito produce l’output desiderato?). Non è differente dal tipo di test che hai fatto nel Tutorial 2, usando la shell per esaminare il comportamento di un metodo o lanciando l’applicazione ed inserendo i dati per controllare come si comporta.

La differenza nell” automatizzare i test è che il sistema svolge i test al posto tuo. Tu crei un insieme di test una volta, e ogni volta che effettui una modifica alla tua applicazione, puoi controllare che il codice funzioni come ti aspettavi originariamente, senza dover impiegare tempo a svolgere test manualmente.

Perché devi creare dei test

Quindi perche creare test, e perche ora?

Potresti sentire di avere abbastanza da fare solo per imparare Python / Django, e avere ancora un’altra cosa da imparare e da fare può sembrare gravoso e forse non necessario. Dopotutto, la nostra applicazione per i sondaggi ora funziona abbastanza bene; affrontare il problema della creazione di test automatizzati non lo farà funzionare meglio. Se la creazione dell’applicazione sondaggi è l’ultima parte della programmazione Django che farai mai, allora è vero, non hai bisogno di sapere come creare test automatizzati. Ma se non è così, ora è un ottimo momento per imparare.

I test ti faranno risparmiare tempo

Fino a un certo punto accertarsi che l’app “sembra funzionare” potrebbe essere un test soddisfacente. In applicazioni sofisticate, potresti avere dozzine di complesse interazioni tra i componenti.

Una modifica in uno di questi componenti potrebbe avere conseguenze inaspettate sul comportamento dell’applicazione. Controllare che «tutto funzioni» potrebbe voler dire provare le funzionalità del tuo codice con decine di differenti varianti dei tuoi dati di test per essere sicuro di non aver danneggiato nulla con l’ultima modifica - non un bell’impiego del tuo tempo.

Specialmente quando i test automatici possono fare tutto questo per te in pochi secondi. Se qualcosa va storto, i test ti assisteranno nel trovare il codice che ha causato il comportamento inaspettato.

A volte, potrebbe sembrare un lavoro che toglie tempo alla tua produttività, dover programmare per affrontare il compito poco affascinante e poco emozionante di scrivere test, soprattutto quando sai che il tuo codice sta funzionando a dovere.

Tuttavia, scrivere test è molto più soddisfacente che passare ore a testare manualmente l’applicazione o cercare di identificare la causa di un problema appena introdotto.

I test non si limitano a identificare i problemi, ma li prevengono

È uno sbaglio pensare che i test siano un aspetto negativo dello sviluppo.

Senza test, il fine o il comportamento atteso di una applicazione potrebbe essere piuttosto opaco. Anche se è il tuo codice, talvolta ti ritroverai a frugarci dentro cercando di capire che cosa fa esattamente.

I test operano un cambiamento; illuminano il tuo codice dall’interno e quando qualcosa va storto, gettano luce sulla parte che ha fallito – anche quando non ti sei nemmeno accorto che ha fallito.

I test rendono il tuo codice più attraente

Potresti anche aver creato un software brillante ma potresti scoprire che molti altri sviluppatori si rifiutano di dargli uno sguardo perchè manca dei test; senza test, non ci faranno affidamento. Jacob Kaplan-Moss, uno degli sviluppatori originali di Django, dice «Il codice senza test è marcio dalle fondamenta».

Il fatto che altri sviluppatori vogliano vedere i test nel tuo software prima di prenderlo sul serio è ancora un altro motivo per iniziare a scrivere test.

I test aiutano i gruppi a lavorare insieme

I punti precedenti sono scritti dal punto di vista di un solo sviluppatore che mantiene una applicazione. Applicazioni complesse saranno manutenute da team. I test garantiscono che i colleghi non rompano inavvertitamente il codice (e che tu non danneggi il loro senza volerlo). Se vuoi vivere della programmazione Django, devi essere bravo a scrivere test!

Strategie basilari di test

Ci sono tanti modi per scrivere i test.

Alcuni programmatori seguono una disciplina chiamata «test-driven development»; scrivono test anche prima di scrivere il codice. Può sembrare una cosa controintuitiva ma in effetti è simile a quel che molte persone farebbero in ogni caso; descrivono un problema, poi creano del codice per risolverlo. Il test-driven development formalizza il problema in un test case Python.

Molto più spesso, un principiante del testing creerà un po” di codice e poi deciderà che debba avere dei test. Magari sarebbe stato meglio scrivere alcuni test prima ma non è mai troppo tardi per iniziare.

A volte è difficile capire da dove cominciare per scrivere dei test. Se hai scritto molte centinaia di righe di Python, scegliere qualcosa da testare può non essere semplice. In questi casi, puoi scrivere il tuo primo test la prossima volta che apporti un cambiamento, che sia aggiungere una nuova feature o risolvere un bug.

Quindi facciamo le cose in maniera giusta.

Scrivere il nostro primo test

Identifichiamo un bug

Fortunatamente, c’è un piccolo bug nell’applicazione polls che possiamo risolvere: il metodo Question.was_published_recently() restituisce True se Question è stata pubblicata entro un giorno (che è corretto) ma anche se il campo pub_date di Question è futuro (che certamente non va bene).

Conferma il bug usando la shell per controllare il metodo su una domanda la cui data è futura:

$ python manage.py shell
...\> py manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # create a Question instance with pub_date 30 days in the future
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # was it published recently?
>>> future_question.was_published_recently()
True

Siccome le cose nel futuro non sono “recenti”, questo è chiaramente sbagliato.

Creare un test per esporre un bug

Quel che abbiamo appena fatto nella shell per testare il problema è esattamente quel che facciamo nel nostro test automatizzato, quindi convertiamolo in un test automatico.

Il posto che per convenzione ospita i test di una applicazione è il file tests.py; il sistema di testing troverà automaticamente i test in ogni file il cui nome inizia per test.

Metti quanto segue nel file tests.py nell’applicazione polls:

polls/tests.py
import datetime

from django.test import TestCase
from django.utils import timezone

from .models import Question


class QuestionModelTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() returns False for questions whose pub_date
        is in the future.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

Qui abbiamo creato una sottoclasse django.test.TestCase con un metodo che crea una istanza di Question con una pub_date nel futuro. Quindi controlliamo l’output di was_published_recently() - che dovrebbe essere False.

Eseguire i test

Nel terminale, possiamo eseguire il nostro test:

$ python manage.py test polls
...\> py manage.py test polls

e vedrai qualcosa del genere:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
    self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.001s

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

Errore diverso?

Se al contrario ottieni un NameError, potresti esserti perso uno step nella Parte 2 dove abbiamo aggiunto gli import di datetime e timezone a polls/models.py. Copia gli import da quella sezione e prova a lanciare di nuovo i test.

Quello che è successo è questo

  • manage.py test polls cerca i test nell’applicazione polls
  • ha trovato una sottoclasse di tipo django.test.TestCase
  • ha creato un database speciale con lo scopo di testing
  • cerca metodi di test - quelli i cui nomi iniziano con test
  • in test_was_published_recently_with_future_question ha creato una istanza di Question il cui campo pub_date è avanti di 30 giorni nel futuro
  • …ed usando il metodo assertIs(), ha scoperto che il suo was_published_recently() restituisce True, anche se volevamo che restituisse False

Il test ci informa su quale test è fallito e ci dice anche la linea su cui si è verificato il fallimento.

Correggere il bug

Sappiamo già che il problema è: Question.was_published_recently() dovrebbe restituire False se la sua pub_date è futura. Correggi il metodo in models.py, così che restiuisca True solo se la data è nel passato:

polls/models.py
def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now

ed eseguire nuovamente il test

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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

Dopo aver identificato un bug, abbiamo scritto un test che lo evidenzia e corretto il bug nel codice in modo che il nostro test venga superato.

Molte altre cose potrebbero andare storte con la nostra applicazione in futuro, ma possiamo essere sicuri che non reintrodurremo inavvertitamente questo specifico bug perché l’esecuzione del test ci avviserà immediatamente. Possiamo considerare questa piccola parte dell’applicazione bloccata in modo sicuro per sempre.

Test più completi

Già che ci siamo, possiamo aggiustare meglio il metodo was_published_recently(); in effetti, potrebbe essere estremamente imbarazzante se nel tentativo di correggere un bug ne introducessimo un altro.

Aggiungi altri due metodi di test alla stessa classe, per testare il comportamento del metodo in modo più esteso:

polls/tests.py
def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() returns False for questions whose pub_date
    is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=1, seconds=1)
    old_question = Question(pub_date=time)
    self.assertIs(old_question.was_published_recently(), False)

def test_was_published_recently_with_recent_question(self):
    """
    was_published_recently() returns True for questions whose pub_date
    is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

Ed adesso abbiamo tre test che confermano che Question.was_published_recently() restuisce valori ragionevoli per le domande passate, recenti e future.

Di nuovo, polls è una applicazione minimale ma a prescindere da quanto cresca in complessità o con quale codice interagisca, adesso abbiamo qualche garanzia che i metodi per cui abbiamo scritto test si comporteranno come ci aspettiamo.

Testare una vista

L’applicazione dei sondaggi agisce in modo abbastanza indiscriminato: pubblicherà ogni domanda, incluse quelle il cui campo pub_date è futuro. Dovremmo migliorare questa cosa. Impostare una pub_date nel futuro significa che la domanda deve essere pubblicata in quel momento ma fino ad allora deve essere invisibile.

Un test per una vista

Quando abbiamo corretto il bug sopra, abbiamo scritto prima il test e poi il codice per risolverlo. In effetti è stato un esempio di sviluppo basato sui test (TDD), ma non importa in quale ordine svolgiamo il lavoro.

Nel nostro primo test, ci siamo concentrati sul comportamento interno del codice. Per questo test, vogliamo verificarne il comportamento come se fosse fatto da un utente tramite un browser web.

Prima di provare a riparare qualcosa, diamo un’occhiata agli strumenti a nostra disposizione.

Il client test di Django

Django offre una Client di test per simulare un utente che interagisce con il codice a livello della view. Possiamo usarla in tests.py o persino nella shell.

Cominciamo di nuovo con la shell, dove dobbiamo fare un paio di cose che non saranno necessarie in tests.py. La prima è approntare l’ambiente di test nella shell:

$ python manage.py shell
...\> py manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() installa un template renderer che ci permetterà di esaminare alcuni attributi aggiuntivi sulle risposte come response.context che altrimenti non sarebbero disponibili. Nota che questo metodo non appronta un database di test, così che quel che segue non sia lanciato sul database esistente e che l’output possa differire in base alle domande già create. Potresti ottenere risultati inattesi se la tua TIME_ZONE in settings.py non è corretta. Se non ti ricordi di averla impostata prima, controlla prima di continuare.

Poi abbiamo bisogno di importare la classe client per il test (più tardi in tests.py useremo la classe django.test.TestCase, che arriva con il suo client, così che questo non sarà necessario):

>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()

Preparato ciò, possiamo chiedere al client di fare del lavoro per noi:

>>> # get a response from '/'
>>> response = client.get('/')
Not Found: /
>>> # we should expect a 404 from that address; if you instead see an
>>> # "Invalid HTTP_HOST header" error and a 400 response, you probably
>>> # omitted the setup_test_environment() call described earlier.
>>> response.status_code
404
>>> # on the other hand we should expect to find something at '/polls/'
>>> # we'll use 'reverse()' rather than a hardcoded URL
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n    <ul>\n    \n        <li><a href="/polls/1/">What&#x27;s up?</a></li>\n    \n    </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

Migliorare la nostra vista

La lista di questionari mostra i questionari che non sono ancora stati pubblicati (cioè quelli la cui pub_date è futura). Mettiamo a posto questa cosa.

Nel Tutorial 4 abbiamo introdotto una view basate su classe, basata su ListView:

polls/views.py
class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        """Return the last five published questions."""
        return Question.objects.order_by('-pub_date')[:5]

Dobbiamo modificare il metodo get_queryset() e cambiarlo in modo che controlli anche la data confrontandola con timezone.now(). Per prima cosa dobbiamo aggiungere un import:

polls/views.py
from django.utils import timezone

e poi dobbiamo correggere il metodo get_queryset in questo modo:

polls/views.py
def get_queryset(self):
    """
    Return the last five published questions (not including those set to be
    published in the future).
    """
    return Question.objects.filter(
        pub_date__lte=timezone.now()
    ).order_by('-pub_date')[:5]

Question.objects.filter(pub_date__lte=timezone.now()) restituisce un queryset contenente le Question la cui pub_date è minore o uguale a - cioè prima o allo stesso momento di - timezone.now.

Testare la nostra nuova vista

Adesso puoi renderti orgoglioso del fatto che questo si comporti come ci si aspetta lanciando runserver, caricando il sito nel browser, creando Questions le cui date sono passate e future e controllando che solo quelle pubblicate appaiano nella lista. Non lo vuoi fare tutte le volte che cambi qualcosa che potrebbe aver ripercussioni su questo - quindi creiamo anche un test, basato sulla nostra sessione shell di prima.

Aggiungere quanto segue a polls/tests.py:

polls/tests.py
from django.urls import reverse

e creeremo una funzione scorciatoia per creare domande così come una nuiova classe di test:

polls/tests.py
def create_question(question_text, days):
    """
    Create a question with the given `question_text` and published the
    given number of `days` offset to now (negative for questions published
    in the past, positive for questions that have yet to be published).
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        """
        If no questions exist, an appropriate message is displayed.
        """
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_past_question(self):
        """
        Questions with a pub_date in the past are displayed on the
        index page.
        """
        question = create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            [question],
        )

    def test_future_question(self):
        """
        Questions with a pub_date in the future aren't displayed on
        the index page.
        """
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        are displayed.
        """
        question = create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            [question],
        )

    def test_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        """
        question1 = create_question(question_text="Past question 1.", days=-30)
        question2 = create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            [question2, question1],
        )

Diamo un occhiata da vicino.

La prima è una funzione scorciatoia, create_question, che astrae alcune ripetizioni nel processo di creazione delle domande.

test_no_questions non crea domande, ma controlla il messaggio: «Non ci sono sondaggi disponibili.» e verifica che latest_question_list sia vuota. Nota che la classe django.test.TestCase offre alcuni metodi di asserzioni aggiuntivi. In questi esempi, usiamo assertContains() e assertQuerysetEqual().

In test_past_question, creiamo una domanda e verifichiamo che appaia nell’elenco.

In test_future_question, creiamo una domanda con una pub_date nel futuro. Il database viene reimpostato per ogni metodo di test, quindi la prima domanda non c’è più e così, di nuovo, l’indice non dovrebbe mostrare nessuna domanda.

E così via. In effetti, stiamo usando i test per raccontare la storia dell’input dell’admin e la user experience sul sito, controllando ad ogni stato e per ogni cambiamento del sito, che i cambiamenti vengano pubblicati.

Testare la DetailView

Quel che abbiamo funziona bene; eppure, anche se le domande future non appaiono nell”indice, gli utenti possono ancora raggiungere se conoscono o indovinano la URL giusta. Quindi dobbiamo aggiungere una restrizione simile a DetailView:

polls/views.py
class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        """
        Excludes any questions that aren't published yet.
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

Dovremmo quindi aggiungere alcuni test, per verificare che una Question la cui pub_date sia nel passato, possa essere visualizzata e quella con pub_date nel futuro non lo sia:

polls/tests.py
class QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        """
        The detail view of a question with a pub_date in the future
        returns a 404 not found.
        """
        future_question = create_question(question_text='Future question.', days=5)
        url = reverse('polls:detail', args=(future_question.id,))
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_past_question(self):
        """
        The detail view of a question with a pub_date in the past
        displays the question's text.
        """
        past_question = create_question(question_text='Past Question.', days=-5)
        url = reverse('polls:detail', args=(past_question.id,))
        response = self.client.get(url)
        self.assertContains(response, past_question.question_text)

Idee per altri test

Dovremmo aggiungere un metodo simile a get_queryset a ResultsView e creare una nuova classe di test per quella view. Sarà molto simile a quello che abbiamo appena creato; in effetti ci saranno un sacco di ripetizioni.

Noi potremmo migliorare la nostra applicazione in altri modi, aggiungendo test. Per esempio, è sciocco che Questions possa essere pubblicato sul sito ma non ha Choices. Le nostre view potrebbero controllare questo scenario, ed escludere questo tipo di Questions. I nostri test potrebbero creare una Question senza Choices e quindi testare che non sia pubblicata, così come potremmo creare una Question simile, ma con Choices e testare che venga pubblicata

Probabilmente gli utenti amministratori loggati dovrebbero poter vedere le Questions non pubblicate, ma non gli utenti normali. Sottolineamo: qualsiasi cosa venga aggiunta al codice per ottenere questo scopo, dovrebbe essere coperto da un test, sia che tu scriva prima il test e poi il codice per superare il test, sia che lavori sulla logica del codice e poi scrivi un test che la copra.

Ad un certo punto sarai obbligato a guardare i tuoi test e chiederti se il tuo codice soffra di eccesso di test , il che ci porta a:

Nei test, di più è meglio

Potrebbe sembrare che i nostri test aumentino senza controllo. A questa velocità ci sarà in breve tempo più codice nei nostri test che nella nostra applicazione, e la ripetizione è antiestetica, in comparazione con l’eleganza della brevità del resto del nostro codice.

** Non importa **. Lascia che crescano. Per la maggior parte puoi scrivere un test una volta e poi dimenticartene. Continuerà a svolgere la sua funzione mentre continui a sviluppare il programma.

A volte i test avranno bisogno di un aggiornamento. Mettiamo caso che cambiano le nostre view in modo che solo Questions con Choices vengano pubblicate. In questo caso, molti dei nostri test falliranno - *dicendoci esattamente quali test bisogna aggiornare, così che l’aggiornamento farà in modo da farli superare al prossimo giro.

Al peggio, dal momento che continui a sviluppare, potrai trovarti ad avere alcuni test che ora sono ridondanti. Nonostante questo, non è un problema; testare la ridondanza è una buona prassi.

Finché i tuoi test sono organizzati in modo ragionevole, non diventeranno ingestibili. Le buone regole pratiche includono avere:

  • Una TestClass separata per ogni model o view
  • un metodo test separato per ogni set di condizioni che tu vuoi testare
  • nomi di metodi di test che descrivono la loro funzione

Altri test

Questo tutorial introduce solo alcune delle basi del testing. C’è molto di più che puoi fare ed una serie di strumenti molto utili sono a tua disposizione per realizzare cose molto ingegnose.

Per esempio, mentre alcuni nostri test qui hanno coperto un po” di logica interna di un modello ed il modo in cui le view pubblicano le informazioni, puoi usare un framework «in-browser» come Selenium per controllare il modo in cui il tuo HTML viene mostrato nel browser. Questi tool inoltre ti permettono di controllare non solo il comportamento del tuo codice Django ma anche, per esempio, del tuo JavaScript. E” veramente fantastico vedere come i test lanciano il browser e ci interagiscono, come se si trattasse di un essere umano! Django include LiveServerTestCase per facilitare l’integrazione con tool come Selenium.

Se hai un’applicazione complessa, potresti voler eseguire i test automaticamente ad ogni commit ai fini dell’integrazione continua, in modo che il controllo di qualità sia esso stesso, almeno parzialmente, automatizzato.

Un buon modo per individuare parti della tua applicazione non testate è controllare la code coverage. Questo aiuta anche a identificare codice morto o fragile. Se tu non riesci a testare un pezzo di codice, di solito significa che quel codice dovrebbe essere rimosso o rielaborato. La Coverage aiuta a identificare codice non più in uso. Vedi Integration with coverage.py per avere dettagli.

Testare in Django contiene informazioni complete sui test.

Cos’altro?

Per i dettagli completi sui test, vedi Testare in Django.

Quando ti senti pronto e preparato nel testare le view Django, leggi parte 6 di questo tutorial per imparare a gestire i file statici.

Back to Top