Γράφοντας το πρώτο σας Django app, μέρος 5

Ο οδηγός αυτός ξεκινά εκεί που τελειώνει ο οδηγός 4. Έχουμε φτιάξει μια εφαρμογή ψηφοφορίας (Web-poll application) και τώρα θα δημιουργήσουμε μερικά αυτοματοποιημένα τεστ για αυτήν.

Εισαγωγή στα αυτοματοποιημένα τεστ

Τι είναι τα αυτοματοποιημένα τεστ;

Τα τεστ (tests) δεν είναι τίποτε άλλο παρά απλές ρουτίνες οι οποίες ελέγχουν τη λειτουργία του κώδικα σας.

Το τεστ δραστηριοποιείται σε διαφορετικά επίπεδα. Μερικά τεστ μπορεί να εφαρμοστούν σε μια μικρή λεπτομέρεια (επιστρέφει μια συγκεκριμένη μέθοδος ενός μοντέλου τις αναμενόμενες τιμές;) ενώ άλλα εξετάζουν την συνολική εικόνα λειτουργίας του λογισμικού (παράγει το επιθυμητό αποτέλεσμα η σειρά των inputs ενός χρήστη στο site;). Αυτό δεν είναι κάτι διαφορετικό από το τεστ που κάνατε νωρίτερα στον Οδηγό 2, χρησιμοποιώντας την εντολή shell για να εξετάσετε τη συμπεριφορά των μεθόδων ή να τρέξετε την εφαρμογή και κατά την αλληλεπίδραση μαζί της, να δείτε πως συμπεριφέρεται.

Αυτό που διαφέρει στα αυτοματοποιημένα τεστ είναι ότι η δουλειά των τεστ γίνεται για σας από το σύστημα. Δημιουργείτε μια φορά ένα σετ από τεστ και μετά καθώς η εφαρμογή σας αλλάζει, μπορείτε να ελέγξετε ότι ο κώδικας σας δουλεύει όπως αρχικά είχατε σχεδιάσει χωρίς να χρονοτριβείτε με χειροκίνητα τεστ.

Γιατί χρειάζεται να δημιουργείτε τεστ

Γιατί, λοιπόν, δημιουργούμε τεστ και γιατί τώρα;

Ίσως να αισθάνεστε ότι έχετε αρκετά στο κεφάλι σας ήδη (μαθαίνετε Python, μαθαίνετε Django) για να σας προστεθεί άλλη μια γνώση η οποία, σκέφτεστε, ίσως σας φανεί περιττή ή ακόμα και άχρηστη. Παρ’ όλ’ αυτά η εφαρμογή μας λειτουργεί περίφημα. Γιατί να μπούμε στον κόπο να φτιάξουμε αυτοματοποιημένα τεστ; Δεν θα κάνει την εφαρμογή μας καλύτερη, έτσι δεν είναι; Αν είναι να φτιάξετε μόνο αυτή την εφαρμογή με το Django και μετά δεν ασχοληθείτε άλλο, τότε ναι, δεν χρειάζεται να γνωρίζετε πως να φτιάξετε αυτοματοποιημένα τεστ. Αλλά αν δεν είναι έτσι, τώρα είναι μια καλή στιγμή να μάθετε πως.

Τα τεστ θα σας γλιτώσουν πολύτιμο χρόνο

Μέχρι ένα σημείο η λογική του “τεστάρω για να δω αν λειτουργεί” θα δουλεύει μια χαρά. Σε πιο εκλεπτυσμένες-περίπλοκες εφαρμογές μπορεί να έχετε δεκάδες, αν όχι εκατοντάδες, αλληλεπιδράσεις μεταξύ των οντοτήτων σας (models, views κλπ) και η παραπάνω λογική να μην μπορεί πλέον να σας φανεί χρήσιμη.

Μια αλλαγή σε μια από αυτές τις οντότητες μπορεί να έχει μη αναμενόμενα αποτελέσματα στη συμπεριφορά της εφαρμογής σας. Αν εφαρμόσετε την παραπάνω λογική του “φαίνεται να δουλεύει” ίσως χρειαστεί να ελέγξετε τον (και να ανατρέξετε στον) κώδικα σας με πάνω από είκοσι διαφορετικές εκδόσεις των τεστ δεδομένων σας, μόνο και μόνο για να σιγουρευτείτε ότι αυτή και μόνο η αλλαγή δεν επηρέασε αρνητικά κάποιο άλλο κομμάτι του κώδικα σας (κάποια άλλη οντότητα). Όλα αυτά χειροκίνητα - όχι και πολύ καλή αξιοποίηση του χρόνου σας.

Αυτό είναι αλήθεια όταν τα αυτοματοποιημένα τεστ μπορούν να κάνουν την παραπάνω δουλειά για εσάς, σε δευτερόλεπτα. Αν κάτι πάει στραβά, τα τεστ θα σας βοηθήσουν στην αναγνώριση του κώδικα που δεν είχε την αναμενόμενη συμπεριφορά (δεν έτρεξε, δηλαδή, όπως του είχατε υπαγορεύσει).

Καμιά φορά ίσως σας φανεί ως αγγαρεία να ξεφύγετε από το παραγωγικό και δημιουργικό κομμάτι της δουλειάς σας μόνο και μόνο για να μπείτε στη διαδικασία γραφής των ανιαρών τεστ, ειδικά όταν γνωρίζετε ότι η εφαρμογή σας λειτουργεί ορθά.

Από την άλλη, το κομμάτι της συγγραφής των τεστ θα προσθέσει μεγαλύτερη αξιοπιστία στον κώδικα σας από το να τεστάρετε τον κώδικα χειροκίνητα ή να προσπαθείτε να αναγνωρίσετε τη πηγή ενός νεοεισερχόμενου προβλήματος.

Τα τεστ όχι μόνο αναγνωρίζουν τα προβλήματα αλλά τα εμποδίζουν επίσης

Είναι λάθος να νομίζετε ότι τα τεστ συμβάλλουν αρνητικά στην ανάπτυξη των εφαρμογών.

Φανταστείτε τους προγραμματιστές να μην είχαν στα εργαλεία τους τα τεστ. Χωρίς αυτά, ο στόχος ή η αναμενόμενη συμπεριφορά μιας εφαρμογής θα ήταν θολή-σκοτεινή. Ακόμα και αν είναι δικός σας κώδικας, θα χρειαστεί, μερικές φορές, να ψάξετε μέσα στον κώδικα για να διαπιστώσετε τι κάνει.

Τα τεστ τα αλλάζουν όλα αυτά. Φωτίζουν τον κώδικα σας από μέσα και όταν κάτι πάει στραβά, ξέρετε ακριβώς ποιο ήταν αυτό το κομμάτι, ακόμη και αν δεν είχατε συνειδητοποιήσει ότι αυτό το κομμάτι πήγε στραβά.

Τα τεστ κάνουν τον κώδικα σας πιο προσιτό

Μπορεί να έχετε δημιουργήσει ένα λογισμικό διαμάντι αλλά άλλοι προγραμματιστές αρνούνται να ρίξουν μια ματιά σε αυτό μόνο και μόνο επειδή στερείται τεστ. Χωρίς τεστ, δεν θα τον εμπιστευτούν. Ο Jacob Kaplan-Moss, ένας από τους αρχικούς προγραμματιστές του Django, λέει «Code without tests is broken by design.»

Προκειμένου άλλοι προγραμματιστές (developers) να δουν σοβαρά το δικό σας software, θα πρέπει να έχετε υλοποιήσει (γράψει) τα απαραίτητα τεστ.

Τα τεστ βοηθούν τις ομάδες να συνεργαστούν

Οι προηγούμενες παράγραφοι γράφτηκαν από την σκοπιά ενός και μόνου developer ο οποίος διατηρεί ένα application. Οι περίπλοκες εφαρμογγές, όμως, θα διατηρούνται από ομάδες. Τα τεστ, εγγυώνται ότι οι συμμετέχοντες στην ομάδα δεν θα χαλάσουν (break) τον κώδικα σας ακούσια αλλά ούτε και εσείς θα χαλάσετε τον δικό του, ακούσια πάλι. Αν θέλετε να βγάλετε τα προς το ζην όντας ένας Django programmer, θα πρέπει να είστε καλός στη συγγραφή των τεστ!

Βασικές στρατηγικές τεστ

Υπάρχουν πολλοί τρόποι για να προσεγγίσει κανείς τη συγγραφή των τεστ.

Κάποιοι προγραμματιστές ακολουθούν μια διαδικασία με το όνομα «test-driven development». Ξεκινούν να γράφουν τα τεστ προτού γράψουν τον κώδικα τους. Αυτό μπορεί να ακούγεται αντί-διαισθητικό αλλά στην πραγματικότητα οι περισσότεροι ακολουθούν αυτή τη μέθοδο: αρχικά περιγράφουν το πρόβλημα και κατόπιν γράφουν κώδικα για να το λύσουν. Η λογική της διαδικασίας test-driven development μπορεί να μεταφερθεί πανεύκολα στα test cases της Python.

Πιο συχνά, όσοι είναι νέοι στο χώρο των τεστ θα γράψουν πρώτα τον κώδικα και μετά θα αποφασίσουν ότι θα έπρεπε να είχαν γράψει μερικά τεστ. Ίσως θα ήταν καλύτερα να είχαν γραφτεί τα τεστ νωρίτερα, αλλά ποτέ δεν είναι αργά για να ξεκινήσει κανείς.

Μερικές φορές δεν ξέρεις πως να ξεκινήσεις να γράφεις τεστ. Αν έχετε γράψει μερικές χιλιάδες γραμμές Python κώδικα, είναι δύσκολο μετά να επιλέξεις κάτι για να γράψεις τεστ πάνω σε αυτό. Σε τέτοιες περιπτώσεις είναι πιο παραγωγικό να γράψετε το πρώτο σας τεστ κάθε φορά που κάνετε μια αλλαγή, είτε προσθέτετε κάποιο καινούργιο feature είτε διορθώνετε ένα bug.

Ας ξεκινήσουμε λοιπόν, αμέσως τώρα.

Γράφοντας το πρώτο σας τεστ

Βρίσκουμε ένα bug

Ευτυχώς, υπάρχει ένα μικρό bug στην εφαρμογή μας (polls) και αξίζει να το διορθώσουμε ευθύς αμέσως: η μέθοδος Question.was_published_recently() επιστρέφει True αν η ερώτηση (Question) εκδόθηκε εντός εικοσιτεσσάρων ωρών (το οποίο είναι σωστό). Η ίδια μέθοδος επιστρέφει πάλι True αν το pub_date τοποθετείται στο μέλλον (κάτι το οποίο δεν μπορεί να ισχύει).

Για να τεστάρουμε ότι αυτό το bug υπάρχει πραγματικά, χρησιμοποιήστε το Admin interface και δημιουργήστε μια ερώτηση της οποίας η ημερομηνία είναι στο μέλλον. Κατόπιν τρέξτε την εντολή shell και καλέστε την μέθοδο αυτή για να δείτε τι επιστρέφει (εδώ δεν χρησιμοποιούμε το admin site αλλά το 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

Εφόσον τα πράγματα στο μέλλον δε θεωρούνται “πρόσφατα”, τότε αυτό είναι ξεκάθαρα λάθος.

Δημιουργία ενός τεστ για την ανάδειξη του bug

Ότι ακριβώς κάναμε στο shell για να τεστάρουμε το πρόβλημα, θα το κάνουμε στο αυτοματοποιημένο τεστ που θα γράψουμε αμέσως τώρα.

Ένα βολικό μέρος για να κρατάτε όλα τα τεστ για την εφαρμογή σας είναι σε ένα ξεχωριστό αρχείο με το όνομα tests.py. Το σύστημα (testing system) θα βρει αυτόματα όλα τα τεστ, αρκεί καθένα από αυτά να ξεκινούν με τη λέξη test.

Γράψτε τον ακόλουθο κώδικα στο αρχείο tests.py μέσα στην εφαρμογή polls:

polls/tests.py
import datetime

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

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)

Αυτό που κάναμε ήταν να δημιουργήσουμε μια django.test.TestCase subclass η οποία περιέχει μία μέθοδο που δημιουργεί ένα Question instance με μια μελλοντική κατά τριάντα μέρες ημερομηνία (pub_date). Ύστερα, ελέγχουμε την έξοδο της μεθόδου was_published_recently() - η οποία οφείλει να είναι False.

Τρέχοντας τα τεστ

Σε ένα τερματικό (κονσόλα ή γραμμή εντολών αν προτιμάτε) μπορούμε να τρέξουμε τα τεστ ως εξής:

$ python manage.py test polls

και θα δείτε κάτι σαν αυτό:

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

Αυτά που συνέβησαν είναι τα εξής:

  • Η εντολή python manage.py test polls κοίταξε για τεστ μέσα στην εφαρμογή polls
  • βρήκε μια subclass της κλάσης django.test.TestCase
  • δημιούργησε μια ειδική database για τους σκοπούς του τεστ
  • έψαξε για τεστ μεθόδους - εκείνες των οποίων το όνομα ξεκινά με τη λέξη test
  • μέσα στην μέθοδο test_was_published_recently_with_future_question δημιούργησε ένα Question instance του οποίου το pub_date field είναι 30 μέρες στο μέλλον
  • … και χρησιμοποιώντας τη μέθοδο assertIs(), διαπίστωσε ότι η μέθοδος was_published_recently() επιστρέφει True, αντί για False

Το τεστ, μας ενημερώνει ποιο τεστ απέτυχε (τώρα έτυχε να έχουμε μονάχα ένα αλλά στο μέλλον δεν θα έχετε μόνο ένα) και ακόμη και την γραμμή του κώδικα στο τεστ όπου προέκυψε το failure.

Διορθώνοντας το bug

Γνωρίζουμε ήδη ποιο και που είναι το πρόβλημα: η μέθοδος Question.was_published_recently() θα πρέπει να επιστρέψει False αν το pub_date τοποθετηθεί στο μέλλον. Βελτιώστε τη μέθοδο μέσα στο αρχείο models.py, ούτως ώστε να επιστρέφει True μόνο για παρελθοντικές ημερομηνίες:

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

και τρέξτε τα τεστ ξανά:

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

Αφού βρήκαμε ένα bug, γράψαμε ένα τεστ το οποίο το αναδεικνύει και τέλος διορθώσαμε το bug στο κώδικα ούτως ώστε το τεστ να θεωρηθεί επιτυχές.

Υπάρχει πιθανότητα πολλά άλλα πράγματα να πάνε στραβά με την εφαρμογή μας στο μέλλον αλλά μπορούμε να είμαστε σίγουροι ότι αυτό το συγκεκριμένο bug δεν θα ξαναεμφανιστεί στον κώδικα μας, αφού όταν τρέξουμε την σουίτα των τεστ του Django θα μας ειδοποιήσει αμέσως. Με άλλα λόγια έχουμε κάνει την εφαρμογή μας πιο ασφαλή-σταθερή κατά ένα βαθμό.

Περισσότερα περιεκτικά τεστ

Όσο είμαστε εδώ μπορούμε να βελτιστοποιήσουμε την μέθοδο was_published_recently() λιγάκι περισσότερο. Θα ήταν ντροπιαστικό, αν προσπαθώντας να διορθώσουμε ένα bug να έχουμε δημιουργήσει κάπου αλλού ένα άλλο (άθελα μας).

Προσθέστε δύο ακόμη μεθόδους τεστ στην ίδια κλάση, για να τεστάρουμε την συμπεριφορά της μεθόδου πιο περιεκτικά:

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)

Τώρα έχουμε τρία τεστ, τα οποία επιβεβαιώνουν ότι η μέθοδος Question.was_published_recently() επιστρέφει λογικές τιμές για παρελθοντικές, παρούσες και μελλοντικές τιμές του pub_date.

Υπενθυμίζουμε ότι η εφαρμογή μας, polls είναι μια απλή εφαρμογή. Ωστόσο, στο μέλλον, όσο περίπλοκη και να γίνει και ο οποιοσδήποτε άλλος κώδικας (ίσως από άλλη δική μας εφαρμογή) αλληλεπιδράσει με την εφαρμογή αυτή, μπορούμε να εγγυηθούμε ότι η μέθοδος στην οποία έχουμε γράψει τα τεστ θα συμπεριφέρεται σε αναμενόμενα πλαίσια.

Τεστ ένα view

Η εφαρμογή μας, polls, δεν κάνει διακρίσεις! Θα κάνει publish οποιαδήποτε ερώτηση, συμπεριλαμβανομένων και εκείνων που το pub_date field βρίσκεται στο μέλλον. Θα πρέπει να το διορθώσουμε αυτό. Θέτοντας το pub_date στο μέλλον θα σημαίνει ότι η ερώτηση θα πρέπει να γίνει publish εκείνη την ημερομηνία αλλά θα είναι αόρατη μέχρι τότε.

Ένα τεστ για τη view

Όταν διορθώσαμε το bug πριν, γράψαμε πρώτα το τεστ και μετά τον κώδικα για να διορθώσουμε το bug. Στην πραγματικότητα αυτό ήταν ένα απλό παράδειγμα του test-driven development, αλλά δεν έχει σημασία η σειρά που ακολουθείται για να γίνει η δουλειά.

Στο πρώτο μας τεστ επικεντρωθήκαμε στην εσωτερική συμπεριφορά του κώδικα μας (στη μέθοδο του μοντέλου). Για αυτό το τεστ, θέλουμε να ελέγξουμε την συμπεριφορά του όπως θα τη βίωνε ο χρήστης μέσα από τον browser του.

Προτού προσπαθήσουμε να φτιάξουμε το οτιδήποτε, ας ρίξουμε μια ματιά στα εργαλεία που έχουμε διαθέσιμα.

Τα τεστ του Django από την πλευρά του client

Το Django παρέχει το τεστ Client για να εξομοιώνει τον χρήστη που αλληλεπιδρά με τον κώδικα σε επίπεδο view. Μπορούμε να το χρησιμοποιήσουμε στο αρχείο tests.py ή ακόμη και στο shell.

Θα ξεκινήσουμε ξανά με το shell, όπου θα κάνουμε ορισμένα πράγματα τα οποία δεν είναι απαραίτητα να γίνουν στο tests.py. Το πρώτο πράγμα είναι ρυθμίσουμε το περιβάλλον για τα τεστ στο shell:

>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

Η μέθοδος setup_test_environment() εγκαθιστά έναν template renderer ο οποίος μας επιτρέπει να ερευνήσουμε μερικά πρόσθετα attributes στα responses όπως το response.context το οποίο δεν θα ήταν διαθέσιμο αλλιώς. Σημειώστε ότι αυτή η μέθοδος δεν φτιάχνει καμία βάση δεδομένων για τεστ, κάτι το οποίο σημαίνει ότι θα τρέξει χρησιμοποιώντας την ήδη υπάρχουσα database. Επίσης σημειώστε ότι η έξοδος ίσως να είναι διαφορετική για εσάς ανάλογα τις ερωτήσεις που έχετε δημιουργήσει. Ίσως να λάβετε μη αναμενόμενα αποτελέσματα αν η ρύθμιση TIME_ZONE, μέσα στο γενικό αρχείο ρυθμίσεων settings.py, δεν είναι σωστή. Αν δεν την έχετε ρυθμίσει, κάντε το προτού προχωρήσετε.

Επόμενο βήμα είναι να κάνουμε import την τεστ κλάση για τον client (αργότερα στο αρχείο tests.py θα χρησιμοποιήσουμε την κλάση django.test.TestCase, η οποία έρχεται με δικό της client, οπότε αυτό δεν θα είναι απαραίτητο):

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

Με αυτό έτοιμο, μπορούμε να βάλουμε τον client να εργαστεί για εμάς:

>>> # 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&#39;s up?</a></li>\n    \n    </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

Βελτιώνοντας το view

Η λίστα με τις ψηφοφορίες εμφανίζει ψηφοφορίες οι οποίες δεν έχουν γίνει ακόμη published (δηλαδή αυτές που το pub_date έχει μελλοντική ημερομηνία). Ας το διορθώσουμε.

Στον Οδηγό 4 εισάγαμε την έννοια του class-based view, βασισμένο στη 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]

Θα χρειαστεί να βελτιώσουμε τη μέθοδο get_queryset() και να την αλλάξουμε ούτως ώστε να ελέγχει και την ημερομηνία συγκρίνοντας τη με το timezone.now(). Πρώτα θα χρειαστεί να προσθέσουμε ένα import:

polls/views.py
from django.utils import timezone

και μετά πρέπει να βελτιώσουμε την μέθοδο get_queryset ως εξής:

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()) επιστρέφει ένα queryset (συμπεριφέρεται ως μια Python λίστα) το οποίο περιέχει Questions των οποίων το pub_date είναι μικρότερο ή ίσο (επειδή στην προκειμένη επρόκειτο για ημερομηνίες, ίσως είναι καλύτερα να πούμε νωρίτερο ή ίσο) από το timezone.now (από το τώρα).

Τεστάροντας το καινούργιο view

Τώρα μπορείτε να είστε σίγουροι ότι η εφαρμογή δουλεύει όπως αναμένετε. Ξεκινήστε τον server (python manage.py runserver), επισκεφτείτε το site σας, δημιουργήστε μερικά Questions των οποίων η ημερομηνία να είναι στο μέλλον και στο παρελθόν και τεστάρετε ότι μόνο αυτές που είναι published έχουν εμφανιστεί. Δεν χρειάζεται, όμως, να κάνετε αυτή τη διαδικασία κάθε φορά που κάνετε μια αλλαγή η οποία μπορεί να επηρεάσει αυτή τη λειτουργία. Επομένως, ας δημιουργήσουμε ένα τεστ βασισμένο στο παραπάνω shell session.

Προσθέστε τα ακόλουθα στο αρχείο polls/tests.py:

polls/tests.py
from django.urls import reverse

Θα δημιουργήσουμε μια συνάρτηση συντόμευσης για να φτιάχνουμε ερωτήσεις και θα δημιουργήσουμε μια νέα test class:

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.
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past 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.
        """
        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: Past question.>']
        )

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

Ας κοιτάξουμε μερικά από τα παραπάνω τεστ πιο προσεκτικά.

Πρώτα, φτιάξαμε μια συνάρτηση (create_question) την οποία τη χρησιμοποιούμε ως συντόμευση για να δημιουργούμε ερωτήσεις.

test_no_questions doesn’t create any questions, but checks the message: «No polls are available.» and verifies the latest_question_list is empty. Note that the django.test.TestCase class provides some additional assertion methods. In these examples, we use assertContains() and assertQuerysetEqual().

In test_past_question, we create a question and verify that it appears in the list.

In test_future_question, we create a question with a pub_date in the future. The database is reset for each test method, so the first question is no longer there, and so again the index shouldn’t have any questions in it.

Και τα τεστ συνεχίζονται. Στην πραγματικότητα χρησιμοποιούμε τα τεστ για να εξομοιώσουμε τη συμπεριφορά του admin interface καθώς και των υπόλοιπων χρηστών που αλληλεπιδρούν με το site μέσω του browser. Κάθε φορά που αλλάζει το state του συστήματος μας (της εφαρμογής μας αν θέλετε) εξετάζουμε την έξοδο του συστήματος και αν αυτή ανταποκρίνεται στα αποτελέσματα που αναμένουμε.

Τεστάροντας το DetailView

Ότι έχουμε μέχρι τώρα λειτουργεί όπως θα θέλαμε να λειτουργεί. Ωστόσο, παρόλο που οι μελλοντικές ερωτήσεις δεν φαίνονται στην αρχική σελίδα της εφαρμογής μας (index), αν οι χρήστες μπορέσουν να μαντέψουν το σωστό URL που οδηγεί σε μια τέτοια ερώτηση (και δεν είναι κάτι δύσκολο αφού χρησιμοποιούμε IDs), τότε θα μπορέσουν να τη δουν. Άρα θα πρέπει να προσθέσουμε παρόμοιους περιορισμούς στο 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())

Και φυσικά θα χρειαστεί να προσθέσουμε μερικά τεστ για να ελέγξουμε ότι η ερώτηση (Question) της οποίας το pub_date είναι στο παρελθόν εμφανίζεται ενώ αυτής που είναι στο μέλλον όχι:

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)

Ιδέες για περισσότερα τεστ

Χρωστάμε να προσθέσουμε μια παρόμοια μέθοδο get_queryset στο ResultsView και να δημιουργήσουμε μια νέα test class για αυτό το view. Θα είναι παρόμοια με αυτή που μόλις δημιουργήσαμε (DetailView). Στη πραγματικότητα θα είναι μια επανάληψη.

Θα μπορούσαμε επίσης να βελτιώσουμε την εφαρμογή μας και με άλλους τρόπους προσθέτοντας τεστ καθώς προχωράμε. Για παράδειγμα είναι λιγάκι ανόητο να μπορούν να γίνονται published οι ερωτήσεις (Questions) δίχως κάποιες απαντήσεις (Choices). Οπότε, τα views θα μπορούσα να ελέγξουν γι’ αυτό και να εξαιρέσουν (exclude) τέτοιες ερωτήσεις. Τα τεστ μας θα δημιουργούσαν μια ερώτηση χωρίς Choices και μετά θα έλεγχαν ότι αυτή η ερώτηση δεν θα γινόταν published. Ομοίως θα δημιουργούσαμε μια ερώτηση (σε άλλο τεστ) με Choices αυτή τη φορά και ελέγχαμε ότι αυτή η ερώτηση θα γινόταν published.

Ίσως οι logged-in admin χρήστες να επιτρεπόταν να δουν τις unpublished ερωτήσεις, αλλά όχι οι συνηθισμένοι επισκέπτες. Για ακόμη μια φορά: οτιδήποτε χρειάζεται να προστεθεί στο software για να επιτευχθεί ο στόχος σας θα πρέπει να συνοδεύεται από ένα τεστ, είτε γράψετε το test πρώτο και μετά τον κώδικα για να κάνετε το τεστ να επιτύχει είτε γράψετε τον κώδικα πρώτα και μετά το τεστ για να αποδείξετε ότι ο κώδικας σας λειτουργεί.

Θα φτάσετε σε ένα σημείο όπου θα κοιτάζετε τα τεστ σας και θα αναρωτιέστε αν ο κώδικας σας υποφέρει από υπερβολικό αριθμό τεστ, κάτι το οποίο μας φέρνει στο εξής:

Όταν τεστάρετε, το περισσότερο είναι καλύτερο

Ίσως να φαίνεται ότι τα τεστ αρχίζουν και γίνονται τεράστια καθώς και μη ελέγξιμα. Σύντομα θα διαπιστώσετε ότι υπάρχει περισσότερος κώδικας στα τεστ παρά στην εφαρμογή την ίδια και ότι η επανάληψη είναι αντιαισθητική εν συγκρίσει με την κομψή περιεκτικότητα του υπόλοιπου κώδικα σας.

Δεν πειράζει. Αφήστε το να μεγαλώσει. Τις πιο πολλές φορές θα γράψετε ένα τεστ και μετά θα το ξεχάσετε. Εκείνο όμως θα κάνει τη δουλειά του όσο εσείς συνεχίζετε με την ανάπτυξη της εφαρμογής σας (και γενικά όλου του project σας).

Μερικές φορές τα τεστ θα χρειαστεί να αναβαθμιστούν. Υποθέστε ότι βελτιώνουμε τα views ούτως ώστε μόνο οι ερωτήσεις (Questions) που έχουν απαντήσεις (Choices) θα γίνουν published. Σε αυτή την περίπτωση πολλά από τα τεστ μας θα αποτύχουν - λέγοντας μας ακριβώς ποια τεστ θα χρειαστεί να βελτιώσουμε για να είναι συμβατά με την εφαρμογή μας. Οπότε όσον αφορά την ανανέωση των τεστ, μην ανησυχείτε, τα τεστ φροντίζουν μόνα τους για τους εαυτούς τους.

Στη χειρότερη, καθώς αναπτύσσετε την εφαρμογή σας, ίσως βρεθείτε σε μια θέση όπου μερικά τεστ είναι πλέον περιττά. Και πάλι μην ανησυχείτε. Στον κόσμο του τεστ ο πλεονασμός (redundancy) είναι καλό στοιχείο.

Όσο τα τεστ σας είναι λογικά οργανωμένα δεν θα γίνουν αδιαχείριστα. Μερικοί κανόνες όσον αφορά τα τεστ είναι:

  • μια ξεχωριστή TestClass για κάθε μοντέλο ή view
  • μια ξεχωριστή μέθοδο τεστ για κάθε κατάσταση που θέλετε να τεστάρετε
  • να ονομάζετε τις μεθόδους τεστ όσο πιο περιεκτικά γίνεται σχετικά με αυτό που προσπαθούν να τεστάρουν

Επιεπλέον τεστ

Αυτό ο οδηγός έκανε μια εισαγωγή στον κόσμο των τεστ. Υπάρχουν πολλά περισσότερα που μπορείτε να κάνετε όπως επίσης και περισσότερα εργαλεία που μπορείτε να χρησιμοποιήσετε για να φτιάξετε πολύ έξυπνα τεστ.

Για παράδειγμα, παρόλο που τα τεστ σε αυτό τον οδηγό κάλυψαν ένα κομμάτι από την εσωτερική λειτουργία του κώδικα μας (μέθοδος μοντέλου) και άλλο ένα από τον τρόπο που τα views κάνουν publish τις ερωτήσεις μας, μπορείτε να χρησιμοποιήσετε ένα «in-browser» framework όπως το Selenium για να τεστάρετε τον τρόπο με τον οποίο η HTML γίνεται render στον browser. Αυτά τα εργαλεία δεν σας επιτρέπουν να ελέγξετε μόνο τη συμπεριφορά του Django κώδικα σας αλλά και του κώδικα π.χ της Javascript (που πολύ πιθανόν να έχετε). Είναι αρκετά συναρπαστικό να μπορείτε να βλέπετε τα τεστ σας να ανοίγουν τον browser σας και να αλληλεπιδρούν με το site ακριβώς όπως ένας άνθρωπος θα το έκανε! Το Django περιλαμβάνει την κλάση LiveServerTestCase για να διευκολύνει την ενσωμάτωση (συνεργασία) με εργαλεία όπως το Selenium.

Αν έχετε μια περίπλοκη εφαρμογή ίσως να θέλετε να τρέχουν τα τεστ, αυτόματα, κάθε φορά που κάνετε commit για λόγους του continuous integration, ούτως ώστε το ίδιο το quality control (έλεγχος ποιότητας) - να είναι τουλάχιστον μερικώς - αυτοματοποιημένο.

Ένας καλός τρόπος για να εντοπίσετε τα μέρη της εφαρμογής σας που δεν έχουν γραφτεί τεστ για αυτά, είναι να ελέγξετε το κατά πόσο είναι καλυμμένος ο κώδικας σας με τεστ. Αυτό επίσης σας βοηθά στο να αναγνωρίσετε αδύναμο ή ακόμη και αχρησιμοποίητο-νεκρό κώδικα. Αν δεν μπορείτε να τεστάρετε ένα κομμάτι κώδικα, τότε αυτό ίσως σημαίνει ότι ο κώδικας θα πρέπει να ξαναγραφτεί ή να αφαιρεθεί τελείως. Το Coverage θα σας βοηθήσει (και είναι πολύ σημαντικό αυτό) να αναγνωρίσετε τον νεκρό κώδικα. Δείτε στην αναφορά Integration with coverage.py για περισσότερες λεπτομέρειες.

Επίσης το άρθρο Testing με το Django περιέχει περισσότερες πληροφορίες σχετικά με το testing.

Επόμενα βήματα

Για περισσότερες πληροφορίες σχετικά με τα τεστ δείτε στο άρθρο Testing με το Django.

Όταν είστε εξοικειωμένοι με το concept και τη λειτουργία των τεστ στο Django, διαβάστε το έκτο μέρος αυτού του οδηγού για να μάθετε περισσότερα σχετικά με τη διαχείριση των στατικών αρχείων (static files).

Back to Top