Utilizando o FastAPI – parte 4

Captura de tela com a saída da execução do pytest apresentando os testes executados e a cobertura dos mesmos. Maiores detalhes em "Executando os testes" mais adiante.

Lembrando que na primeira e segunda partes foram apresentados o FastAPI e o pydantic que foram usados, respectivamente, para a implementação dos métodos HTTP e para a validação dos dados. Já a terceira parte acrescentou suporte para bancos de dados com uma ajuda do SQLAlchemy.

E para finalizar, algo que deveria ter sido montado em paralelo desde o começo mas que acabou ficando para esta parte, as rotinas de teste… 🙂

Fixtures em pytest

Há algum tempo escrevi sobre o pytest, então posso “pular” a parte introdutória e tratar logo das fixtures

Elas são a forma usada pelo pytest para configurar e ajustar o ambiente antes da execução dos testes e são usadas para definir variáveis, instanciar classes, levantar serviços etc. Elas são criadas através do decorador @pytest.fixture e passadas para os testes como se fossem argumentos de função.

Especificamente para este projeto foram criadas duas fixtures em “./testes/fixtures.py” que usam o Faker para a geração de dados fictícios para o cadastro de estudantes.

import pytest
from faker import Faker
from .datatypes import StudentType

fake = Faker(["pt-BR"])

@pytest.fixture(scope="function")
def student() -> StudentType:
    postcode = fake.postcode()
    return {
        "name": fake.name(),
        "address": fake.street_address(),
        "neighbour": fake.neighborhood(),
        "city": fake.city(),
        "state": fake.state(),
        "postal_code": postcode[0:5] + "-" + postcode[-3:],
    }

@pytest.fixture(scope="function")
def student_name() -> StudentType:
    return {
        "name": fake.name(),
    }

Além de carregar os módulos necessários, o Faker é inicializado e configurado para retornar dados fictícios e localizados para português brasileiro.

A função student() retorna um dicionário completo contendo todos os dados necessários para se criar um novo estudante enquanto que student_name() produz um dicionário mais simples e contendo apenas o nome do estudante.

E como preciso que cada teste trabalhe com um conjunto diferente de dados o escopo das duas fixtures está definido em “função”, o que fará o pytest executá-las a cada teste.

Um pequeno ajuste

Antes de seguir adiante com as rotinas de teste, um pequeno e necessário ajuste no código. Dentro do arquivo “./api/config.py” está a URL de conexão com o banco de dados e que é definida da seguinte maneira.

SQLALCHEMY_DATABASE_URL = "sqlite:///db.sqlite3"

É prático durante o desenvolvimento mas é algo que não se deve fazer já que, sendo uma configuração específica do ambiente de execução, não faz muito sentido alterar o código cada vez que este é alterado, mesmo estando um local específico — e neste caso, como está, não é prático alterar o código apenas para a execução dos testes. Então é necessário fazer uma pequena modificação na aplicação e deixá-la mais flexível, ou seja, pegar a URL de configuração a partir de uma variável de ambiente, por exemplo.

Para facilitar (a minha) vida vou usar minha própria ração para cachorro aqui e recorrer ao Tomatic¹.

from tomatic import Tomatic
from tomatic.buckets import EnvironBucket

t = Tomatic(
    EnvironBucket,
    static_profile="FASTAPI",
    raise_if_none=ValueError
)

SQLALCHEMY_DATABASE_URL = t.DATABASE__str__

O Tomatic é configurado para trabalhar com variáveis de ambiente (o EnvironBucket), usando o perfil “FASTAPI” e gerando uma exceção de ValueError toda vez que uma chave não retorne alor.

A URL de conexão será armazenada em “DATABASE” e recuperada como sendo uma string (que é comportamento padrão do Tomatic, logo poderia se usar “t.DATABASE” apenas). E na prática isto significa que a URL de conexão deverá ser armazenada na variável de ambiente “FASTAPI__DATABASE”.

$ pip install -r requirements.txt
...
$ FASTAPI__DATABASE="sqlite:///db.sqlite3" uvicorn api:app
INFO: Started server process [6313]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

Pronto, desejando alterar a URL, para usar outro banco ou mesmo um outro SGBD, basta alterar o conteúdo da variável de ambiente e nada mais.

E agora é possível prosseguir com os testes! 🙂

(¹) De forma bem rápida, o Tomatic é uma biblioteca que ajuda a habilitar a automatizar a configuração de programas em Python.

Os testes

O correto seria criar testes para todas as funções para justamente ter certeza de que tudo está funcionando como deveria mas como o foco aqui é o FastAPI preferi testar apenas o necessário, ou seja:

  • Os métodos GET, DELETE, POST e PUT e
  • As validações dos schemas de dados do pydantic.

E como o programa está bem modularizado, a implementação destas rotinas de testes já é mais que suficiente para garantir quase 100% de cobertura.

Aliás, a ideia aqui é não é se aprofundar no uso do pytest mas apenas demostrar como implementar os testes da API.

Cliente de testes do FastAPI

O FastAPI facilita bastante o trabalho por possuir um cliente de testes, o mesmo cliente do Starlette no qual ele é construído e que permite simular as requisições HTTP. E em “./tests/test_http_methods.py” estão definidos os testes da API.

from datetime import datetime

from fastapi.testclient import TestClient

from api import app
from .datatypes import StudentType
from .fixtures import student, student_name

client = TestClient(app)

E além o TestClient também são carregados alguns outros módulos, inclusive as fixtures.

Testando o “/health/”

Lembra do “/health/” e as utilidades dele? Mais uma, mostrar como o TestClient funciona.

def test_health() -> None:

    response = client.get("/health/")

    assert response.status_code == 200
    try:
        assert datetime.fromisoformat(
            response.json().get("timestamp")
        )
    except ValueError:
        assert False

O TestClient é usado para fazer uma requisição GET para “/health/” e duas coisas são verificadas do resultado em ‘response’:

  • Se a requisição HTTP retornou 200 (sucesso) e
  • Se o JSON retornado possui uma chave “timestamp” contendo uma data válida em formato ISO.

O bloco “try … except” serve para tratar de forma correta os valores.

Testando “/students/”

Os testes de “students” seguem a mesma estrutura dos de “health”, isto é, fazem uma conexão via cliente de teste e validam tanto o status do HTTP quanto o conteúdo retornado. A diferença está no fato de utilizarem as fixtures como argumentos e para não ficar repetitivo separei apenas dois testes, os test_success_create_student() e test_success_update_student().

...
class TestStudentsApi:

    def test_success_create_student(
        self,
        student: StudentType
    ) -> None:

        response = client.post(
            "/students/", json=student
        )

        assert response.status_code == 201
        assert response.json().get("id")

    def test_success_update_student(
        self,
        student: StudentType,
        student_name: StudentType
    ) -> None:

        data = client.post("/students/", json=student)
        student_id = data.json().get("id")

        response = client.put(
            f"/students/{student_id}/",
            json=student_name
        )

        assert response.status_code == 201
        assert response.json().get(
            "name") == change_student.get("name")

O test_success_create_student() recebe um conjunto de dados aleatórios de estudante de students() e o usa para fazer uma requisição POST e inseri-lo no baco de dados. Com a execução bem sucedida é verificado os seguinte em ‘response’:

  • Se a requisição HTTP resultou o status 201 (criado) e
  • Se no JSON devolvido há uma chave ‘id’, provando que os dados do estudante foram corretamente armazenados no banco de dados.

Já o test_success_update_student() tem duas etapas, na primeira ele repete os mesmos passos do teste anterior e faz uma inserção de estudante. Daí com o id do registro criado ele chama a rotina de alteração para substituir o nome do estudante usando o valor definido em student_name() e então verifica o resultado em ‘response’:

  • Se a requisição HTTP resultou o status 201 (criado) e
  • Se no JSON devolvido a chave “name” contém o mesmo nome (o novo) que foi enviado para o método PUT.

Os demais testes, ou seja, test_success_retrieve_student(), test_success_retrieve_all_students() e test_success_delete_student(), seguem a mesma lógica de inserir um registro antes de testar o método enquanto que test_fail_retrieve_student(), test_fail_update_student() e test_fail_delete_student() tentam manipular um estudante com id igual a zero — que, ao menos na teoria, não deveria existir no banco de dados… 🙂


Mas qual o motivo de inserir um novo estudante? Não poderia ser usado o registro criado no teste anterior? A resposta é bem simples, os testes precisam ser independentes uns dos outros e não temos certeza absoluta de que serão executados sempre na mesma ordem.

Por exemplo, é possível executar somente o test_success_update_student().

$ pip install -r requirements_dev.txt
...
$ FASTAPI__DATABASE="sqlite:///teste.sqlite3" \
pytest tests/test_http_methods.py::TestStudentsApi::test_success_
update_student
...
 tests/test_http_methods.py ✓                         100% ██████████

Results (0.37s):
       1 passed

E neste caso como fazer para ele pegar o id do registro que foi inserido em test_success_create_student() se ele nem ao menos foi executado?

Testando os schemas

Como são usados para validação eles são parte importante da API e, portanto, precisam ser testados. Como antes, para não ficar me repetindo, vou comentar apenas um deles, o test_state_validation(), já que todos tem a mesma estrutura.

from api.schemas import (
    CreateStudentSchema,
    UpdateStudentSchema
)
from .datatypes import StudentType
from .fixtures import student

class TestStudentchema:

    invalid_state = "Guanabara"
    valid_postal = ["99999-999"]
    invalid_postal = ["99999", "999-999", ""]

    def test_state_validation(
        self, student: StudentType
    ) -> None:

        new_student = CreateStudentSchema(**student)
        try:
            new_student_dict = new_student.dict()
            new_student_dict["state"] = self.invalid_state
            __ = UpdateStudentSchema(**new_student_dict)
        except ValueError:
            assert True
        else:
            assert False

Como estou testando a validação — sim, fica tudo ao contrário — eles usam um bloco “try … except … else” para se certificar que o conteúdo executado gera na exceção de ValueError (True, valores rejeitados) ou sucesso (False, valores aceitos) ao instanciar a classe.

E o mesmo acontece com test_success_student_validation(), test_fail_student_validation() e também test_postal_code_validation(), a diferença está apenas no esquema testado.

Executando os testes

Então, agora, é só rodar os testes…

$ FASTAPI__DATABASE="sqlite:///teste.sqlite3" \
  pytest --cov=api
...
tests/test_http_methods.py ✓✓✓✓✓✓✓✓✓                    69% ███████
tests/test_schema.py ✓✓✓✓                              100% ██████████

----------- coverage: platform linux, python 3.8.2-final-0 -----------
Name                 Stmts   Miss  Cover
----------------------------------------
api/init.py              1      0   100%
api/config.py            5      0   100%
api/crud.py             28      0   100%
api/database.py          7      0   100%
api/datatypes.py         4      0   100%
api/enumerators.py      48      0   100%
api/main.py             40      0   100%
api/models.py           11      0   100%
api/schemas.py          34      0   100%
----------------------------------------
TOTAL                  178      0   100%
Results (0.76s):
      13 passed

Os testes são realizados em um novo banco de dados, o “teste.sqlite3”, que pode ser apagado depois — uma segunda execução incluirá mais registros porém não afetará os testes.

A “mágica” do 100% de cobertura dos testes foi garantida por dois “# pragma: nocover” colocados em dois trechos de “main.py” onde não seria lá muito fácil escrever um teste.

Finalizando

Isto conclui o desenvolvimento da API utilizando o FastAPI incluindo a validação dos dados, persistência dos dados em um banco de dados relacional, os testes necessários para ter certeza de que tudo está funcionando perfeitamente e até mesmo a documentação desta. Os arquivos estão no repositório git do projeto sob a tag “parte-4”.

Uma última coisa o FastAPI pode trabalhar com chamadas assíncronas mas como não é suportado por todos os bancos de dados eu preferi não utilizar.