Escrevendo sua primeira aplicação Django, parte 5

Este tutorial começa onde o Tutorial 4 parou. Nós construímos uma aplicação web de enquete, e agora nós vamos criar alguns testes automatizados para ela.

Apresentando testes automatizados

O que são testes automatizados?

Testes são rotinas simples que checam 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.

Uma mudança em qualquer desses componentes pode trazer consequências inesperadas no comportamento da aplicação. Verificar que ainda ‘parece funcionar’ poderia significar rodar todas as funcionalidades do seu código com variações diferentes dos dados do seu teste, só pra ter certeza que você não tem nada quebrado - e isso não é um bom uso do seu tempo.

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.

Você pode criar uma parte importante no software, mas você vai encontrar muitos outros desenvolvedores que vão simplesmente ignorar ao olhar isto, por que isto carece de testes, e sem testes, não há confiança. Jacob Kaplan-Moss, um dos primeiros desenvolvedores do Django diz “Código sem testes é quebra de 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.

Alguns programadores seguem uma disciplina chamada “test-driven development”; eles escrevem seus testes antes de escrever o código. Isso talvez pareção não intuitivo, mas na verdade é similar o que muitas pessoas farão de qualquer forma: eles descrevem um problema, então criam um código para resolver isso. O desenvolvimento Test-driven simplesmente formaliza o problema em um caso de teste em Python.

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

Para checar se o bug realmente existe, usando o Admin crie uma enquete na qual a data encontra-se no futuro e verifique o método através do 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.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)

O que nós fizemos aqui foi ter criado uma subclasse django.test.TestCase com o método que cria uma instância de Question with pub_date no futuro. Nós então vamos checar a saida de was_published_recently() - o quel deve ser False.

Executando o teste

No terminal, nós podemos executar nosso test:

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

O que ocorreu foi o seguinte:

  • python manage.py test polls procurou por testes na aplicação polls
  • 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. Emende 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.

Muitas outras coisas podem dar errado com a nossa aplicação no futuro, mas você pode ter certeza que não vamos reintroduzir este bug inadivertidamente, porque simplesmente rodando os testes já receberemos o aviso imediatamente. Nós podemos considerar que esta pequena parte da aplicação está a salvo para sempre

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.

De novo, polls é uma simples aplicação, mas independente de quão complexa ela se torne no futuro e qualquer outro código que venha a interagir com ela, nós agora temos alguma garantia que o método que nós escrevemos com testes vão se comportar de maneira esperada.

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

Quando arrumamos o bug acima, escrevemos primeiro o teste e depois o código que o concerta. Defato aquilo foi um exemplo de desenvolvimento guiado por teste, mas não faz diferença a a ordem que fazemos o trabalho.

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.

Começaremos denovo com o shell, onde faremos alumas coisas que não seriam necessárias no test.py. A primeira é definir o ambiente de teste no :django:`shell`:

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

O setup_test_environment() instala um renderizador de templates o qual permite que sejam examinados alguns atributos adicionais nas respostas http tal como um response.context que de outra maneira não estariam disponíveis. Note que este método não configura um banco de dados de teste, então o que se segue será executado no banco de dados existente e a saída pode ser diferente quanto as questões que você já criou. Você talvez tenha resultados inesperados se seu TIME_ZONE no settings.py não estiver correto. Se você não lembrou de defini-lo antes, verifique-o antes de continuar.

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&#39;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 pode ficar satisfeito que isso se comporta como esperado disparando o “runserver”, carregando no seu browser, criando Questions com datas no passado e no futuro, e verificando que somente aqueles que foram publicados são listados. Você não quer fazer isso toda vez que fizer uma alteração que talvez afete isso - então vamos criar um teste, baseado na 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.
        """
        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.>']
        )

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

E assim por diante. Defato, estamos usando os testes para contar uma história da entrada do admin e da experiencia do usuário no site, 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())

E claro, adicionaremos alguns testes, para verificar que uma Question a qual seu pub_date está no passado pode ser mostrada, e que uma com ``pub_date``no futuro não pode:

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 browsers como o Selenium para testar a maneira como seu HTML é processado de verdade pelo browser. Isso permite a você testar não somente o comportamente 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 intergir 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