Criar Alguns Testes Automatizados

Beginner

Introdução

Este tutorial começa onde **Processamento de Formulários e Redução do Nosso Código** parou. Construímos uma aplicação de enquete web e, agora, criaremos alguns testes automatizados para ela.

Introdução aos testes automatizados

O que são testes automatizados?

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

Os testes operam em diferentes níveis. Alguns testes podem se aplicar a um detalhe minúsculo (um determinado método do modelo retorna os valores esperados?), enquanto outros examinam a operação geral do software (uma sequência de entradas do usuário no site produz o resultado desejado?). Isso não é diferente do tipo de teste que você fez anteriormente em **Configurar o Banco de Dados**, usando o shell para examinar o comportamento de um método, ou executando a aplicação e inserindo dados para verificar como ela se comporta.

O que é diferente nos testes automatizados é que o trabalho de teste é feito para você pelo sistema. Você cria um conjunto de testes uma vez e, em seguida, à medida que faz alterações em seu aplicativo, pode verificar se seu código ainda funciona como você pretendia originalmente, sem ter que realizar testes manuais demorados.

Por que você precisa criar testes

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

Você pode sentir que já tem bastante coisa para aprender apenas com Python/Django, e ter mais uma coisa para aprender e fazer pode parecer opressor 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 for a última coisa que você fará em programação Django, então, de fato, você não precisa saber como criar testes automatizados. Mas, se esse não for o caso, agora é um excelente momento para aprender.

Testes economizarão seu tempo

Até certo ponto, 'verificar se parece funcionar' será um teste satisfatório. Em uma aplicação mais sofisticada, você pode ter dezenas de interações complexas entre os componentes.

Uma alteração em qualquer um desses componentes pode ter consequências inesperadas no comportamento da aplicação. Verificar se ainda 'parece funcionar' pode significar executar a funcionalidade do seu código com vinte variações diferentes dos seus dados de teste para garantir que você não quebrou nada - não é um bom uso do seu tempo.

Isso é especialmente verdade quando os testes automatizados podem fazer isso por você em segundos. Se algo deu errado, os testes também ajudarão a identificar o código que está causando o comportamento inesperado.

Às vezes, pode parecer uma tarefa árdua se afastar do seu trabalho de programação produtivo e criativo para enfrentar o negócio pouco glamouroso e pouco emocionante de escrever testes, principalmente quando você sabe que seu código está funcionando corretamente.

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

Testes não apenas identificam problemas, eles os previnem

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

Sem testes, o propósito ou o comportamento pretendido de uma aplicação pode ser bastante opaco. Mesmo quando é seu próprio código, você às vezes se encontrará fuçando nele tentando descobrir o que exatamente ele está fazendo.

Os testes mudam isso; eles iluminam seu código por dentro e, quando algo dá errado, eles focam a luz na parte que deu errado - mesmo que você nem tenha percebido que deu errado.

Testes tornam seu código mais atraente

Você pode ter criado um software brilhante, mas descobrirá que muitos outros desenvolvedores se recusarão a analisá-lo porque ele não possui testes; sem testes, eles não confiarão nele. Jacob Kaplan-Moss, um dos desenvolvedores originais do Django, diz: "Código sem testes é quebrado por design."

Que outros desenvolvedores queiram ver testes em seu software antes de levá-lo a sério é mais uma razão para você começar a escrever testes.

Testes ajudam as equipes a trabalhar juntas

Os pontos anteriores são escritos do ponto de vista de um único desenvolvedor que mantém uma aplicação. Aplicações complexas serão mantidas por equipes. Os testes garantem que os colegas não quebrem inadvertidamente seu código (e que você não quebre o deles sem saber). Se você quer ganhar a vida como programador Django, você deve ser bom em escrever testes!

Estratégias básicas de teste

Existem muitas maneiras de abordar a escrita de testes.

Alguns programadores seguem uma disciplina chamada "desenvolvimento orientado a testes" (test-driven development); eles realmente escrevem seus testes antes de escrever seu código. Isso pode parecer contraintuitivo, mas, na verdade, é semelhante ao que a maioria das pessoas costuma fazer de qualquer maneira: elas descrevem um problema e, em seguida, criam algum código para resolvê-lo. O desenvolvimento orientado a testes formaliza o problema em um caso de teste Python.

Mais frequentemente, um novato em testes criará algum código e, mais tarde, decidirá que ele deve ter alguns testes. Talvez fosse melhor escrever alguns testes antes, mas nunca é tarde demais para começar.

Às vezes, é difícil descobrir por onde começar a escrever testes. Se você escreveu vários milhares de linhas de Python, escolher algo para testar pode não ser fácil. Nesse caso, é proveitoso escrever seu primeiro teste na próxima vez que você fizer uma alteração, seja ao adicionar um novo recurso ou corrigir um bug.

Então, vamos fazer isso imediatamente.

Escrevendo nosso primeiro teste

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 no último dia (o que está correto), mas também se o campo pub_date da Question está no futuro (o que certamente não está).

Confirme o bug usando o shell para verificar o método em uma pergunta cuja data está no futuro:

cd ~/project/mysite
python 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

Como as coisas no futuro não são 'recentes', isso está claramente errado.

Crie um teste para expor o bug

O que acabamos de fazer no shell para testar o problema é exatamente o que podemos fazer em um teste automatizado, então vamos transformá-lo em um teste automatizado.

Um local convencional para os testes de uma aplicação é no arquivo tests.py da aplicação; o sistema de teste encontrará automaticamente testes em qualquer arquivo cujo nome comece com test.

Coloque o seguinte no arquivo tests.py na aplicação polls:

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 django.test.TestCase com um método que cria uma instância Question com um pub_date no futuro. Em seguida, verificamos a saída de was_published_recently() - que deveria ser False.

Executando testes

No terminal, podemos executar nosso teste:

python manage.py test polls

e você verá algo como:

[object Object]

Erro diferente?

Se, em vez disso, você estiver recebendo um NameError aqui, pode ter perdido uma etapa em Parte 2 <tutorial02-import-timezone> onde adicionamos importações de datetime e timezone para polls/models.py. Copie as importações dessa seção e tente executar seus testes novamente.

O que aconteceu foi o seguinte:

  • manage.py test polls procurou testes na aplicação polls
  • encontrou uma subclasse da classe django.test.TestCase
  • criou um banco de dados especial para fins de teste
  • procurou métodos de teste - aqueles cujos nomes começam com test
  • em test_was_published_recently_with_future_question, criou uma instância Question cujo campo pub_date está 30 dias no futuro
  • ... e usando o método assertIs(), descobriu que seu was_published_recently() retorna True, embora quiséssemos que retornasse False

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

Corrigindo o bug

Já sabemos qual é o problema: Question.was_published_recently() deve retornar False se seu pub_date estiver no futuro. Modifique o método em models.py para que ele retorne True somente se a data também estiver no passado:

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

e execute o 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 bug, escrevemos um teste que o expõe e corrigimos o bug no código para que nosso teste passe.

Muitas outras coisas podem dar errado com nossa aplicação no futuro, mas podemos ter certeza de que não reintroduziremos inadvertidamente esse bug, porque a execução do teste nos avisará imediatamente. Podemos considerar esta pequena parte da aplicação fixada com segurança para sempre.

Testes mais abrangentes

Enquanto estamos aqui, podemos fixar ainda mais o método was_published_recently(); na verdade, seria positivamente embaraçoso se, ao corrigir um bug, tivéssemos introduzido outro.

Adicione mais dois métodos de teste à mesma classe, para testar o comportamento do método de forma mais abrangente:

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 temos três testes que confirmam que Question.was_published_recently() retorna valores sensatos para perguntas passadas, recentes e futuras.

Novamente, polls é uma aplicação mínima, mas por mais complexa que ela se torne no futuro e com qualquer outro código com o qual ela interaja, agora temos alguma garantia de que o método para o qual escrevemos testes se comportará da maneira esperada.

Testando uma view

A aplicação polls é bastante indiscriminada: ela publicará qualquer pergunta, incluindo aquelas cujo campo pub_date está no futuro. Devemos melhorar isso. Definir um pub_date no futuro deve significar que a Question é publicada naquele momento, mas invisível até então.

Um teste para uma view

Quando corrigimos o bug acima, escrevemos o teste primeiro e depois o código para corrigi-lo. Na verdade, esse foi um exemplo de desenvolvimento orientado a testes, mas não importa realmente em que ordem fazemos o trabalho.

Em nosso primeiro teste, nos concentramos de perto no comportamento interno do código. Para este teste, queremos verificar seu comportamento como seria experimentado por um usuário através de um navegador web.

Antes de tentar corrigir qualquer coisa, vamos dar uma olhada nas ferramentas à nossa disposição.

O cliente de teste Django

O Django fornece um ~django.test.Client de teste para simular um usuário interagindo com o código no nível da view. Podemos usá-lo em tests.py ou até mesmo no shell.

Começaremos novamente com o shell, onde precisamos fazer algumas coisas que não serão necessárias em tests.py. A primeira é configurar o ambiente de teste no shell:

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

~django.test.utils.setup_test_environment instala um renderizador de template que nos permitirá examinar alguns atributos adicionais nas respostas, como response.context, que de outra forma não estariam disponíveis. Observe que este método não configura um banco de dados de teste, portanto, o seguinte será executado em relação ao banco de dados existente e a saída pode diferir ligeiramente dependendo de quais perguntas você já criou. Você pode obter resultados inesperados se seu TIME_ZONE em settings.py não estiver correto. Se você não se lembra de defini-lo anteriormente, verifique-o antes de continuar.

Em seguida, precisamos importar a classe do cliente de teste (mais tarde em tests.py usaremos a classe django.test.TestCase, que vem com 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 ao cliente para fazer algum trabalho para nós:

>>> ## 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 nossa view

A lista de enquetes mostra enquetes que ainda não foram publicadas (ou seja, aquelas que têm um pub_date no futuro). Vamos corrigir isso.

Em **Processamento de Formulários e Reduzindo Nosso Código** introduzimos uma view baseada em classe, baseada em ~django.views.generic.list.ListView:

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 alterar o método get_queryset() e alterá-lo para que ele também verifique a data, comparando-a com timezone.now(). Primeiro, precisamos adicionar uma importação:

from django.utils import timezone

e então devemos alterar o método get_queryset assim:

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 pub_date é menor ou igual a - ou seja, anterior ou igual a - timezone.now.

Testando nossa nova view

Agora você pode se certificar de que isso se comporta como esperado, iniciando o runserver, carregando o site em seu navegador, criando Questions com datas no passado e no futuro e verificando se apenas aquelas que foram publicadas estão listadas. Você não quer ter que fazer isso toda vez que fizer qualquer alteração que possa afetar isso - então vamos também criar um teste, com base em nossa sessão shell acima.

Adicione o seguinte a polls/tests.py:

from django.urls import reverse

e criaremos uma função de atalho para criar perguntas, bem como uma nova classe de teste:

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

Vamos analisar alguns deles mais de perto.

Primeiro, há uma função de atalho de pergunta, create_question, para tirar um pouco da repetição do processo de criação de perguntas.

test_no_questions não cria nenhuma pergunta, mas verifica a mensagem: "No polls are available." e verifica se latest_question_list está vazio. Observe que a classe django.test.TestCase fornece alguns métodos de asserção adicionais. Nesses exemplos, usamos ~django.test.SimpleTestCase.assertContains() e ~django.test.TransactionTestCase.assertQuerySetEqual().

Em test_past_question, criamos uma pergunta e verificamos se ela aparece na lista.

Em test_future_question, criamos uma pergunta com um pub_date no futuro. O banco de dados é redefinido para cada método de teste, então a primeira pergunta não está mais lá e, portanto, novamente o índice não deve ter nenhuma pergunta nele.

E assim por diante. Na verdade, estamos usando os testes para contar uma história de entrada do administrador e experiência do usuário no site, e verificando que em cada estado e para cada nova mudança no estado do sistema, os resultados esperados são publicados.

Testando o DetailView

O que temos funciona bem; no entanto, embora as perguntas futuras não apareçam no índice, os usuários ainda podem acessá-las se souberem ou adivinharem a URL correta. Então, precisamos adicionar uma restrição semelhante ao DetailView:

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

Devemos então adicionar alguns testes para verificar se uma Question cujo pub_date está no passado pode ser exibida e que uma com um pub_date no futuro não pode:

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 get_queryset semelhante a ResultsView e criar uma nova classe de teste para essa view. Será muito semelhante ao que acabamos de criar; na verdade, haverá muita repetição.

Também poderíamos melhorar nossa aplicação de outras maneiras, adicionando testes ao longo do caminho. Por exemplo, é bobo que Questions possam ser publicadas no site que não têm Choices. Portanto, nossas views poderiam verificar isso e excluir tais Questions. Nossos testes criariam uma Question sem Choices e, em seguida, testariam se ela não é publicada, bem como criariam uma Question semelhante com Choices e testariam se ela é publicada.

Talvez os usuários administradores logados devam ter permissão para ver Questions não publicadas, mas não visitantes comuns. Novamente: o que precisar ser adicionado ao software para realizar isso deve ser acompanhado por um teste, seja você escrevendo o teste primeiro e depois fazendo o código passar no teste, ou elaborando a lógica em seu código primeiro e depois escrevendo um teste para prová-lo.

Em um determinado momento, você certamente analisará seus testes e se perguntará se seu código está sofrendo de inchaço de teste, o que nos leva a:

Ao testar, mais é melhor

Pode parecer que nossos testes estão crescendo fora de controle. Nesse ritmo, em breve haverá mais código em nossos testes do que em nossa aplicação, e a repetição é antiestética, em comparação com a concisão elegante do restante do nosso código.

Não importa. Deixe-os crescer. Na maior parte, você pode escrever um teste uma vez e depois esquecê-lo. Ele continuará desempenhando sua função útil à medida que você continua a desenvolver seu programa.

Às vezes, os testes precisarão ser atualizados. Suponha que alteremos nossas views para que apenas Questions com Choices sejam publicadas. Nesse caso, muitos de nossos testes existentes falharão - dizendo-nos exatamente quais testes precisam ser alterados para atualizá-los, de modo que, até certo ponto, os testes ajudam a cuidar de si mesmos.

Na pior das hipóteses, à medida que você continua a desenvolver, pode descobrir que tem alguns testes que agora são redundantes. Mesmo isso não é um problema; em testes, a redundância é uma coisa boa.

Desde que seus testes sejam organizados de forma sensata, eles não se tornarão incontroláveis. Boas regras práticas incluem ter:

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

Testes adicionais

Este tutorial apresenta apenas alguns dos conceitos básicos de teste. Há muito mais que você pode fazer e várias ferramentas muito úteis à sua disposição para realizar coisas muito inteligentes.

Por exemplo, embora nossos testes aqui tenham coberto parte da lógica interna de um modelo e a maneira como nossas views publicam informações, você pode usar um framework "in-browser" como Selenium para testar a forma como seu HTML realmente renderiza em um navegador. Essas ferramentas permitem que você verifique não apenas o comportamento do seu código Django, mas também, por exemplo, do seu JavaScript. É impressionante ver os testes lançarem um navegador e começar a interagir com seu site, como se um ser humano estivesse dirigindo! O Django inclui ~django.test.LiveServerTestCase para facilitar a integração com ferramentas como Selenium.

Se você tiver uma aplicação complexa, pode querer executar testes automaticamente a cada commit para fins de integração contínua, para que o controle de qualidade seja ele mesmo - pelo menos parcialmente - automatizado.

Uma boa maneira de detectar partes não testadas de sua aplicação é verificar a cobertura do código. Isso também ajuda a identificar código frágil ou até mesmo morto. Se você não pode testar um trecho de código, geralmente significa que esse código deve ser refatorado ou removido. A cobertura ajudará a identificar código morto. Consulte topics-testing-code-coverage para obter detalhes.

Testando no Django </topics/testing/index> tem informações abrangentes sobre testes.

Resumo

Parabéns! Você concluiu o laboratório Criar Alguns Testes Automatizados. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.