Skriva din första Django-app, del 5¶
Denna handledning börjar där Tutorial 4 slutade. Vi har byggt en applikation för webbenkäter och nu ska vi skapa några automatiserade tester för den.
Var du kan få hjälp:
Om du har problem med att gå igenom den här handledningen kan du gå till avsnittet Att få hjälp i FAQ.
Introduktion av automatiserad testning¶
Vad är automatiserade tester?¶
Tester är rutiner som kontrollerar att din kod fungerar som den ska.
Testning sker på olika nivåer. Vissa tester kan gälla en liten detalj (återför en viss modellmetod värden som förväntat?) medan andra undersöker programvarans övergripande funktion (ger en sekvens av användarinmatningar på webbplatsen önskat resultat?). Det skiljer sig inte från den typ av testning du gjorde tidigare i Tutorial 2, där du använde shell
för att undersöka hur en metod fungerar, eller körde programmet och matade in data för att kontrollera hur det fungerar.
Det som är annorlunda med automatiserade tester är att testarbetet görs åt dig av systemet. Du skapar en uppsättning tester en gång, och när du sedan gör ändringar i din app kan du kontrollera att din kod fortfarande fungerar som du ursprungligen avsåg, utan att behöva utföra tidskrävande manuell testning.
Varför du behöver skapa tester¶
Så varför skapa tester, och varför just nu?
Du kanske tycker att du har tillräckligt att göra bara med att lära dig Python/Django, och att ha ännu en sak att lära sig och göra kan verka överväldigande och kanske onödigt. När allt kommer omkring fungerar vår pollsapplikation ganska lyckligt nu; att gå igenom besväret med att skapa automatiserade tester kommer inte att få det att fungera bättre. Om skapandet av omröstningsapplikationen är den sista delen av Django-programmering som du någonsin kommer att göra, då är det sant att du inte behöver veta hur man skapar automatiserade tester. Men om så inte är fallet är det nu en utmärkt tid att lära sig.
Tester sparar tid¶
Upp till en viss punkt är det tillräckligt att ”kontrollera att det verkar fungera”. I en mer sofistikerad applikation kan du ha dussintals komplexa interaktioner mellan komponenter.
En förändring i någon av dessa komponenter kan få oväntade konsekvenser för applikationens beteende. Att kontrollera att det fortfarande ”verkar fungera” kan innebära att du kör igenom din kods funktionalitet med tjugo olika variationer av dina testdata för att se till att du inte har brutit något - inte en bra användning av din tid.
Det är särskilt sant när automatiserade tester kan göra detta åt dig på några sekunder. Om något har gått fel hjälper testerna också till att identifiera koden som orsakar det oväntade beteendet.
Ibland kan det kännas jobbigt att slita sig från det produktiva och kreativa programmeringsarbetet för att ägna sig åt det oglamorösa och ospännande arbetet med att skriva tester, särskilt när man vet att koden fungerar som den ska.
Att skriva tester är dock mycket mer tillfredsställande än att spendera timmar på att testa din applikation manuellt eller att försöka identifiera orsaken till ett nytillkommet problem.
Tester identifierar inte bara problem, de förebygger dem¶
Det är ett misstag att bara se tester som en negativ aspekt av utveckling.
Utan tester kan syftet med eller det avsedda beteendet hos en applikation vara ganska oklart. Även om det är din egen kod kommer du ibland att behöva rota runt i den för att försöka ta reda på exakt vad den gör.
Tester ändrar på det; de lyser upp din kod från insidan, och när något går fel fokuserar de ljuset på den del som har gått fel - även om du inte ens hade insett att det hade gått fel.
Tester gör din kod mer attraktiv¶
Du kanske har skapat en briljant programvara, men du kommer att upptäcka att många andra utvecklare vägrar att titta på den eftersom den saknar tester; utan tester litar de inte på den. Jacob Kaplan-Moss, en av Djangos ursprungliga utvecklare, säger ”Code without tests is broken by design”
Att andra utvecklare vill se tester i din programvara innan de tar den på allvar är ytterligare ett skäl för dig att börja skriva tester.
Tester hjälper team att arbeta tillsammans¶
De föregående punkterna är skrivna ur en enskild utvecklares synvinkel som underhåller en applikation. Komplexa applikationer kommer att underhållas av team. Tester garanterar att kollegor inte oavsiktligt bryter din kod (och att du inte bryter deras utan att veta om det). Om du vill kunna försörja dig som Django-programmerare måste du vara bra på att skriva tester!
Grundläggande teststrategier¶
Det finns många olika sätt att skriva tester på.
Vissa programmerare följer en disciplin som kallas ”testdriven utveckling”; de skriver faktiskt sina tester innan de skriver sin kod. Detta kan verka kontraintuitivt, men i själva verket liknar det vad de flesta människor ofta kommer att göra ändå: de beskriver ett problem och skapar sedan lite kod för att lösa det. Testdriven utveckling formaliserar problemet i ett Python-testfall.
Oftare skapar en nybörjare lite kod och bestämmer sig senare för att den borde ha några tester. Kanske hade det varit bättre att skriva några tester tidigare, men det är aldrig för sent att komma igång.
Ibland är det svårt att komma på var man ska börja skriva tester. Om du har skrivit flera tusen rader Python är det kanske inte så lätt att välja något att testa. I ett sådant fall är det fruktbart att skriva ditt första test nästa gång du gör en förändring, antingen när du lägger till en ny funktion eller fixar en bugg.
Så låt oss göra det på en gång.
Skriva vårt första test¶
Vi identifierar ett fel¶
Lyckligtvis finns det en liten bugg i applikationen polls
som vi kan åtgärda direkt: metoden Question.was_published_recently()
returnerar True
om Question
publicerades under den senaste dagen (vilket är korrekt) men också om Question
fältet pub_date
ligger i framtiden (vilket det verkligen inte gör).
Bekräfta felet genom att använda shell
för att kontrollera metoden på en fråga vars datum ligger i framtiden:
$ python manage.py shell
...\> py manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> # 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
Eftersom saker i framtiden inte är ”nyligen inträffade” är detta helt klart fel.
Skapa ett test för att avslöja felet¶
Det vi just har gjort i shell
för att testa problemet är exakt vad vi kan göra i ett automatiserat test, så låt oss göra det till ett automatiserat test.
En konventionell plats för en applikations tester är i applikationens fil tests.py
; testsystemet hittar automatiskt tester i alla filer vars namn börjar med test
.
Skriv följande i filen tests.py
i applikationen 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)
Här har vi skapat en django.test.TestCase
-underklass med en metod som skapar en Question
-instans med ett pub_date
i framtiden. Vi kontrollerar sedan resultatet av was_published_recently()
- som bör vara False.
Genomföra tester¶
I terminalen kan vi köra vårt test:
$ python manage.py test polls
...\> py manage.py test polls
och du kommer att se något liknande:
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/djangotutorial/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'...
Annat fel?
Om du istället får ett NameError
här, kan du ha missat ett steg i :ref:Part 2 <tutorial02-import-timezone>
där vi lade till import av datetime
och timezone
till polls/models.py
. Kopiera importerna från det avsnittet och försök köra dina tester igen.
Det som hände var följande:
manage.py test polls
letade efter tester i applikationenpolls
den hittade en underklass av
django.test.TestCase
-klassenskapade en särskild databas i syfte att testa
den letade efter testmetoder - sådana vars namn börjar med
test
i
test_was_published_recently_with_future_question
skapades enQuestion
instans varspub_date
fält är 30 dagar i framtiden… och med hjälp av metoden
assertIs()
upptäckte den att desswas_published_recently()
returnerarTrue
, även om vi ville att den skulle returneraFalse
Testet informerar oss om vilket test som misslyckades och till och med på vilken linje felet inträffade.
Åtgärda felet¶
Vi vet redan vad problemet är: Question.was_published_recently()
ska returnera False
om dess pub_date
ligger i framtiden. Ändra metoden i models.py
, så att den bara returnerar True
om datumet också är i det förflutna:
polls/modeller.py
¶def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
och kör testet igen:
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'...
Efter att ha identifierat en bugg skrev vi ett test som avslöjar den och korrigerade buggen i koden så att vårt test passerade.
Många andra saker kan gå fel med vår applikation i framtiden, men vi kan vara säkra på att vi inte oavsiktligt kommer att återinföra den här buggen, eftersom testet varnar oss omedelbart. Vi kan betrakta den här lilla delen av applikationen som säker för alltid.
Mer omfattande tester¶
Medan vi är här kan vi ytterligare fastställa metoden was_published_recently()
; det skulle faktiskt vara positivt pinsamt om vi genom att fixa en bugg hade introducerat en annan.
Lägg till ytterligare två testmetoder i samma klass för att testa metodens beteende på ett mer heltäckande sätt:
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)
Och nu har vi tre tester som bekräftar att Question.was_published_recently()
returnerar rimliga värden för tidigare, nyligen publicerade och framtida frågor.
Återigen, polls
är en minimal applikation, men hur komplex den än blir i framtiden och vilken annan kod den än interagerar med, har vi nu en viss garanti för att den metod vi har skrivit tester för kommer att bete sig på förväntade sätt.
Testa en vy¶
Polls-programmet är ganska icke-diskriminerande: det kommer att publicera alla frågor, inklusive sådana vars pub_date
-fält ligger i framtiden. Vi bör förbättra detta. Att ange ett pub_date
i framtiden bör betyda att frågan publiceras vid den tidpunkten, men är osynlig fram till dess.
Ett test för en vy¶
När vi åtgärdade buggen ovan skrev vi först testet och sedan koden för att åtgärda den. I själva verket var det ett exempel på testdriven utveckling, men det spelar egentligen ingen roll i vilken ordning vi gör arbetet.
I vårt första test fokuserade vi noga på det interna beteendet i koden. I det här testet vill vi kontrollera beteendet så som det skulle upplevas av en användare via en webbläsare.
Innan vi försöker fixa något, låt oss ta en titt på de verktyg vi har till vårt förfogande.
Testklienten för Django¶
Django tillhandahåller ett test Client
för att simulera en användare som interagerar med koden på vy-nivå. Vi kan använda det i tests.py
eller till och med i shell
.
Vi börjar igen med shell
, där vi behöver göra ett par saker som inte kommer att vara nödvändiga i tests.py
. Den första är att sätta upp testmiljön i shell
:
$ python manage.py shell
...\> py manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
setup_test_environment()
installerar en mallåtergivare som gör att vi kan undersöka några ytterligare attribut på svar som response.context
som annars inte skulle vara tillgängliga. Observera att den här metoden inte sätter upp en testdatabas, så följande kommer att köras mot den befintliga databasen och resultatet kan skilja sig något beroende på vilka frågor du redan har skapat. Du kan få oväntade resultat om din TIME_ZONE
i settings.py
inte är korrekt. Om du inte minns att du har ställt in den tidigare, kontrollera det innan du fortsätter.
Därefter måste vi importera testklientklassen (senare i tests.py
kommer vi att använda django.test.TestCase
-klassen, som levereras med sin egen klient, så detta kommer inte att behövas):
>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()
När detta är klart kan vi be kunden att göra en del arbete åt oss:
>>> # 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's up?</a></li>\n \n </ul>\n\n'
>>> response.context["latest_question_list"]
<QuerySet [<Question: What's up?>]>
Förbättra vår syn¶
Listan över opinionsundersökningar visar opinionsundersökningar som inte har publicerats ännu (dvs. de som har ett pub_date
i framtiden). Låt oss fixa det.
I Tutorial 4 introducerade vi en klassbaserad vy, baserad på 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]
Vi måste ändra metoden get_queryset()
så att den även kontrollerar datumet genom att jämföra det med timezone.now()
. Först måste vi lägga till en import:
polls/views.py
¶from django.utils import timezone
och sedan måste vi ändra metoden get_queryset
så här:
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())
returnerar en frågeuppsättning som innehåller Question
vars pub_date
är mindre än eller lika med - det vill säga tidigare än eller lika med - timezone.now()
.
Testar vår nya vy¶
Nu kan du försäkra dig om att det här fungerar som förväntat genom att starta runserver
, ladda webbplatsen i din webbläsare, skapa några Question
-poster med datum i det förflutna och i framtiden, och kontrollera att endast de som har publicerats listas. Du vill inte behöva göra det varenda gång du gör någon ändring som kan påverka detta - så låt oss också skapa ett test, baserat på vår shell
-session ovan.
Lägg till följande i polls/tests.py
:
polls/tests.py
¶from django.urls import reverse
och vi kommer att skapa en genvägsfunktion för att skapa frågor samt en ny testklass:
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],
)
Låt oss titta närmare på några av dessa.
Den första är en genvägsfunktion för frågor, create_question
, som gör att processen för att skapa frågor inte behöver upprepas.
test_no_questions
skapar inte några frågor, men kontrollerar meddelandet: ”Inga omröstningar är tillgängliga.” och verifierar att latest_question_list
är tom. Observera att klassen django.test.TestCase
tillhandahåller ytterligare några assertion-metoder. I dessa exempel använder vi assertContains()
och assertQuerySetEqual()
.
I test_past_question
skapar vi en fråga och kontrollerar att den visas i listan.
I test_future_question
skapar vi en fråga med ett pub_date
i framtiden. Databasen återställs för varje testmetod, så den första frågan finns inte längre kvar, och indexet bör inte heller ha några frågor i sig.
Och så vidare. I själva verket använder vi testerna för att berätta en historia om administratörsinmatning och användarupplevelse på webbplatsen, och kontrollerar att de förväntade resultaten publiceras vid varje tillstånd och för varje ny förändring i systemets tillstånd.
Testning av DetailView
¶
Det vi har fungerar bra, men även om framtida frågor inte visas i index kan användarna fortfarande nå dem om de känner till eller gissar rätt URL. Så vi måste lägga till en liknande begränsning till 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())
Vi bör sedan lägga till några tester för att kontrollera att en ”fråga” vars ”publiceringsdatum” ligger i det förflutna kan visas, och att en fråga med ett ”publiceringsdatum” i framtiden inte kan visas:
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)
Idéer för fler tester¶
Vi borde lägga till en liknande get_queryset
-metod till ResultsView
och skapa en ny testklass för den vyn. Den kommer att vara mycket lik det vi just har skapat; i själva verket kommer det att finnas en hel del upprepningar.
Vi kan också förbättra vår applikation på andra sätt och lägga till tester längs vägen. Det är till exempel meningslöst att en Fråga
utan relaterad Val
kan publiceras på webbplatsen. Så våra vyer kan kontrollera detta och utesluta sådana Question
-objekt. Våra tester skulle skapa en Fråga
utan en Val
, och sedan testa att den inte publiceras, samt skapa en liknande Fråga
* med * minst en Val
, och testa att den * är * publicerad.
Kanske bör inloggade administratörsanvändare tillåtas se opublicerade ”frågor”, men inte vanliga besökare. Återigen: vad som än behöver läggas till i programvaran för att åstadkomma detta bör åtföljas av ett test, oavsett om du skriver testet först och sedan får koden att klara testet, eller om du först utarbetar logiken i din kod och sedan skriver ett test för att bevisa det.
Vid en viss tidpunkt måste du titta på dina tester och undra om din kod lider av testbloat, vilket leder oss till:
Vid testning är mer bättre¶
Det kan tyckas att våra tester håller på att växa utom kontroll. I den här takten kommer det snart att finnas mer kod i våra tester än i vår applikation, och upprepningen är oestetisk jämfört med den eleganta koncisa resten av vår kod.
Det spelar ingen roll. Låt dem växa. För det mesta kan du skriva ett test en gång och sedan glömma bort det. Det kommer att fortsätta att utföra sin användbara funktion när du fortsätter att utveckla ditt program.
Ibland behöver tester uppdateras. Anta att vi ändrar våra vyer så att endast Question
-poster med tillhörande Choice
-instanser publiceras. I det fallet kommer många av våra befintliga tester att misslyckas - teller oss exakt vilka tester som behöver ändras för att få dem uppdaterade, så i den utsträckningen hjälper testerna till att ta hand om sig själva.
I värsta fall, när du fortsätter att utveckla, kan du upptäcka att du har några tester som nu är överflödiga. Inte ens det är ett problem; när det gäller testning är redundans en bra sak.
Så länge dina tester är förnuftigt arrangerade kommer de inte att bli ohanterliga. Bra tumregler inkluderar att ha:
en separat
TestClass
för varje modell eller vyen separat testmetod för varje uppsättning villkor som du vill testa
namn på testmetoder som beskriver deras funktion
Ytterligare tester¶
Denna handledning introducerar bara några av grunderna i testning. Det finns mycket mer du kan göra, och ett antal mycket användbara verktyg till ditt förfogande för att uppnå några mycket smarta saker.
Till exempel, medan våra tester här har täckt en del av den interna logiken i en modell och hur våra vyer publicerar information, kan du använda ett ”in-browser”-ramverk som Selenium för att testa hur din HTML faktiskt återges i en webbläsare. Med dessa verktyg kan du inte bara kontrollera beteendet hos din Django-kod, utan också till exempel hos ditt JavaScript. Det är något alldeles extra att se testerna starta en webbläsare och börja interagera med din webbplats, som om en människa körde den! Django inkluderar LiveServerTestCase
för att underlätta integration med verktyg som Selenium.
Om du har en komplex applikation kanske du vill köra tester automatiskt med varje commit för ”kontinuerlig integration”, så att kvalitetskontrollen i sig själv - åtminstone delvis - är automatiserad.
Ett bra sätt att upptäcka otestade delar av din applikation är att kontrollera kodtäckningen. Detta hjälper också till att identifiera bräcklig eller till och med död kod. Om du inte kan testa en del av koden betyder det vanligtvis att koden bör omarbetas eller tas bort. Täckning hjälper till att identifiera död kod. Se Integration med coverage.py för mer information.
Testing in Django har omfattande information om testning.
Vad kommer härnäst?¶
För fullständig information om testning, se Testning i Django.
När du känner dig bekväm med att testa Django-vyer kan du läsa del 6 av denna handledning för att lära dig mer om hantering av statiska filer.