Skip to content

Instantly share code, notes, and snippets.

@gcrsaldanha
Created April 2, 2024 20:13
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gcrsaldanha/49f4a85a031081c0e216fe4e3b94e011 to your computer and use it in GitHub Desktop.
Save gcrsaldanha/49f4a85a031081c0e216fe4e3b94e011 to your computer and use it in GitHub Desktop.
Roteiro PythOnRio Abril - 2024 - Test Driven Development com Python/Django

Test Driven Development com Python e Django

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

Disclaimer

Esse roteiro é baseado no livro Test-Driven Development with Python de Harry Percival.

Objetivo

Aplicar Test Driven Development em um projeto simples de lista de tarefas (todo list).

Agenda

Tópicos

  • 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

1. Configurando o ambiente

2. Nosso primeiro teste: Django instalado

  • 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/

3. Melhorando nossa suíte de testes

  • Utilizando unittest.TestCase (standard library)
  • setUp e tearDown

4. Testes funcionais

  • 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

4.1. Teste funcional: Adicionar tarefas (Jornada do Usuário)

  1. Alice acessa o site de lista de tarefas
  2. Ela observa o cabeçalho "Tarefas"
  3. Ela observa um campo de texto para adicionar uma tarefa
  4. Ela digita "Comprar leite" e pressiona Enter
  5. A página é atualizada e agora ela vê "1: Comprar leite" na lista de tarefas
  6. Ela adiciona "Comprar pão" e pressiona Enter
  7. A página é atualizada e agora ela vê "1: Comprar leite" e "2: Comprar pão" na lista de tarefas
  8. Alice fecha o navegador

Vamos implementar até o ponto 2, depois disso, vamos...

5. Configurar nosso projeto Django

  • 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

Arquitetura do Django

  • Django Request/Response
  • tests.py
  • urls.py (router)
  • views.py (controller)
  • models.py (model/ORM)
  • templates/ (view/UI)

Testes unitários

  • 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 tipo bytes, não uma string
  • Corrigir a view até o teste passar.
  • Red / Green -> Refactor!

Configurar pytest com Django

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

6. Refactor: Utilizando templates

  • 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 no settings.py

✅ Teste passando!

Atualizando teste funcional

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

Refatorando os testes

  • 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")

7. Adicionando tarefas

  • 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}")

Jornada do usuário 1 a 5

  1. id_new_item não encontrado
<input id="id_new_item" placeholder="Nova tarefa" />
  1. table não encontrada
<input id="id_new_item" placeholder="Nova tarefa" />
<table id="id_lista_tarefas"></table>
  1. 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.

8. Salvando input do usuário

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

8.1 Adicionando contexto ao template

  • 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 / sem item_text
    • Adicionar um valor padrão: request.POST.get("item_text", "")
def home_page(request):
    return render(request, "home.html", {"new_item_text": request.POST.get("item_text", "")})

Ambos testes passando!

8.2 De volta para o teste funcional

  • 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!

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

9.1 Django Model: Task

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

9.2 View: salvar no banco de dados

  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)

9.3 Verificar manualmente tarefas salvas

sqlite3 db.sqlite3
.tables
.schema todolist_task
SELECT * FROM todolist_task;
.quit

ou

from todolist.models import Task
Task.objects.all()

10 Renderizando múltiplas tarefas

  • 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

11. Teste funciona - LiveServerTestCase

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 separado
  • self.browser.get(self.live_server_url)

Discussão: Unit test vs Functional test

Discussão: Injeção de dependência e mocks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment