Programação dirigida a testes no Django
Introdução
O TDD [1] tem crescido exponencialmente. É cada vez maior o número e a diversidade de cases utilizando essa metodologia, e esse crescimento é claramenete compreensível.
Desenvolver baseando-se em testes é uma forma limpa e ágil de criar e mantêr software. Com ela é possível enxugar os requisitos, evitando o clássico hábito que o analista (e o cliente) tem de pedir requisitos que não serão utilizados. O TDD também facilita o projeto, dando um rumo ao implementador para que ele tenha um referencial entre certo e errado. Também ajuda na documentação e no cumprimento dos prazos.
A Tron [2] já utiliza TDD em alguns projetos, especialmente no Phonus e SeLiga, onde muitas das tarefas de manutenção e desenvolvimento são feitas com base em testes de unidade, criados com dUnit [3]. Acontece que o Delphi não foi exatamente criado para metodologias ágeis, ele deu lá a sua contribuição para o desenvolvimento rápido, mas sempre se limitou a ser uma (boa) ferramenta. O fato de trabalhar em desktop/win32 também dificulta bastante criar determinadas rotinas de testes, sem dizer que sua época de ouro não foi a mesma do TDD.
No Django essa realidade é diferente. Ele foi totalmente criado com base em testes, a versão do trunk possui toneladas deles. Qualquer proposta de mudança no framework só é recebida se for com os respectivos testes em anexo. E nele é possível testar praticamente tudo. Naquilo que o Django não testa (ainda - o caso do JavaScript e navegadores) o Selenium [4] o faz.
Tipos de testes
O Django - da mesma forma que o Python - oferece duas formas distintas de testar, cada qual com suas vantagens e aplicabilidades:
Esses dois módulos são parte nativa do Python e o Django não possui nenhum mérito nesse sentido. Entretanto, ele faz uso destes de uma forma bem bacana, a destacar:
manage.py test
A opção test [7] do manage.py percorre cada aplicação, verificando se a mesma possui um módulo de nome tests.py. Se este existir, ele será executado automaticamente.
O mesmo vale para docstrings de classes de modelo e de funções e views. Qualquer uma encontrada é testada no processo.
Ao final dos testes é exibido um relatório com os erros encontrados ou simplismente uma mensagem de "Ok".
Banco de dados de testes
Ao rodar a opção de teste (manage.py test), o Django cria um banco de dados para os testes, e popula esse banco com os fixtures initial_data caso eles existam. Ao concluir, o banco é destruído. Isso te tranquiliza?
Test Client
Mas... e quanto ao resultado final das views? Pois bem, existe uma classe chamada django.test.client.Client que simula um navegador e retorna o resultado da url passada. É simples de utilizar e bastante eficaz, principalmente para aplicações que utilizem RESTful [8] ou Ajax [9].
Esta classe pode ser utilizada em doctests, testes de unidade ou no shell.
manage.py shell
Para testes rápidos, o Django também embarca na onda do IPython [10]: basta executar a opção pra cair num prompt já preparado para o projeto em questão. Mas tome cuidado com as modificações de dados, pois elas são feitas no banco de dados de produção.
Colocando a mão na massa
Pois bem, este espaço não é grande o suficiente, portanto, vamos fazer alguns exercícios mais comuns para você ter seu friozinho na barriga.
Mas afinal, como desenvolver para testes?
O TDD se baseia em: testes antes, trabalho depois. Sinistro, não? Na verdade é uma questão de paradigmas. Um bom desenvolvedor faz seus próprios testes mentalmente enquanto lê uma ficha de caso de uso ou um requisito. É natural ouvir o analista falar e "matutar" um pouco sobre o assunto, buscando os pontos de gargalo e as possíveis dificuldades.
Acontece que existe toda aquela baboseira de documentação, projeto, fichas, etc. que é um saco mas necessário, afinal todo mundo tem lá seu dia de inferno. É bom que tudo esteja devidamente anotado como e quando deve funcionar, anexado de muitas observações e atas de reunião, como diz uma boa metodologia tradicional.
É aí que o TDD revoluciona: ele dá um basta a toda essa burocracia, pois um teste bem feito é melhor que qualquer documentação. Um teste é facilmente legível - no caso de teste documental, pode ser escrito por alguém que sequer saiba programar - e serve como projeto e documento de requisitos, ou seja: se o software passa nos testes, é porque cumpriu os requisitos.
Como fazer então?
Então, o primeiro passo é colocar a imaginação para funcionar e escrever os testes. Apesar de parecer que eles serão milhões (e eles podem realmente o ser), você vai notar que no primeiro momento não existem tantos testes assim a definir, pois não deve-se escrever testes que não estejam claros e definidos. Se não o estiver, é o caso para debater e chegar à clareza. Portanto, os primeiros testes escritos são meia-dúzia ou pouco mais que isso.
Evidentemente esses primeiros testes não irão funcionar, já que o software sequer foi escrito. Mas é bom executá-los para ter uma primeira sensação de pisar no terreno.
Após ter um grupo bacana de testes já implementáveis - e isso pode ser exatamente após ter sua meia-dúzia -, escreva seu software, e assim por diante: escrevendo testes, escrevendo código e refatorando-os num ciclo. Ao concluir o software, vai notar que resolveu alguns de seus maiores pesadelos, dentre eles o famoso "conserta de um lado, estraga do outro", que ora realmente ocorre, ora é sinônimo de pisar em ovos ao dar manutenção.
Agora, colocando a mão na massa (sério)
Supondo que você está criando um blog (ô case maldito, todo mundo só dá exemplos de blogs!), e ele terá duas classes de modelo: Entry (artigos) e Tag. Não vamos nos ater à classe Tag, mas para testar a Entry, precisaremos dela, certo? Então dê uma olhada no TestCase que montei pra ela:
# -*- encoding: utf-8 -*-
import unittest
from datetime import datetime
from django.contrib.auth.models import User
from django.core.validators import ValidationError
from blog.models import Entry, Tag
class EntryTestCase(unittest.TestCase):
def testFields(self):
entry = Entry()
# Campos obrigatorios
entry.title = 'Titulo'
entry.description = 'Texto descritivo'
entry.content = 'Texto completo'
entry.author, new = User.objects.get_or_create(username='admin')
# Salva
entry.save()
# Verifica se salvou
self.assertTrue(entry.id)
# Preenchido por signal
self.assertTrue(entry.slug)
# Valor automatico: data/hora da criação
hoje = datetime.today()
self.assertEquals(entry.pub_date.day, hoje.day)
# Valor padrao = True
self.assertTrue(entry.published)
# Valor padrao = 'rest', opcoes = (('rest','reStructuredText'),('html','HTML'),('txt','Texto'),)
self.assertEquals(entry.format, 'rest')
# Valor padrao = 'P', opcoes = (('P','Post'),('I','Image'),('W','Wiki'),('L','Link'))
self.assertEquals(entry.media_type, 'P')
# Valor padrao = Null
self.assertEquals(entry.image, None)
# Tipo de campo = URL (com verify_exists)
entry.url = 'abcd'
self.assertRaises(ValidationError, entry.save)
# Campo ManyToManyField
t1, new = Tag.objects.get_or_create(title='django', defaults={'slug': 'django'})
t2, new = Tag.objects.get_or_create(title='python', defaults={'slug': 'python'})
entry.tags.add(t1)
entry.tags.add(t2)
self.assertEquals(entry.tags.count(), 2)evidentemente este case de testes não foi construído todo num só tapa. O primeiro teste foi salvar os quatro campos obrigatórios (neste caso em nem precisei me preocupar com asserção, pois o save iria soltar uma bela Exception logo de cara), depois disso fui acrescentando outros à medida que fui abstraindo... outros foram inseridos depois, quando criei campos novos.
Para isso eu usei a biblioteca unittest e o detalhamento desta pode ser encontrado em [11].
Uma classe de testes deve sempre herdar de unittest.TestCase. Ela pode declarar os métodos setUp e tearDown, respectivamente para serem chamados antes e depois de cada método de teste. É convencional que cada método de teste inicie com 'test', e esses métodos de teste devem possuir asserções para verificar o valor correto que deve ser retornado. Essas asserções irão acusar erros quando o valor esperado não for condizente com o ocorrido.
Para exemplo, o método assertTrue garante que o valor contido dele deve ser True, se não o for, PAU! E assim por diante. Veja maiores detalhes em [11].
Agora executando este teste através do comando manage.py test, o seguinte resultado será retornado:
Creating test database...
Creating table auth_message
Creating table auth_group
Creating table auth_user
Creating table auth_permission
Creating table django_content_type
Creating table django_session
Creating table django_admin_log
Creating table django_site
Creating table django_flatpage
Creating table central_userprofile
Creating table blog_comment
Creating table blog_entry
Creating table blog_tag
Installing index for auth.Message model
Installing index for auth.Permission model
Installing index for admin.LogEntry model
Installing index for flatpages.FlatPage model
Installing index for blog.Comment model
Installing index for blog.Entry model
Loading 'initial_data' fixtures...
No fixtures found.
..F
======================================================================
FAIL: testFields (mb.blog.tests.EntryTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/arquivos/Projects/marinhobrandao/mb/../mb/blog/tests.py", line 47, in testFields
self.assertRaises(ValidationError, entry.save)
AssertionError: ValidationError not raised
----------------------------------------------------------------------
Ran 3 tests in 0.042s
FAILED (failures=1)
Destroying test database...não sei por que diabos a URLField não verificou a URL 'abcd' como inválida, mas não importa, fiquei satisfeito com o resultado. Se eu quiser tirar essa mensagem feia, eu tiro ou comento a asserção sobre a URL defeituosa ou verifico onde errei. Compreendeu??
Colocando a mão na massa com doctests
Agora com a Entry já criada, eu percebo a necessidade de um método 'get_by_url' para verificar se uma URL é referente a um artigo e carregá-lo a partir dela. Os motivos que levaram a este requisito não são da sua conta, mas vamos colocá-lo pra testar:
class EntryManager(models.Manager):
"""
# Uma URL invalida
>>> Entry.objects.get_by_url('http://www.terra.com.br/')
# Uma URL valida
>>> user, new = User.objects.get_or_create(username='admin')
>>> Entry.objects.create(title='Titulo', description='Descricao', content='Conteudo', author=user)
>>> Entry.objects.get_by_url('http://localhost:8000/blog/p/1/')
# Uma URL valida porem a Entry nao existe
>>> try:
... print Entry.objects.get_by_url('http://localhost:8000/blog/p/19/')
... except Exception, e:
... print e
Entry matching query does not exist.
""" ao executar o mesmo comando do exemplo anterior, a mesma mensagem será exibida, ou seja, um sucesso! As regrinhas básicas do teste documental você vai encontrar em [6], mas você deve estar atento a duas regras do uso de teste documental no Django:
- serão considerados somente os testes escritos nos módulos models.py e tests.py
- o módulo tests.py não deve ser necessariamente de testes de unidade, ele pode ser um grande e belo texto que se inicia e termina com três aspas seguidas, contendo testes documentais.
o teste documental é estranho para quem não está acostumado, mas ele é bem excitante, não acha?
Colocando a mão na massa com Test Client
Para finalizar, quero dar uma breve passada na classe django.test.client.Client. Ela é bastante simples e você deve estar ciente de que o resultado de uma view pode trazer muito mais do que você está determinado a testar - toda o HTML de uma página, por exemplo.
Mas ainda assim, ele é muito bacana e bastante prático, principalmente para views que retornem JSON, XML ou serviços RESTful.
Você pode utilizar esta classe em testes de unidade ou testes documentais, ou mesmo no shell. É uma escolha sua.
Esta classe dispõe de métodos bem interessantes:
- login (para efetuar login antes de testar uma view restrita)
- logout (para efetuar logout após testar a view restrita que desejava)
- session (sessão corrente)
- get (para fazer requisições do tipo GET)
- post (para fazer requisições do tipo POST)
os dois últimos métodos retornam mais do que um HTMLzão, eles retornam um objeto com os seguintes atributos:
- content (o texto de retorno, em geral é HTML, mas poderia ser JSON, XML ou qualquer outro formato, vai depender de sua view)
- headers (o cabeçalho HTTP retornado)
- request (a request que foi criada para a requisição)
- status_code (retorna o código de status retornado (o código de sucesso padrão é 200). ex.: 404)
- template (o objeto de template que foi utilizado para renderizar o retorno)
bom, devido ao tamanho que este artigo tomou e ao maior detalhamento que quero fazer sobre este último recurso, vou parar por aqui. Mas você pode dar uma olhada em [12] para sentir o poder que o test Client oferece.
É isso aí, e tenha uma boa semana!
Links relacionados
| [1] | http://en.wikipedia.org/wiki/Test-driven_development |
| [2] | http://www.tron.com.br/ |
| [3] | http://dunit.sourceforge.net/ |
| [4] | http://www.openqa.org/selenium/ |
| [5] | http://docs.python.org/lib/module-unittest.html |
| [6] | (1, 2) http://docs.python.org/lib/module-doctest.html |
| [7] | http://www.djangoproject.com/documentation/django-admin/#test |
| [8] | http://code.google.com/p/django-rest-interface/ |
| [9] | http://pt.wikipedia.org/wiki/AJAX_(programa%C3%A7%C3%A3o) |
| [10] | http://ipython.scipy.org/ |
| [11] | (1, 2) http://docs.python.org/lib/testcase-objects.html |
| [12] | http://www.djangoproject.com/documentation/testing/#the-test-client |
Marinho Brandão