Escrevendo sua primeira aplicação Django, parte 5

This tutorial begins where Tutorial 4 left off. We’ve built a web-poll application, and we’ll now create some automated tests for it.

Onde obter ajuda:

Se tiver problemas enquanto caminha por este tutorial, por favor consulte a seção Obtendo ajuda da FAQ.

Apresentando testes automatizados

O que são testes automatizados?

Testes são rotinas que verificam o funcionamento do seu código.

Testes funcionam em diferentes níveis. Alguns testes podem ser aplicados a um pequeno detalhe (um determinado método de um modelo retorna o valor esperado?) enquanto outros examinam o funcionamento global do software (a sequência de entradas do usuário no site produz o resultado desejado?). Isso não é diferente do teste que você fez anteriormente no Tutorial 2, usando o shell para examinar o comportamento de um método, ou executar a aplicação e entrar com um dado para verificar como ele se comporta.

A diferença em testes automatizados é que o trabalho de testar é feito para você pelo sistema. Você cria um conjunto de testes uma vez, e então conforme faz mudanças em sua aplicação, você pode checar se o seu código continua funcionando como você originalmente pretendia, sem ter que gastar tempo executando o teste manualmente.

Porque você precisa criar testes

Então, por que criar testes, e por que agora?

Você pode achar que já tem coisa suficiente no seu prato apenas aprendendo Python/Django, e tendo ainda outra coisa para aprender e fazer pode parecer exagerado e talvez desnecessário. Afinal, nossa aplicação de enquetes está funcionando muito bem agora; passar pelo trabalho de criar testes automatizados não vai fazê-la funcionar melhor. Se criar a aplicação de enquetes é a última coisa que você vai programar em Django, então realmente, você não precisa saber sobre como criar testes automatizados. Mas, se esse não é o caso, agora é uma excelente hora para aprender.

Teste vão salvar seu tempo

Até um certo ponto, ‘verificar que parece funcionar’ será um teste satisfatório. Em uma aplicação mais sofisticada, você pode ter dúzias de interações complexas entre componentes.

A change in any of those components could have unexpected consequences on the application’s behavior. Checking that it still ‘seems to work’ could mean running through your code’s functionality with twenty different variations of your test data to make sure you haven’t broken something - not a good use of your time.

Isso é verdade especialmente quando testes automatizados poderiam fazer isso pra você em segundos. Se alguma coisa errada acontecer, os testes também ajudarão em identificar o código que está causando o comportamento inesperado.

Às vezes pode parece uma tarefa para afastar você da sua produtividade e criatividade em programar para enfrentar o trabalho nada excitante e glamouroso de escrever testes, particularmente quando você sabe que seu código está funcionando corretamente.

No entanto, a tarefa de escrever testes é muito mais satisfatório do que gastar horas testando sua aplicação manualmente, ou tentando identificar a causa de um problema recém-introduzido.

Testes não só identificam problemas, mas também os previnem

É um erro pensar nos testes simplesmente como um aspecto negativo do desenvolvimento.

Sem testes, o objetivo ou comportamento desejado de uma aplicação pode não ser tão claro. Mesmo quando é seu próprio código, algumas vezes você vai se encontrar bisbilhotando nele tentando descobrir exatamente o que está fazendo.

Testes mudam isso; eles mostram seu código por dentro, e quando algumas coisas saem errado, eles revelam parte do erro.

Testes fazem seu código mais atraente.

You might have created a brilliant piece of software, but you will find that many other developers will refuse to look at it because it lacks tests; without tests, they won’t trust it. Jacob Kaplan-Moss, one of Django’s original developers, says “Code without tests is broken by design.”

Assim, esses outros desenvolvedores procuram ver os testes do seu software antes de levar a sério, e isso é uma outra razão para você iniciar a escrita dos testes.

Testes ajudam as equipes a trabalharem juntos

Os pontos anteriores foram escritos do ponto de vista de um único desenvolvedor mantendo uma aplicação. Aplicações complexas serão mantidas por times. Testes garantem que seus colegas de trabalho não quebrem acidentalmente o seu código (e que você não quebre o deles sem saber). Se você deseja trabalhar como um programador Django, deve ser bom escrevendo testes!

Estratégias básicas de testes

Há várias maneiras de abordar a escrita de testes.

Some programmers follow a discipline called “test-driven development”; they actually write their tests before they write their code. This might seem counter-intuitive, but in fact it’s similar to what most people will often do anyway: they describe a problem, then create some code to solve it. Test-driven development formalizes the problem in a Python test case.

Comumente, um novato em testes irá criar algum código e depois decidir que ele deveria ser testado. Talvez seria melhor escrever alguns testes antes, mas nunca é tarde para começar.

Às vezes é difícil saber por onde devemos começar a escrever testes. Se você tiver escrito milhares de linhas em Python, escolher algo para testar pode não ser fácil. Nesse caso, é interessante escrever seu primeiro teste na próxima vez que você fizer alguma alteração, seja a adição de uma nova função ou correção de um bug.

Então vamos fazer isso a partir de agora.

Escrevendo nosso primeiro teste

Nós identificamos um bug

Felizmente, há um pequeno bug na aplicação polls para corrigirmos imediatamente: O método Question.was_published_recently() retorna True se a Question foi publicada dentro do último dia (o que está correto), mas também se o campo pub_date de Question está no futuro (o que certamente não é o caso).

Confirme o bug utilizando shell para verificar o método numa questão em que a data encontra-se no futuro:

$ 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

Desde que coisas no futuro não são ‘recent’, isto é claramente um erro.

Criar um teste para expor um bug

O que nós fizemos no shell para testar o problema é exatamente o que podemos fazer em um teste automatizado, então vamos voltar para o teste automatizado

Um lugar convencional para os testes da aplicação é o arquivo tests.py; o sistema de testes irá encontrar automaticamente todos os testes que estiverem em qualquer arquivo cujo o nome comece com test.

Coloque o seguinte código no arquivo tests.py na aplicação 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)

Aqui criamos uma subclasse de django.test.TestCase com um método que cria uma instância de Question com uma pub_date no futuro. Então verificamos a saída de was_published_recently() - o qual deve ser False.

Executando o teste

No terminal, podemos rodar nosso teste:

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

E você verá algo como:

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

Erro diferente?

Se no lugar disso, você recebeu uma exceção NameError, você pode ter pulado algo passo da Parte 2 onde nós adicionamos “imports” de``datetime`` e timezone em polls/models.py. Copie os “imports” dessa seção, e tente executar seus testes novamente.

O que ocorreu foi o seguinte:

  • manage.py test polls looked for tests in the polls application
  • ele encontrou uma subclass da classe django.test.TestCase
  • ele cria um banco de dados especial com o propósito de teste
  • ele procurou por métodos de test - aquele cujo nome começam com test
  • em test_was_published_recently_with_future_question é criado uma instância de Question na qual o campo pub_date está 30 dias no futuro
  • … e usando o método assertIs(), descobrimos que was_published_recently() retorna True, mas queremos que retorne False

O teste nos informa que teste falhou e até mesmo a linha na qual a falha ocorreu.

Corrigindo o erro

Nós sabemos onde o problema está. “Question.was_published_recently()” deveria retornar “False” se “pub_date” está no futuro. Corrija o método no “models.py”, dessa forma ele só retornará “True” se a data também estiver no passado:

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

execute e teste novamente

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

Depois de identificar um erro, nós escrevemos um teste que demonstra e corrige o erro no código, então o nosso teste passa.

Many other things might go wrong with our application in the future, but we can be sure that we won’t inadvertently reintroduce this bug, because running the test will warn us immediately. We can consider this little portion of the application pinned down safely forever.

Testes mais abrangentes

Enquanto estamos aqui, nós podemos nos aprofundar no método was_published_recently(); De fato, seria certamente embaraçoso introduzir um novo bug enquanto arruma outro.

Adicione mais dois métodos de teste na mesma classe, para testá-la melhor.

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)

E agora nós temos 3 testes que confirmam que Question.was_published_recently() retorna valores sensíveis de perguntas do passado, recente e futuro.

Again, polls is a minimal application, but however complex it grows in the future and whatever other code it interacts with, we now have some guarantee that the method we have written tests for will behave in expected ways.

Teste a View

A aplicação de enquete é bastante indiscriminatória: irá publicar qualquer questão, incluindo aquelas que o campo pub_date está no futuro. Devemos melhorar isso. Definindo um pub_date no futuro deveria fazer com que a Question seja publicada naquele momento, mas que esteja invisível até lá.

Um teste para uma view

When we fixed the bug above, we wrote the test first and then the code to fix it. In fact that was an example of test-driven development, but it doesn’t really matter in which order we do the work.

Em nosso primeiro teste, focamos no comportamento interno do código. Para este teste, nós queremos checar seu comportamento como seria experienciado por um usuário através de um navegador web.

Antes de tentarmos consertar alguma coisa, vamos dar uma olhada nas ferramentas à nossa disposição.

O cliente de testes do Django

o Django fornece um test Client para simular um usuário interagindo com o código no nível de view. POdemos usá-lo em tests.py ou memso no shell.

We will start again with the shell, where we need to do a couple of things that won’t be necessary in tests.py. The first is to set up the test environment in the shell:

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

setup_test_environment() installs a template renderer which will allow us to examine some additional attributes on responses such as response.context that otherwise wouldn’t be available. Note that this method does not set up a test database, so the following will be run against the existing database and the output may differ slightly depending on what questions you already created. You might get unexpected results if your TIME_ZONE in settings.py isn’t correct. If you don’t remember setting it earlier, check it before continuing.

A diante precisamos importar a classe cliente de teste (depois iremos usar a classe django.test.TestCase no tests.py, a qual traz seu próprio cliente, então isso não será necessário):

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

Com isso pronto, podemos pedir que o cliente faça algum trabalho para a gente:

>>> # 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?>]>

Melhorando a nossa view

A lista de enquete mostra enquetes que não estão publicadas ainda (isto é aqueles que tem um ``pub_date``no futuro). Vamos concertar isso.

No Tutorial 4 nós introduzimos o class-based views, baseado no 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]

Precisamos fazer uma adição no método get_queryset() e alterá-lo de modo que ele verifique também a data comparando com timezone.now(). Antes precisamos adicionar uma importação:

polls/views.py
from django.utils import timezone

e então devemos fazer uma adição ao método get_queryset como a seguir:

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()) retorna um queryset contendo Questions cujo o pub_date é menor ou igual a - que quer dizer, anterior ou igual a - timezone.now.

Testando nossa nova view

Agora você pode se certificar se isso se comporta como esperado executando runserver, carregando o site no seu navegador, criando Questions com datas no passado e no futuro, e verificando que somente aqueles que foram publicados são listados. Você não quer ter que fazer isso toda vez que fizer uma alterações que possa afetar isso - então vamos também criar um teste, baseado no nossa sessão shell acima.

Adicione o seguinte a “polls/tests.py”:

polls/tests.py
from django.urls import reverse

e nós vamos criar uma função de atalho para criar perguntas assim como uma nova classe de teste:

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],
        )

Vejamos algumas delas mais de perto.

O primeiro é uma função de atalho para questão, create_question, para retirar algumas repetições do processo de criar questões.

test_no_questions não cria nenhuma questão, mas verifica a mensagem:”Nenhuma enquete disponível.” e verifica que latest_question_list está vazia. Note que a classe django.test.TestCase fornece alguns métodos adicionais de assertividade. Nestes exemplos nós usamos assertContains() e assertQuerysetEqual().

Em test_past_question, criamos uma questão e verificamos se esta aparece na lista.

Em test_future_question, nós criamos uma pub_date no futuro. O banco de dados é resetado para cada método de teste, quer dizer que a primeira questão não está mais lá, então novamente o index não deve ter nenhuma questão.

E assim por diante. De fato, estamos usando os testes para contar uma história de entradas no site de administração e da experiencia do usuário, e verificando se para cada estado e para cada nova alteração no estado do sistema, o resultado esperado é publicado.

Testando o DetailView

O que temos funciona bem; Porém, mesmo quando questões futuras não aparecem no * index*, os usuários ainda podem acessá-las se eles conhecem ou adivinham a URL correta. Então precisamos adicionar uma regra similar ao 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())

We should then add some tests, to check that a Question whose pub_date is in the past can be displayed, and that one with a pub_date in the future is not:

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)

Ideias para mais testes

Devemos adicionar um método similar ao get_queryset ao ResultsView e criar uma nova classe de teste para essa “view”. Isso será muito parecido com o que criamos há pouco; defato terá muitas repetições.

Podemos também melhorar nossa aplicação de várias outras maneiras, adicionando testes no caminho. Por exemplo é estranho que Questions possam ser publicadas no site sem que tenham Choices. Então, nossas “views” podem verificar isso, e excluir tais Questions. Nossos testes podem criar uma Question sem Choices e então testar que não estão publicadas, também criar uma Question parecida com Choices, e testar se estão publicadas.

Talvez usuários registrados no admin devam ser permitidos a ver Questions não publicadas, mas não os visitantes comuns. Denovo: independente da necessidade a serem adicionadas ao software para que isso seja realizado, isso deve ser acompanhado por um teste, não importando se você escreve o teste primeiro e então faz o código para passar no teste, ou escreva o código da lógica primeiro e então escreva o teste para fazer a prova.

Em um certo ponto, você precisará olhar seus testes e se perguntar se o seu código está sofrendo de inchaço de testes, o que nos trás a:

Quando estiver testando, mais é melhor

Pode parecer que nossos testes estão crescendo fora de controle. Nessa taxa teremos logo mais código nos testes do que em nossa aplicação, e a repetição não é muito estética, comparado a concisa elegância do resto do nosso código.

Isso não importa. Deixe eles crescerem. Por boa parte do tempo, você pode escrever um teste uma vez e então esquecer dele. Ele vai continuar a executar sua função útil enquanto você continua a escrever seu programa.

Algumas vezes os testes precisarão ser atualizados. Suponha que emendamos nossas “views” para que somente Questions``com ``Choices são publicadas. Neste caso, muitos dos nossos testes existentes irão falahar - nos dizendo exatamente quais testes precisam ter ajustes para que sejam atualizados, assim que os próprios testes auxiliam na manutenção deles mesmos.

Na pior das hipóteses, ao continuar a desenvolver, você talvez encontre alguns tests que são reduntantes agora. Mesmo isso não é um problema; em testes redundância é uma boa coisa.

Enquanto seus testes estiverem organizados sensatamente, eles não vão se tornar desorganizados. Boas práticas incluem ter:

  • um “TestClass” separado para cada modelo ou view
  • um método de teste separado para cada conjunto de condições que você quer testar
  • nomes de métodos de teste que descrevem a sua função

Mais testes

Esse tutorial apenas introduz alguns conceitos básicos de testes. Há muito mais do que você pode fazer, e uma série de ferramentas muito úteis à sua disposição para conseguir algumas coisas muito inteligentes.

Por exemplo, enquanto nossos testes aqui tem coberto algumas lógicas internas de um modelo e a informação publicada nas nossas “views”, você pode usar um framework para navegador como o Selenium para testar a maneira como seu HTML é processado de verdade pelo navegador. Isso permite a você testar não somente o comportamento do seu código Django, mas também, por exemplo, do seu JavaScript. É bem legal ver seus testes iniciarem um navegador, e começar a interagir com seu site, como se fosse um ser humano guiando! O Django inclue a LiveServerTestCase para facilitar a integração com ferramentas como o Selenium.

Se você tem uma aplicação complexa, você pode querer rodar testes automaticamente com cada commit pelo propósito de integração contínua, e o próprio controle de qualidade - ao menos parcialmente - é automatizado.

Uma boa forma de encontrar partes não testadas de sua aplicação é verificar a cobertura de código. Isto também ajuda a identificar códigos frágeis ou mesmo morto. Se você não pode testar um pedaço do código, isso normalmente significa que o código precisa ser refatorado ou removido. Cobertura vai ajudar a identificar código morto. Veja Integration with coverage.py para mais detalhes.

Testes no django tem informações sobre testes.

E agora?

Para maiores detalhes sobre testing, veja Testando no Django.

Quando estiver confortável testando “views” Django, leia: doc:parte 6 deste tutorial</intro/tutorial06> para aprender sobre gerenciamente de arquivos estáticos.

Back to Top