É um negócio perigoso, Frodo, sair pela porta de casa. Você coloca um pé na estrada, e se você não se manter firme, não tem como saber para onde será levado.
Esse roteiro é baseado no livro Test-Driven Development with Python de Harry Percival.
Aplicar Test Driven Development em um projeto simples de lista de tarefas (todo list).
- Test Driven Development
- Testing Pyramid
- UI Tests
- Service Tests
- Unit Tests
- Testes Unitários
- Testes Integração
- Testes End-to-End
- Testes Funcionais / Aceitação
- Injeção de dependência
- Python 3.11+: https://www.python.org/downloads/
- Django 5.0+:
pip install django
- Google Chrome: https://www.google.com/chrome/
- Virtual env:
python -m venv venv && source venv/bin/activate
- Selenium:
pip install selenium
- Chromedriver: https://googlechromelabs.github.io/chrome-for-testing/
- Minha versão:
123.0.6312.87
- https://googlechromelabs.github.io/chrome-for-testing/#stable
- Baixar o chromedriver e descompactar na pasta
venv/bin/
- Isso garante que o
chromedriver
será adicionado ao PATH durante a execução - Outra opção é passar o
executable_path
para owebdriver.Chrome()
- Isso garante que o
- Minha versão:
- Verificar que funciona:
python 00_test_selenium_works.py
- Verificar que instalamos o Django corretamente
python 01_functional_test.py
# functional_tests.py
from selenium import webdriver
browser = webdriver.Chrome()
browser.get("http://localhost:8000")
assert "django" in browser.title
- Erro:
selenium.common.exceptions.WebDriverException: Message: unknown error: net::ERR_CONNECTION_REFUSED
- RED: Fazer o teste falhar
- GREEN: Fazer o teste passar
- REFACTOR: Melhorar o código
- Melhorar nossa mensagem de erro:
assert "django" in browser.title, f'browser.title: {browser.title}'
- Criar um projeto Django:
django-admin startproject listadetarefas . # Atenção para o "." (ponto)!!!
python manage.py runserver
# Starting development server at http://127.0.0.1:8000/
- Utilizando unittest.TestCase (standard library)
- setUp e tearDown
- Teste funcional == Teste de aceitação == Teste end-to-end
- É um teste de mais alto nível
- Quais são algumas das funcionalidades de um TODO list?
- Usuário consegue adicionar tarefas
- Usuário consegue visualizar tarefas
- Usuário consegue completar tarefas
- Usuário consegue deletar tarefas
- Usuário consegue editar tarefas
- ...
- Vamos focar em uma funcionalidade por vez: Usuário consegue adicionar tarefas
- Alice acessa o site de lista de tarefas
- Ela observa o cabeçalho "Tarefas"
- Ela observa um campo de texto para adicionar uma tarefa
- Ela digita "Comprar leite" e pressiona Enter
- A página é atualizada e agora ela vê "1: Comprar leite" na lista de tarefas
- Ela adiciona "Comprar pão" e pressiona Enter
- A página é atualizada e agora ela vê "1: Comprar leite" e "2: Comprar pão" na lista de tarefas
- Alice fecha o navegador
Vamos implementar até o ponto 2, depois disso, vamos...
python manage.py startapp tarefas
- Já existe um arquivo tests.py!
- Verificar que o test discovery do Django está funcionando
from django.test import TestCase
class TestHomePage(TestCase):
def test_dummy(self):
self.assertTrue(False)
- Executar com
python manage.py test
- Django Request/Response
- tests.py
- urls.py (router)
- views.py (controller)
- models.py (model/ORM)
- templates/ (view/UI)
- Não vamos ser puristas: nosso teste unitário roda muito mais rápido que o teste funcional
- Entender que estão em níveis de abstração diferentes!
- Vamos testar que conseguimos acessar a home page:
# tests.py
from django.test import TestCase
class TestHomePage(TestCase):
def test_home_page_renders_expected_html(self):
response = self.client.get("/") # Home page
self.assertIn("<title>Lista de Tarefas</title>", response.content)
- TypeError!
response.content
é um objeto do tipobytes
, não uma string - Corrigir a view até o teste passar.
- Red / Green -> Refactor!
pip install pytest-django
[pytest]
DJANGO_SETTINGS_MODULE = listadetarefas.settings
python_files = tests.py test_*.py *_tests.py
Agora conseguimos rodar apenas com pytest
ou pela IDE
- Django Request/Response
- A template engine que é responsável por juntar o contexto ao código HTML
- Adicionar pasta "templates" dentro do app "todolist"
- Padrão do Django para encontrar templates
<!-- templates/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Lista de Tarefas</title>
</head>
<body>
</body>
</html>
- Atualizar a view:
# views.py
from django.shortcuts import render
def home_page(request):
return render(request, "home.html")
TemplateDoesNotExist - Precisamos adicionar nosso app ao
INSTALLED_APPS
nosettings.py
✅ Teste passando!
# 2. Ela observa o cabeçalho "Tarefas" e título "Lista de Tarefas"
from selenium.webdriver.common.by import By
h1 = self.browser.find_element(by=By.TAG_NAME, value="h1")
self.assertIn("Lista de Tarefas", self.browser.title)
self.assertIn("Tarefas", h1.text)
- Teste funcional: verifica visualmente
- Teste unitário deveria verificar comportamento
- Comportamento da view: renderizar template "home.html"
- Não inspecionar o HTML, mas verificar que o HTML correto é utilizado!
self.assertTemplateUsed(response, template_name="home.html")
- Implementar os passos 3, 4 e 5 no functional_tests
from selenium.webdriver.common.keys import Keys
# 3. Ela observa um campo de texto para adicionar uma tarefa
input_box = self.browser.find_element(by=By.ID, value="id_new_item")
# 4. Ela digita "Comprar leite" e pressiona Enter
input_box.send_keys("Comprar leite")
input_box.send_keys(Keys.ENTER)
# 5. A página é atualizada e agora ela vê "1: Comprar leite" na lista de tarefas
time.sleep(1) # Espera a página ser atualizada
table = self.browser.find_element(by=By.TAG_NAME, value="table")
rows = table.find_elements(by=By.TAG_NAME, value="tr")
self.assertTrue(rows[0].text == "1: Comprar leite", f"Texto encontrado: {rows[0].text}")
id_new_item
não encontrado
<input id="id_new_item" placeholder="Nova tarefa" />
table
não encontrada
<input id="id_new_item" placeholder="Nova tarefa" />
<table id="id_lista_tarefas"></table>
row
não encontrado
<input id="id_new_item" placeholder="Nova tarefa" />
<table id="id_lista_tarefas">
<tr><td>1: Comprar leite</td></tr>
</table>
Nosso teste passar não significa que nosso código está certo! Nosso código pode estar errado.
- Adicionar um formulário na home page e name ao input
<form method="POST">
<input id="id_new_item" placeholder="Nova tarefa" name="item_text"/>
</form>
- Rodar teste: CSRF verification failed. Request aborted.
- CSRF: Cross-Site Request Forgery
- Django protege contra ataques CSRF por padrão
- Adicionar
{% csrf_token %}
ao formulário
- Atualizar view para confirmar que POST funciona (dummy)
def test_view_handles_post_request(self):
response = self.client.post("/", data={"item_text": "Comprar livro de TDD"})
self.assertContains(response, "Comprar livro de TDD")
def home_page(request):
if request.method == "POST":
return HttpResponse(request.POST["item_text"])
return render(request, "home.html")
- Atualizar html
<tr><td>{{ new_item_text }}</td></tr>
- Red: Atualizar teste: utilizando template correto
- Green: Teste passando
- Refactor: Remove "if" do POST
def home_page(request):
return render(request, "home.html", {"new_item_text": request.POST["item_text"]})
- Rodar testes novamente: KeyError 'item_text'
- Ao carregar a página:
GET /
semitem_text
- Adicionar um valor padrão:
request.POST.get("item_text", "")
- Ao carregar a página:
def home_page(request):
return render(request, "home.html", {"new_item_text": request.POST.get("item_text", "")})
Ambos testes passando!
- Está falhando - sem numeração
- Prefixar "1: " no nosso item
- Teste vai passar mas ainda não é exatamente o que queremos...
- Precisamos persistir os dados!
- Atualizar teste funcional
# 6. Ela adiciona "Comprar pão" e pressiona Enter
input_box.send_keys("Comprar pão")
input_box.send_keys(Keys.ENTER)
time.sleep(1)
# 7. A página é atualizada e agora ela vê "1: Comprar leite" e "2: Comprar pão" na lista de tarefas
rows = table.find_elements(by=By.TAG_NAME, value="tr")
self.assertTrue(rows[0].text == "1: Comprar leite", f"Texto encontrado: {rows[0].text}")
self.assertTrue(rows[1].text == "2: Comprar pão", f"Texto encontrado: {rows[1].text}")
- Erro: stale element - input_box
- AssertionError: False is not true : Texto encontrado:
1: Comprar pão
- Criar um TESTE primeiro!
class TestTaskModel(TestCase):
def test_create_new_task_in_database(self):
task = Task()
task.text = "Comprar leite"
task.save()
task_db = Task.objects.get(id=task.pk)
self.assertEqual(task_db.text, "Comprar leite")
- Red / Green - passo a passo
- Task is not defined: criar Task no models.py
- Task has no attribute save: herdar models.Model
- no such table: todolist_task:
python manage.py makemigrations & migrate
- AttributeError: 'Task' object has no attribute 'text': adicionar campo text
- django.db.utils.OperationalError: no such column: todolist_task.text...
def test_POST_save_task_to_database(self):
self.client.post("/", data={"item_text": "Comprar leite"})
task = Task.objects.first()
self.assertEqual(task.text, "Comprar leite")
- AttributeError: 'NoneType' object has no attribute 'text': precisamos corrigir nossa view!
def home_page(request):
text = request.POST.get("item_text", "")
task = Task.objects.create(text=text)
return render(
request,
"home.html",
{"new_item_text": text}
)
- Agora o teste passa, mas qual o problema?
- Criando uma task toda vez que a view é accessada!
- Poderíamos escrever um teste para expôr o bug!
test_get_request_creates_empty_task
text = request.POST.get("item_text", "")
if request.method == "POST":
Task.objects.create(text=text)
sqlite3 db.sqlite3
.tables
.schema todolist_task
SELECT * FROM todolist_task;
.quit
ou
from todolist.models import Task
Task.objects.all()
- Escrever um teste
def test_GET_return_template_with_existing_tasks(self):
Task.objects.create(text='Primeira tarefa')
Task.objects.create(text='Segunda tarefa')
response = self.client.get('/')
self.assertContains(response, 'Primeira tarefa')
self.assertContains(response, 'Segunda tarefa')
AssertionError: False is not true : Couldn't find 'Primeira tarefa' in response
- Atualizar o template
<table id="id_lista_tarefas">
{% for task in tasks %}
<tr><td>{{ forloop.counter }}: {{ task.text }}</td></tr>
{% endfor %}
</table>
- Não precisamos mais do "item_text" no contexto
- Teste funcional continua falhando - Vamos abrir a aplicação
- Múltiplos items!
rm db.sqlite3
python manage.py migrate --noinput
Ao rodar o teste funcional:
E AssertionError: False is not true : Texto encontrado: 1
- Está compartilhando o mesmo banco de dados de desenvolvimento!
- Isso não acontece com os testes de view porque eles rodam em um banco de dados separado (TestCase do Django)
LiveServerTestCase
roda em um servidor separadoself.browser.get(self.live_server_url)