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 dos 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 coisas no futuro não são ‘recent’, é 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 QuestionMethodTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() should return False for questions whose
        pub_date is in the future.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertEqual(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'...
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
    self.assertEqual(future_question.was_published_recently(), False)
AssertionError: True != 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

  • ... and using the assertEqual() method, it discovered that its was_published_recently() returns True, though we wanted it to return 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'...
.
----------------------------------------------------------------------
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() should return False for questions whose
    pub_date is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=30)
    old_question = Question(pub_date=time)
    self.assertEqual(old_question.was_published_recently(), False)

def test_was_published_recently_with_recent_question(self):
    """
    was_published_recently() should return True for questions whose
    pub_date is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=1)
    recent_question = Question(pub_date=time)
    self.assertEqual(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 processador de templates o qual nos habilita a examinar alguns atributos adicionais na respostas tal como response.context que de outra maneira não estaria disponível. Note que este método não define um banco de dados de teste, então ele irá rodar sobre o banco de dados existente e a saida talvez seja diferente dependendo das questões que você já criou.

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('/')
>>> # we should expect a 404 from that address
>>> 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.core.urlresolvers import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n\n\n    <p>No polls are available.</p>\n\n'
>>> # note - you might get unexpected results if your ``TIME_ZONE``
>>> # in ``settings.py`` is not correct. If you need to change it,
>>> # you will also need to restart your shell session
>>> from polls.models import Question
>>> from django.utils import timezone
>>> # create a Question and save it
>>> q = Question(question_text="Who is your favorite Beatle?", pub_date=timezone.now())
>>> q.save()
>>> # check the response once again
>>> response = client.get('/polls/')
>>> response.content
b'\n\n\n    <ul>\n    \n        <li><a href="/polls/1/">Who is your favorite Beatle?</a></li>\n    \n    </ul>\n\n'
>>> # If the following doesn't work, you probably omitted the call to
>>> # setup_test_environment() described above
>>> response.context['latest_question_list']
[<Question: Who is your favorite Beatle?>]

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.core.urlresolvers 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):
    """
    Creates 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 QuestionViewTests(TestCase):
    def test_index_view_with_no_questions(self):
        """
        If no questions exist, an appropriate message should be 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_index_view_with_a_past_question(self):
        """
        Questions with a pub_date in the past should be 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_index_view_with_a_future_question(self):
        """
        Questions with a pub_date in the future should not be 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_index_view_with_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        should be 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_index_view_with_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_index_view_with_no_questions não cria nenhuma questão, mas verifica a menssagem: “No polls are available.” e verifica se a latest_question_list está vazia. Note que a classe django.test.TestCase fornece algunns métodos adicionais de afirmação. Neste exemplo, nós usamos o assertContains() e assertQuerysetEqual().

Em “test_index_view_with_a_past_question”, nós criamos uma pergunta e verificamos que ela aparece na lista.

No test_index_view_with_a_future_question, nós criamos uma questão com o pub_date no futuro. O banco de dados é redefinido para cada método de teste, então a primeira questão não está mais lá, e também o índice não deve ter nenhuma questão nele.

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 QuestionIndexDetailTests(TestCase):
    def test_detail_view_with_a_future_question(self):
        """
        The detail view of a question with a pub_date in the future should
        return 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_detail_view_with_a_past_question(self):
        """
        The detail view of a question with a pub_date in the past should
        display 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