Test Runner Django

O test runner padrão do django é o unittest. E é possivel roda-lo diretamente no projeto usando python manage.py test -v1. (usando a opção v1 é o nivel de verbosidade que vai até v3, e é opcional )

TestCase é uma classe de teste fornecida pelo próprio Django que herda do unittest.TestCase do Python, mas com várias melhorias específicas para projetos Django. Tem varios métodos de asserção que vêm do unittest.TestCase, que é herdado por django.test.TestCase. Obs.: se precisar de mais velocidade e puder utilizar o unittest.TestCase, será mais rapido que o TestCase (que herda muito mais classes).

Alguns métodos de asserção de TestCase:
Método Verifica
self.assertEqual(a, b) se a == b
self.assertNotEqual(a, b) se a != b
self.assertTrue(x) se x é True
self.assertFalse(x) se x é False
self.assertIsNone(x) se x is None
self.assertIn(a, b) se a está dentro de b
self.assertRaises() se uma exceção específica é levantada

O interessante de usar o TestCase é que, o Django cria um ambiente isolado. Garante que cada teste rode com banco de dados limpo (cria um banco de dados tempórario para cada teste). Permite usar o cliente de teste (self.client) para fazer requisições simuladas como GET, POST e testar views, respostas e templates.

setUp() é um método especial herdado do unittest.TestCase, que o Django também herda no django.test.TestCase. O Django executa automaticamente o método setUp() antes de cada método de teste. Serve para criar dados, configurar variáveis ou qualquer preparação necessária para os testes.

@skip('mensagem sobre porque está pulando o teste') (from unittest import skip) é um decorador usado para pular testes (funções ou classes). obs.: geralmente a mensagem é 'WIP' - work in progress.

O método subTest é muito útil quando você quer testar vários cenários dentro de um mesmo método de teste, sem que uma falha pare a execução dos outros testes dentro dele. É como um grupo de subtestes de um unico teste.

Testes parametrizados são testes que rodam o mesmo código de teste várias vezes, mas com conjuntos diferentes de dados de entrada e saída esperada. Em vez de você escrever vários métodos de teste, você escreve um único teste, e ele é executado automaticamente para cada conjunto de dados.

Os testes integrados (diferente dos unitários) testam uma situação como um todo de uma vez, envolvendo varios arquivos. por exemplo, testa uma url e a resposta da view ao mesmo tempo. Com o parâmetro follow=True no self.client.post() (ou self.client.get()) nos testes do Django faz com que o cliente de testes siga automaticamente os redirecionamentos (HTTP 302) até chegar na resposta final (sendo util quando temos direcionamento em uma pagina).

padrão/recomendação de diretórios para testes:

pytest.ini
[pytest]
# Define qual é o arquivo de configurações do Django que o pytest deve usar.
DJANGO_SETTINGS_MODULE = project.settings
# Define os arquivos Python a serem testados.
python_files = test.py tests.py test_*.py tests_*.py *_test.py *_tests.py
# rP captura e exibe os prints das funções testes para exibir no output da extensão
addopts = 
  -rP

Este arquivo é um exemplo de configuração para testes python em um projeto django. obs: esse arquivo não aceita comentários nas mesmas linhas dos comandos. 

As configurações gerais dos testes estarão presentes no arquivo pytest.ini que deverá ser colocado na raiz do projeto.

tests_com_testcase_django.py
from django.test import TestCase
from django.urls import reverse, resolve
from recipes import views


class RecipeURLsTest(TestCase):
    # Testa se a url home de recipes está correta (apontando para /)
    def test_recipe_home_url_is_correct(self):
        url = reverse('recipes:home')
        print(f"resultado do reverse = {url}")
        self.assertEqual(url, '/')

    def test_recipe_category_url_is_correct(self):
        # Já que a url requer argumentos, vamos testar com um valor específico
        url = reverse('recipes:category', kwargs={'category_id': 1})
        self.assertEqual(url, '/recipes/category/1/')

    def test_recipe_detail_url_is_correct(self):
        # normalmente uso kwargs, mas aqui args so para exemplo
        url = reverse('recipes:recipe', args=(1,))
        self.assertEqual(url, '/recipes/1/')


class RecipeViewTest(TestCase):
    # Testa se a url home de recipes está apontando para view correta
    def test_recipe_home_view_function_is_correct(self):
        view = resolve('/')
        self.assertIs(view.func, views.home)

    def test_recipe_category_view_function_is_correct(self):
        # Uso o reverso para inserir o argumento category_id ao invés de hardcoded # noqa: E501
        view = resolve(reverse('recipes:category', kwargs={'category_id': 1}))
        self.assertIs(view.func, views.category)

    def test_recipe_detail_view_function_is_correct(self):
        view = resolve('/recipes/1/')
        self.assertIs(view.func, views.recipe)

    def test_recipe_home_view_returns_status_code_200(self):
        # Captura a resposta da requisição
        response = self.client.get(reverse('recipes:home'))
        self.assertEqual(response.status_code, 200)

    def test_recipe_home_view_loads_correct_template(self):
        response = self.client.get(reverse('recipes:home'))
        # Verifica se o template foi usado corretamente
        self.assertTemplateUsed(response, 'recipes/pages/home.html')
    def test_recipe_home_template_shows_no_recipes_if_no_recipes_exist(self):
        # Simula uma situação onde não há receitas no banco de dados
        response = self.client.get(reverse('recipes:home'))
        # Verifica se o texto, "No recipes found", foi renderizado na pagina, decode é feito para o texto ser legivel já que vem no formato de bytes
        self.assertIn(
            'No recipes found',  # Essa frase está no template
            response.content.decode('utf-8')
        )
        # Obs.: geralmente usamos uma frase constante que está em nosso template para verificar se a página foi carregada corretamente # noqa: E501

# Não é uma boa pratica fazer uma fixture como abaixo
    def test_recipe_home_template_loads_recipes(self):
        # Crio categoria, autor e uma receita em meu banco de dados temporário
        category = Category.objects.create(name='Category')
        author = User.objects.create_user(
            first_name='user',
            last_name='name',
            username='username',
            password='123456',
            email='username@email.com',
        )
        recipe = Recipe.objects.create(
            category=category,
            author=author,
            title='Recipe Title',
            description='Recipe Description',
            slug='recipe-slug',
            preparation_time=10,
            preparation_time_unit='Minutos',
            servings=5,
            servings_unit='Porções',
            preparation_steps='Recipe Preparation Steps',
            preparation_steps_is_html=False,
            is_published=True,
        )
        # Simula requisição GET para a página com os dados do meu DB temporário
        response = self.client.get(reverse('recipes:home'))
        # transformo o conteúdo da resposta em string para poder verificar
        # se a receita está presente na página
        content = response.content.decode('utf-8')
        # busco o que está no contexto da pagina
        response_context_recipes = response.context['recipes']

        # Verifico se a receita está presente na página, verificando o título
        self.assertIn(recipe.title, content)
        self.assertIn('10 Minutos', content)
        self.assertIn('5 Porções', content)
        # verifico se o numero de receitas é igual a 1 conforme meu contexto
        self.assertEqual(len(response_context_recipes), 1)

    def test_recipe_category_view_returns_404_if_no_recipes_found(self):
        # Teste de 404 quando não há recipes com o category_id especificado
        response = self.client.get(reverse(
            'recipes:category', kwargs={
                'category_id': 1000})
        )
        self.assertEqual(response.status_code, 404)
tests_in_models.py
def test_recipe_title_raises_error_if_title_has_more_than_65_chars(self):
        self.recipe.title = "A" * 70
        # Se levantar uma exceção o teste passou
        with self.assertRaises(ValidationError):
            # antes de usar o save() use o full_clean() para validar os dados
            self.recipe.full_clean() # o código para aqui.

        # o django não valida tamanho dos caracteres dos models no metodo save,
        # então ele salva mesmo com o erro se não for validado
        self.recipe.save()
        print(f"veja: {self.recipe.title}")

Nesse exemplo, verifamos a validação de tamanho de campos. obs.: nesse exemplo usamos uma receita (recipe), salvo em outro arquivo.

test_subTest_django.py
from django.test import TestCase
from django.urls import reverse, resolve


class TestUrls(TestCase):
    def test_urls_resolvem_views(self):
        urls = {
            'recipes:home': '/',
            'recipes:category': '/recipes/category/1/',
        }

        for name, path in urls.items():
            with self.subTest(url=name):
                self.assertEqual(reverse(name, kwargs={'category_id': 1} if 'category' in name else {}), path)

No pytest, se houver varias falhas no mesmo teste, so mostrará a falha de um unico teste, mas no unittest mostrará quantas falhas houverem dentro desse mesmo teste.

test_parameterized.py
from django.test import TestCase
from django.urls import reverse
from parameterized import parameterized


class TestUrls(TestCase):
    
    @parameterized.expand([
        ("home", "recipes:home", {}, "/"),
        ("category", "recipes:category", {"category_id": 1}, "/recipes/category/1/"),
    ])
    def test_urls_resolvem_views(self, name_case, url_name, kwargs, expected_path):
        url = reverse(url_name, kwargs=kwargs)
        self.assertEqual(url, expected_path)

Exemplo de testes parametrizados com unittest.

Instale a biblioteca externa parameterized.

| Mock Django

test_mock_django.py
from unittest import TestCase
from unittest.mock import patch
from app import views

class MinhaViewTest(TestCase):

     # Diz ao Python: "Durante esse teste, trate NUMERO_DE_ALUNOS da view como 10"
    @patch('app.views.NUMERO_DE_ALUNOS', new=10)
    def test_view_com_numero_mockado(self):
        # Executa a função da view, que agora usa o valor mockado
        resultado = views.minha_view()
        self.assertEqual(resultado, 'Total de alunos: 10') # Valida o resultado esperado

O valor é substituído temporariamente só durante este teste.

views.py
# app/views.py
NUMERO_DE_ALUNOS = 30

def minha_view():
    return f"Total de alunos: {NUMERO_DE_ALUNOS}"

Testes Funcionais

Testes funcionais (ou functional tests), também chamados de End-to-end (E2E), são testes que verificam se o sistema funciona corretamente do ponto de vista do usuário, ou seja, eles simulam ações reais do usuário (como preencher um formulário, clicar em um botão, navegar por páginas) para garantir que os fluxos completos do sistema estão funcionando como esperado.

Selenium é uma ferramenta de automação de navegador usada principalmente para fazer testes funcionais em aplicações web. Ele permite que você controle um navegador real via código — como se fosse um usuário humano clicando, digitando e navegando no site. Em python é uma biblioteca externa instalada com pip. obs.: (atualmente a comunidade está cada vez mais preferindo o Playwright para testes funcionais — inclusive para projetos com Django).

O ChromeDriver é um programa auxiliar (um executável) que permite ao Selenium controlar o navegador Google Chrome automaticamente. Baixe o driver aqui (caso sua versão do selenium esteja abaixo da 4.6.0): https://sites.google.com/chromium.org/driver/. Nesse caso (versão seja antiga), pode ser interessante deixar o chromedrive numa pasta bin na raiz do projeto.

Atualmente não é mais necessário baixar o ChromeDriver, pois o selenium faz isso automáticamente. No windows:  %USERPROFILE%\.cache\selenium\chromedrive, no linux: ~/.cache/selenium/chromedriver.

🧰 Tabela de opções do Chrome (usadas com add_argument())
Argumento (add_argument) Descrição
'--headless' Executa o Chrome sem interface gráfica (modo invisível).
'--disable-gpu' Desativa aceleração por GPU (necessário no modo --headless em alguns casos antigos).
'--no-sandbox' Desativa o sandboxing de segurança (necessário em alguns servidores Linux).
'--disable-dev-shm-usage' Evita usar /dev/shm, útil em ambientes com pouca RAM (ex: Docker).
'--window-size=WIDTH,HEIGHT' Define o tamanho da janela, ex: '--window-size=1920,1080'.
'--start-maximized' Inicia com a janela maximizada.
'--incognito' Abre o navegador em modo anônimo.
'--disable-extensions' Desativa todas as extensões do Chrome.
'--disable-popup-blocking' Desativa o bloqueador de pop-ups.
'--disable-infobars' Remove a barra de informações “Chrome está sendo controlado por software de teste”.
'--lang=pt-BR' Define o idioma do navegador.
'--mute-audio' Silencia todos os sons do navegador.
'--user-agent=STRING' Define um user-agent personalizado.
'--ignore-certificate-errors' Ignora erros de certificados SSL.
'--disable-notifications' Bloqueia notificações do navegador.
'--disable-logging' Reduz o número de logs no console.
'--remote-debugging-port=9222' Habilita a depuração remota (útil para debugging).
'--profile-directory=Default' Usa um perfil específico do Chrome.
'--user-data-dir=/caminho/para/perfil' Usa dados de perfil personalizados.
'--disable-blink-features=AutomationControlled' Tenta evitar a detecção de automação por alguns sites.

Uma ideia para testes funcionais, seria deixa-los numa pasta tests/functional_tests na raiz. Mas o mais comum e padrão de ser utilizado é: seu_app/tests/test_fuctional/.


A classe LiveServerTestCase é uma classe de teste do Django que inicializa um servidor HTTP real e temporário antes dos testes. Esse servidor serve sua aplicação Django completa (HTML, views, rotas, banco de dados, etc.). Permite que ferramentas externas como Selenium (ou cURL, etc.) acessem sua aplicação via HTTP, como um usuário real faria. Uma alternativa que serve os arquivos estáticos seria: StaticLiveServerTestCase (mais pesado).

Comandos Selenium

bash
$ pip install selenium
selenium_manualmente.py
from pathlib import Path
from selenium import webdriver
from selenium.webdriver.chrome.service import Service

ROOT_PATH = Path(__file__).parents[1]
CHROMEDRIVER_NAME = "chromedriver.exe"
CHROMEDRIVER_PATH = ROOT_PATH / 'bin' / CHROMEDRIVER_NAME
# print(CHROMEDRIVER_PATH)

# Especificar o caminho do executável do ChromeDriver
chrome_service = Service(executable_path=CHROMEDRIVER_PATH)

# cria o drive com o caminho do bin
driver = webdriver.Chrome(service=chrome_service)

# Abre um site
driver.get('https://www.google.com')

Modo manual, supondo que o executável baixado esteja em bin/ e o script esteja em utils/ (todos os diretórios na raiz do projeto).
As novas versões do selenium, <= 4.6.0, baixam automaticamente o chrome drive da primeira vez que o codigo é executado. O modo manual serve para Ambiente offline ou restrito.

selenium.py
from selenium import webdriver
import time


def make_chrome_browser(*options):
    # configura as opções do ChromeDriver, para ir adicionando posteriormente
    chrome_options = webdriver.ChromeOptions()

    if options is not None:
        for option in options:
            # adiciona as opções recebidas
            chrome_options.add_argument(option)

    browser = webdriver.Chrome(options=chrome_options)
    return browser


if __name__ == '__main__':
    # cria o navegador com as opções passadas, headless é execução silenciosa
    browser = make_chrome_browser('--headless')
    # Abre o browser
    browser.get("https://google.com")
    time.sleep(5)

O navegador aberto se encerra automáticamente após executar.

test_selenium.py
# importa minha conexão com o selenium
from utils.browser import make_chrome_browser
# importa o buscador de tags
from selenium.webdriver.common.by import By
import time
from django.test import LiveServerTestCase


class HomeFunctionalTest(LiveServerTestCase):
    def sleep(self, seconds=5):
        time.sleep(seconds)

    def test_home_page(self):
        # pego uma conexão ja criada do selenium para chrome
        browser = make_chrome_browser()

        # É a URL base do servidor de teste, gerado automaticamente
        browser.get(self.live_server_url)  # Ex: http://localhost:8081
        self.sleep()
        # Encontre o elemento da pagina pelo nome da tag, nesse caso body
        body = browser.find_element(By.TAG_NAME, 'body')
        # Verifica se a string "Estou na Pagina Home..." está presente no texto do body
        self.assertIn('Estou na Pagina Home', body.text)
        # fecha o navegador
        browser.quit()

Assuntos Relacionados


Code Coverage

Coverage (ou Code Coverage) é uma ferramenta de análise que mostra quais partes do seu código foram executadas durante os testes automatizados. É uma forma de medir quanto do seu código foi testado.

O Python Coverage (ou apenas coverage) é uma ferramenta (externa) de análise de cobertura de testes em código Python. Ele mede quais partes do seu código foram executadas durante os testes e quais não foram — ajudando você a identificar trechos de código que ainda não estão testados. pode-se criar um arquivo na raiz do projeto .coveragerc com as configurações.

Padrão no omit
Padrão Significado
.venv/* Ignora todos os arquivos dentro da pasta .venv (ambiente virtual).
**/*test*.py Ignora qualquer arquivo Python em qualquer subpasta que tenha test no nome, como test_example.py ou mytest.py.
manage.py Ignora o arquivo manage.py (geralmente não precisa testar ele).
.coveragerc
[run]
branch = True
omit = .venv/*,**/*test*.py,manage.py

O omit informa quais pastas não testar. O branch ativa a análise de cobertura de ramificações lógicas (como if, else, try, etc.), não só de linhas.

Comandos Coverage

bash
$ pip install coverage
$ coverage run -m pytest # Utiliza o pytest
$ coverage run manage.py test # utiliza o proprio django para testar
$ coverage html # gera o relatório em html e salva na raiz do projeto na pasta htmlcov
$ coverage erase # limpa todos os arquivos de cobertura