Plataforma Django para execução de casos de teste, logs, gráficos e tarefas agendadas

Otimização da página de logs

O template basse concentra o menu principal e os blocos usados pelas páginas de projetos, logs e gráficos.

Template base

{% load static %}

<html lang="pt-br">
<head>
    <meta charset="utf-8">
    <title>Painel de Testes</title>
    <link rel="stylesheet" href="{% static 'adminlte/css/adminlte.min.css' %}">
</head>
<body class="hold-transition sidebar-mini">
<div class="wrapper">
    <aside class="main-sidebar sidebar-dark-primary elevation-4">
        <a href="{% url 'home' %}" class="brand-link">Painel de Testes</a>
        <div class="sidebar">
            <nav class="mt-2">
                <ul class="nav nav-pills nav-sidebar flex-column">
                    <li class="nav-item"><a href="{% url 'home' %}" class="nav-link">Projetos</a></li>
                    <li class="nav-item"><a href="{% url 'log_list' %}" class="nav-link">Logs</a></li>
                    <li class="nav-item"><a href="{% url 'charts' %}" class="nav-link">Gráficos</a></li>
                </ul>
            </nav>
        </div>
    </aside>
    <div class="content-wrapper">
        <section class="content">
            <div class="container-fluid">
                {% block content %}{% endblock %}
            </div>
        </section>
    </div>
</div>
<script src="{% static 'adminlte/js/adminlte.min.js' %}"></script>
{% block js %}{% endblock %}
</body>
</html>

Lista de logs

{% extends 'base.html' %}
{% block content %}
<table class="table table-striped table-hover table-bordered">
    <thead>
    <tr>
        <th>#</th>
        <th>Projeto</th>
        <th>Criado em</th>
        <th>Total</th>
        <th>Falhas</th>
        <th>Erros</th>
        <th>Aprovados</th>
        <th>Taxa</th>
        <th>Relatório</th>
    </tr>
    </thead>
    <tbody>
    {% for log in logs %}
        <tr>
            <td>{{ forloop.counter }}</td>
            <td>{{ log.project.name }}</td>
            <td>{{ log.created_at }}</td>
            <td>{{ log.total_count }}</td>
            <td>{{ log.failed_count }}</td>
            <td>{{ log.error_count }}</td>
            <td>{{ log.passed_count }}</td>
            <td>{{ log.pass_rate }}</td>
            <td><a href="{% url 'preview_log' log.pk %}">Visualizar</a></td>
        </tr>
    {% endfor %}
    </tbody>
</table>
{% endblock %}

Views principais

import json
from io import BytesIO
from django.http import JsonResponse, HttpResponse, FileResponse
from django.shortcuts import render, redirect
from django.utils.encoding import escape_uri_path
from app01 import models
from utils.forms import ProjectForm, CaseForm
from utils.runner import TestRunner
from utils.charts import ChartData


def home(request):
    projects = models.Project.objects.all()
    return render(request, "index.html", {"projects": projects})


def project_create(request):
    form = ProjectForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        form.save()
        return redirect("home")
    return render(request, "project_form.html", {"form": form})


def project_update(request, pk):
    project = models.Project.objects.filter(pk=pk).first()
    form = ProjectForm(request.POST or None, instance=project)
    if request.method == "POST" and form.is_valid():
        form.save()
        return redirect("home")
    return render(request, "project_form.html", {"form": form})


def project_delete(request, pk):
    models.Project.objects.filter(pk=pk).delete()
    return redirect("home")


def case_list(request, pk):
    project = models.Project.objects.filter(pk=pk).first()
    cases = models.TestCase.objects.filter(project_id=pk)
    return render(request, "case_list.html", {"cases": cases, "project": project})


def case_create(request, pk):
    form = CaseForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        case = form.save(commit=False)
        case.project_id = pk
        case.save()
        return redirect("case_list", pk=pk)
    return render(request, "case_form.html", {"form": form})


def case_update(request, pk):
    case = models.TestCase.objects.filter(pk=pk).first()
    form = CaseForm(request.POST or None, instance=case)
    if request.method == "POST" and form.is_valid():
        case = form.save(commit=False)
        case.passed = False
        case.executed = False
        case.report = ""
        case.save()
        return redirect("case_list", pk=case.project_id)
    return render(request, "case_form.html", {"form": form})


def case_delete(request, pk):
    case = models.TestCase.objects.filter(pk=pk).first()
    project_id = case.project_id
    case.delete()
    return redirect("case_list", pk=project_id)


def execute_cases(request, pk=0):
    if request.headers.get("x-requested-with") == "XMLHttpRequest":
        ids = json.loads(request.POST.get("selected_cases", "[]"))
        cases = models.TestCase.objects.filter(pk__in=ids)
        TestRunner.run(cases)
        return JsonResponse({"redirect": "/logs/"})

    case = models.TestCase.objects.filter(pk=pk).first()
    TestRunner.run([case])
    return redirect("log_list")


def download_case_report(request, pk):
    case = models.TestCase.objects.filter(pk=pk).first()
    content = BytesIO(case.report.encode("utf-8"))
    return FileResponse(
        content,
        as_attachment=True,
        filename=f"{case.name}.html",
    )


def log_list(request):
    logs = models.ExecutionLog.objects.all()
    return render(request, "logs_list.html", {"logs": logs})


def preview_log(request, pk):
    if request.method == "POST":
        log_id = request.POST.get("log_id")
        log = models.ExecutionLog.objects.filter(pk=log_id).first()
        content = BytesIO(log.report.encode("utf-8"))
        return FileResponse(
            content,
            as_attachment=True,
            filename=f"{log.project.name}.html",
        )

    log = models.ExecutionLog.objects.filter(pk=pk).first()
    return render(request, "preview.html", {"log": log})

URLs

from django.urls import path, re_path
from app01 import views

urlpatterns = [
    path("", views.home, name="home"),
    path("projects/", views.home, name="home"),
    path("projects/add/", views.project_create, name="project_create"),
    re_path(r"^projects/(?P<pk>\d+)/edit/$", views.project_update, name="project_update"),
    re_path(r"^projects/(?P<pk>\d+)/delete/$", views.project_delete, name="project_delete"),
    re_path(r"^projects/(?P<pk>\d+)/cases/$", views.case_list, name="case_list"),
    re_path(r"^projects/(?P<pk>\d+)/cases/add/$", views.case_create, name="case_create"),
    re_path(r"^cases/(?P<pk>\d+)/edit/$", views.case_update, name="case_update"),
    re_path(r"^cases/(?P<pk>\d+)/delete/$", views.case_delete, name="case_delete"),
    re_path(r"^cases/(?P<pk>\d+)/run/$", views.execute_cases, name="execute_case"),
    re_path(r"^cases/(?P<pk>\d+)/report/$", views.download_case_report, name="download_case_report"),
    path("logs/", views.log_list, name="log_list"),
    re_path(r"^logs/(?P<pk>\d+)/preview/$", views.preview_log, name="preview_log"),
    path("charts/", views.show_charts, name="charts"),
    re_path(r"^projects/(?P<pk>\d+)/upload/$", views.upload_cases, name="upload_cases"),
]

Modelos

from django.db import models


class Project(models.Model):
    name = models.CharField(max_length=32, default="", verbose_name="Nome do projeto")
    description = models.TextField(max_length=255, default="", verbose_name="Descrição")
    start_date = models.DateField(verbose_name="Início")
    end_date = models.DateField(verbose_name="Término")

    def __str__(self):
        return self.name

    def coverage_rate(self):
        total = self.test_cases.count()
        if not total:
            return "0.0%"
        passed = self.test_cases.filter(passed=True).count()
        return f"{(passed / total) * 100:.2f}%"


class TestCase(models.Model):
    project = models.ForeignKey(
        Project,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="test_cases",
        verbose_name="Projeto",
    )
    name = models.CharField(max_length=32, default="", verbose_name="Nome do caso")
    description = models.CharField(max_length=255, default="", verbose_name="Descrição")
    url = models.CharField(max_length=255, default="", verbose_name="URL")
    method = models.CharField(max_length=32, default="", verbose_name="Método HTTP")
    params = models.CharField(max_length=255, default="{}", verbose_name="Parâmetros")
    data = models.CharField(max_length=255, default="{}", verbose_name="Corpo")
    expected = models.CharField(max_length=4196, default="{}", verbose_name="Resultado esperado")
    report = models.TextField(default="", verbose_name="Relatório")
    run_at = models.DateTimeField(null=True, blank=True, verbose_name="Executado em")
    passed = models.BooleanField(default=False, verbose_name="Aprovado")
    executed = models.BooleanField(default=False, verbose_name="Executado")

    def __str__(self):
        return self.name


class ExecutionLog(models.Model):
    report = models.TextField(default="", verbose_name="Relatório")
    project = models.ForeignKey(
        Project,
        on_delete=models.CASCADE,
        related_name="logs",
        verbose_name="Projeto",
    )
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="Criado em")
    passed_count = models.IntegerField(verbose_name="Aprovados")
    failed_count = models.IntegerField(verbose_name="Falhas")
    error_count = models.IntegerField(verbose_name="Erros")
    total_count = models.IntegerField(verbose_name="Total executado")

    @property
    def pass_rate(self):
        if not self.total_count:
            return "0%"
        return f"{(self.passed_count / self.total_count) * 100:.0f}%"

    class Meta:
        ordering = ["-created_at"]

Os formulários podem ser definidos como ModelForm:

from django import forms
from app01 import models


class ProjectForm(forms.ModelForm):
    class Meta:
        model = models.Project
        fields = "__all__"


class CaseForm(forms.ModelForm):
    class Meta:
        model = models.TestCase
        exclude = ("report", "run_at", "passed", "executed")

Pré-visualização de logs

{% extends 'base.html' %}
{% block content %}
<div class="card card-primary">
    <div class="card-header">
        <h3 class="card-title">[{{ log.project.name }}] Relatório</h3>
    </div>
    <div class="card-body">
        <div class="chart">
            {{ log.report|safe }}
        </div>
        <form method="post">
            {% csrf_token %}
            <input type="hidden" name="log_id" value="{{ log.pk }}">
            <a href="{% url 'log_list' %}" class="btn btn-default">Voltar</a>
            <button type="submit" class="btn btn-success">Baixar relatório</button>
        </form>
    </div>
</div>
{% endblock %}

Visualização

Três abordagens comuns para gráficos são Highcharts, ECharts e Pyecharts. No modelo com ECharts ou Highcharts, o frontend obtém os dados do backend e renderiza o gráfico. No Pyecharts, o backend gera o HTML do gráfico.

Pyecharts

import webbrowser
from pyecharts import Pie


def draw_gender_pie(contacts):
    labels = ["Masculino", "Feminino", "Não informado"]
    counts = [0, 0, 0]

    for contact in contacts:
        if contact.sex == 1:
            counts[0] += 1
        elif contact.sex == 2:
            counts[1] += 1
        else:
            counts[2] += 1

    chart = Pie("Distribuição por gênero")
    chart.add("", labels, counts, is_label_show=True)
    chart.render("genero.html")
    webbrowser.open("genero.html")

ECharts

Baixe o arquivo echarts.min.js, coloque-o no diretório static e referencie no template. O fluxo é: criar um elemetno com id, instanciar echarts.init, configurar opções e chamar setOption.

def show_charts(request):
    if request.headers.get("x-requested-with") == "XMLHttpRequest":
        chart_data = ChartData()
        payload = {}
        payload.update(chart_data.pie())
        payload.update(chart_data.line_simple())
        return JsonResponse(payload)

    return render(request, "show_tab.html")
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="card card-primary">
    <div class="card-header">
        <h3 class="card-title">Casos nos últimos 12 meses</h3>
    </div>
    <div class="card-body">
        <div id="line-chart" style="height: 400px;"></div>
    </div>
</div>

<div class="row">
    <div class="col-md-6">
        <div class="card card-primary">
            <div class="card-header"><h3 class="card-title">Execução</h3></div>
            <div class="card-body"><div id="execute-chart" style="height: 400px;"></div></div>
        </div>
    </div>
    <div class="col-md-6">
        <div class="card card-primary">
            <div class="card-header"><h3 class="card-title">Aprovação</h3></div>
            <div class="card-body"><div id="pass-chart" style="height: 400px;"></div></div>
        </div>
    </div>
</div>
{% csrf_token %}
{% endblock %}

{% block js %}
<script src="{% static 'echarts.min.js' %}"></script>
<script>
function renderPie(elementId, labels, values, seriesName) {
    const chart = echarts.init(document.getElementById(elementId));
    chart.setOption({
        tooltip: { trigger: 'item', formatter: '{a} <br/>{b} : {c} ({d}%)' },
        legend: { orient: 'vertical', left: 'left', data: labels },
        series: [{
            name: seriesName,
            type: 'pie',
            radius: '55%',
            center: ['50%', '60%'],
            data: values,
            emphasis: {
                itemStyle: {
                    shadowBlur: 10,
                    shadowOffsetX: 0,
                    shadowColor: 'rgba(0,0,0,0.5)'
                }
            }
        }]
    });
}

function renderLine(elementId, labels, values) {
    const chart = echarts.init(document.getElementById(elementId));
    chart.setOption({
        xAxis: { type: 'category', boundaryGap: false, data: labels },
        yAxis: { type: 'value' },
        series: [{ data: values, type: 'line', areaStyle: {} }]
    });
}

function loadCharts() {
    $.post("{% url 'charts' %}", {
        csrfmiddlewaretoken: $("[name=csrfmiddlewaretoken]").val()
    }, function (data) {
        renderPie("pass-chart", data.pass_pie.title, data.pass_pie.data, "Casos");
        renderPie("execute-chart", data.execute_pie.title, data.execute_pie.data, "Casos");
        renderLine("line-chart", data.line_simple.title, data.line_simple.data);
    });
}

loadCharts();
</script>
{% endblock %}
import datetime
from collections import defaultdict
from app01 import models


class ChartData:
    def pie(self):
        approved = models.TestCase.objects.filter(passed=True).count()
        rejected = models.TestCase.objects.filter(passed=False).count()
        executed = models.TestCase.objects.filter(executed=True).count()
        pending = models.TestCase.objects.filter(executed=False).count()

        return {
            "pass_pie": {
                "title": ["Aprovado", "Reprovado"],
                "data": [
                    {"value": approved, "name": "Aprovado"},
                    {"value": rejected, "name": "Reprovado"},
                ],
            },
            "execute_pie": {
                "title": ["Executado", "Não executado"],
                "data": [
                    {"value": executed, "name": "Executado"},
                    {"value": pending, "name": "Não executado"},
                ],
            },
        }

    def line_simple(self):
        end_date = datetime.date.today()
        start_date = end_date - datetime.timedelta(days=365)
        projects = models.Project.objects.filter(start_date__range=(start_date, end_date))

        monthly = defaultdict(int)
        for project in projects:
            month = project.start_date.strftime("%Y-%m")
            monthly[month] += project.test_cases.count()

        ordered = sorted(monthly.items())
        return {
            "line_simple": {
                "title": [month for month, _ in ordered],
                "data": [count for _, count in ordered],
            }
        }

Tarefas agendadas

Ferramentas comuns para agendamento incluem crontab, tarefas agendadas do Django, APScheduler e Celery. Neste exemplo, usa-se APScheduler.

pip install apscheduler

Execução em data específica

import datetime
from apscheduler.schedulers.blocking import BlockingScheduler


def tarefa_data(texto):
    print("tarefa_data", datetime.datetime.now(), texto)


agendador = BlockingScheduler()
agendador.add_job(
    tarefa_data,
    "date",
    run_date=datetime.datetime(2025, 2, 25, 19, 5, 6),
    args=["exemplo"],
    id="tarefa_data",
)
agendador.start()

Intervalo fixo

import datetime
from apscheduler.schedulers.blocking import BlockingScheduler


def tarefa_intervalo():
    print("tarefa_intervalo", datetime.datetime.now())


agendador = BlockingScheduler()
agendador.add_job(tarefa_intervalo, "interval", seconds=5, id="tarefa_intervalo")
agendador.start()

Execução diária em horário fixo

from apscheduler.schedulers.blocking import BlockingScheduler


agendador = BlockingScheduler()


@agendador.scheduled_job("cron", day_of_week="*", hour=11, minute=56, second=2)
def verificar_banco():
    print("verificação executada")


if __name__ == "__main__":
    agendador.start()

Envio de e-mail com Django

Configuração SMTP

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_USE_TLS = True
EMAIL_USE_SSL = False
EMAIL_HOST = "smtp.example.com"
EMAIL_PORT = 25
EMAIL_HOST_USER = "no-reply@example.com"
EMAIL_HOST_PASSWORD = "SUA_SENHA"
EMAIL_RECIPIENTS = ["destino@example.com"]

Envio simples

from django.conf import settings
from django.core.mail import send_mail
from django.http import HttpResponse


def send_simple_email(request):
    send_mail(
        subject="Título do e-mail",
        message="Conteúdo do e-mail",
        from_email=settings.EMAIL_HOST_USER,
        recipient_list=settings.EMAIL_RECIPIENTS,
        fail_silently=False,
    )
    return HttpResponse("OK")

Envio com anexo

from django.conf import settings
from django.core.mail import EmailMessage
from django.http import HttpResponse


def send_email_with_attachment(request):
    message = EmailMessage(
        subject="Relatório de execução",
        body="Segue o relatório em anexo.",
        from_email=settings.EMAIL_HOST_USER,
        to=settings.EMAIL_RECIPIENTS,
    )
    message.attach_file("/caminho/relatorio.html")
    message.send(fail_silently=False)
    return HttpResponse("OK")

O envio de e-mail pode ser combinado com a tarefa agendada para entregar o relatório após a execução em lote.

Upload de arquivos

O upload pode ser feito por formulário tradicional ou por AJAX. No exemplo abaixo, usa-se AJAX com FormData e leitura do Excel com xlrd dentro de uma transação atômica.

import xlrd
from django.db import transaction
from django.http import JsonResponse
from django.shortcuts import render
from app01 import models


def upload_cases(request, pk):
    if request.headers.get("x-requested-with") == "XMLHttpRequest":
        try:
            with transaction.atomic():
                excel_file = request.FILES.get("file")
                project_id = request.POST.get("project_id")
                workbook = xlrd.open_workbook(file_contents=excel_file.read())
                sheet = workbook.sheet_by_index(0)
                headers = sheet.row_values(0)
                rows = [
                    dict(zip(headers, sheet.row_values(index)))
                    for index in range(1, sheet.nrows)
                ]

                for row in rows:
                    models.TestCase.objects.create(
                        project_id=project_id,
                        name=row["title"],
                        description=row["desc"],
                        url=row["url"],
                        method=row["method"],
                        params=row["params"],
                        data=row["data"],
                        expected=row["expect"],
                    )

            return JsonResponse({
                "status": 200,
                "redirect": f"/projects/{pk}/cases/",
            })
        except Exception as exc:
            return JsonResponse({
                "status": 500,
                "errors": f"Envie um arquivo XLS/XLSX válido. Detalhe: {exc}",
                "redirect": f"/projects/{pk}/cases/",
            })

    return render(request, "upload.html", {"project_id": pk})

Versões recentes do xlrd podem não ler arquivos .xlsx. Se necessário, use pip install xlrd==1.2.0.

{% extends 'base.html' %}
{% block content %}
<div class="alert alert-warning" id="alert-box">
    <strong>Atenção:</strong> envie apenas arquivos Excel compatíveis.
</div>
<div class="form-group">
    <label for="file-input">Arquivo</label>
    <input type="file" id="file-input" class="form-control">
    <button id="upload-btn" class="btn btn-primary mt-2">Enviar</button>
</div>
<span id="error-message"></span>
<div id="project-id" hidden>{{ project_id }}</div>
{% csrf_token %}
{% endblock %}

{% block js %}
<script>
$("#upload-btn").click(function () {
    const formData = new FormData();
    const file = document.getElementById("file-input").files[0];
    const projectId = $("#project-id").text();

    formData.append("file", file);
    formData.append("project_id", projectId);
    formData.append("csrfmiddlewaretoken", $("[name=csrfmiddlewaretoken]").val());

    $.ajax({
        url: "/projects/" + projectId + "/upload/",
        type: "POST",
        data: formData,
        processData: false,
        contentType: false,
        success: function (response) {
            if (response.status === 200) {
                window.location.href = response.redirect;
            } else {
                $("#alert-box").attr("class", "alert alert-danger");
                $("#error-message").text(response.errors);
            }
        }
    });
});
</script>
{% endblock %}

Na página de projetos, adicione o link de importação:

<a href="{% url 'upload_cases' project.pk %}" class="btn btn-default btn-sm">Importar em lote</a>

Atividades práticas

Implemente a pré-visualização do relatório com botão de retorno e download, os três gráficos com ECharts e uma tarefa agendada que verifica projetos com término no dia atual.

Requisito da tarefa: diariamente às 1:30:50, verificar os projetos cujo end_date é o dia atual, executar todos os casos associados e gerar um registro em ExecutionLog. Como extensão, enviar o relatório por e-mail e executar o agendador em uma thread separada.

import datetime
from apscheduler.schedulers.blocking import BlockingScheduler
from app01 import models
from utils.runner import TestRunner


def check_projects():
    today = datetime.date.today()
    projects = models.Project.objects.filter(end_date=today)

    for project in projects:
        cases = project.test_cases.all()
        if cases.exists():
            TestRunner.run(cases)


def start_scheduler():
    scheduler = BlockingScheduler()
    scheduler.add_job(
        check_projects,
        "cron",
        hour=1,
        minute=30,
        second=50,
        id="check_projects",
    )
    scheduler.start()
import os
import sys
from threading import Thread


def main():
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plataforma.settings")
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError("Django não está disponível no PYTHONPATH.") from exc

    from utils.CrontabHandler import start_scheduler

    worker = Thread(target=start_scheduler, daemon=True)
    worker.start()

    execute_from_command_line(sys.argv)


if __name__ == "__main__":
    main()

Execução de casos e geração de relatórios

import json
import datetime
import unittest
import requests
from io import BytesIO
from deepdiff import DeepDiff
from HTMLTestRunner import HTMLTestRunner
from app01 import models


class DynamicApiTest(unittest.TestCase):
    def test_response(self):
        self._testMethodDoc = self.case_description
        diff = DeepDiff(self.actual, self.expected)
        self.assertFalse(diff, msg=f"Diferença encontrada: {diff}")


class RequestRunner:
    def __init__(self, case):
        self.case = case

    def execute(self):
        return requests.request(
            method=self.case.method,
            url=self.case.url,
            params=self._json_or_empty(self.case.params),
            data=self._json_or_empty(self.case.data),
        )

    def build_test(self, response):
        test = DynamicApiTest(methodName="test_response")
        test.case_description = self.case.description
        test.actual = response.json()
        test.expected = self._json_or_empty(self.case.expected)
        return test

    def save_single_report(self, test):
        suite = unittest.TestSuite()
        suite.addTest(test)
        stream = BytesIO()

        result = HTMLTestRunner(
            stream=stream,
            title=self.case.name,
            description=self.case.description,
        ).run(suite)

        obj = models.TestCase.objects.get(pk=self.case.pk)
        obj.report = stream.getvalue()
        obj.run_at = datetime.datetime.now()
        obj.executed = True
        obj.passed = result.wasSuccessful()
        obj.save()

    @staticmethod
    def _json_or_empty(value):
        return json.loads(value) if value else {}


class BatchRunner:
    @staticmethod
    def run(cases):
        suite = unittest.TestSuite()
        last_case = None

        for case in cases:
            last_case = case
            runner = RequestRunner(case)
            response = runner.execute()
            test = runner.build_test(response)
            suite.addTest(test)
            runner.save_single_report(test)

        if last_case:
            BatchRunner.save_batch_report(last_case, suite)

    @staticmethod
    def save_batch_report(last_case, suite):
        stream = BytesIO()
        result = HTMLTestRunner(
            stream=stream,
            title="Execução em lote",
            description=f"Projeto: {last_case.project.name}",
        ).run(suite)

        models.ExecutionLog.objects.create(
            report=stream.getvalue(),
            project_id=last_case.project_id,
            passed_count=getattr(result, "success_count", 0),
            failed_count=getattr(result, "failure_count", 0),
            error_count=getattr(result, "error_count", 0),
            total_count=result.testsRun,
        )

Tags: Django echarts APScheduler Python unittest

Publicado em 10-21 02:12