Olá, Habr!
Meu nome é Vladislav Timashenkov, e eu trabalho com automação de testes na GK Infowatch. Nossa equipe se deparou com problemas comuns em testes automatizados para APIs: uma única alteração na API exige a atualização de vários testes; a verificação da estrutura da resposta está distribuída entre os testes e não centralizada; e a validação de estruturas aninhadas e campos gerados requer código adicional. Diante disso, nos perguntamos: qual ferramenta de validação de contrato seria adequada para nós? Neste artigo, compartilharemos nossa reavaliação da abordagem de teste de API através da implementação do Pydantic.
Vamos considerar um exemplo simples de teste para criação de um usuário:
python# ../your_project/tests/user/test_crud_user.py def test_create_user(): user_creation_body = { "USERNAME": "John_Smith", "EMAIL": "john@example.com", "DISPLAY_NAME": "John Smith", } response = requests.post(f"{SOME_URL}/user", json=user_creation_body) response.raise_for_status() data = response.json() assert data["USERNAME"] == user_creation_body["USERNAME"] assert data["EMAIL"] == user_creation_body["EMAIL"] assert data["DISPLAY_NAME"] == user_creation_body["DISPLAY_NAME"] assert isinstance(data["USER_ID"], int)
Este teste verifica o sucesso da execução da requisição e a conformidade dos dados em campos específicos. No entanto, essa abordagem apresenta várias desvantagens:
- Não há verificação de toda a estrutura de dados da resposta.
- Não há uma descrição unificada do contrato.
- Duplicação de
assert(a validação de tipo paradata["USER_ID"]é necessária em todos os testes que envolvem a entidade USER). - Alterações na API podem passar despercebidas em campos não verificados.
Por isso, decidimos isolar o contrato da API em uma camada de validação separada. Para isso, precisamos de uma ferramenta que:
- Valide a estrutura com base em um contrato unificado.
- Seja implementada de forma independente.
- Falhe em caso de divergência com a API.
- Seja implementada e escalada gradualmente.
Inicialmente, tentamos usar templates e descrever as respostas como JSON de referência para comparar a estrutura e o valor da resposta com um template pré-definido. Isso atendia parcialmente às necessidades de estrutura de dados, mas oferecia poucas possibilidades de validação de campos e escalabilidade.
O Pydantic v2 resolve essas tarefas através de tipagem estrita, validação integrada e comportamento previsível, tornando-o a melhor opção para nossos requisitos. Uma requisição do teste no exemplo anterior:
pythonrequests.post(f"{SOME_URL}/user", json=user_creation_body).json()
Retornará o seguinte JSON:
json{ "USER_ID": 69, "USERNAME": "John_Smith", "DISPLAY_NAME": "John Smith", "EMAIL": "john@example.com", "NOTE": null, "CREATE_DATE": "2026-01-01T00:01:30.897869", "STATUS": 1 }
Com base na documentação da API do projeto, podemos conhecer os requisitos para os valores dos campos. Criaremos um modelo Pydantic para o corpo da resposta com base nisso. O modelo descreve as estruturas de dados usando anotações de tipo do Python. Ele define quais campos são esperados na resposta, seus tipos e restrições, e também valida automaticamente os dados ao criar um objeto. Cada campo é um atributo do modelo com uma chamada conveniente.
python# ../your_project/models/user/response.py from datetime import datetime from enum import IntEnum from pydantic import BaseModel, ConfigDict, EmailStr, Field, PositiveInt class UserStatuses(IntEnum): active = 1 inactive = 0 class UserResponseModel(BaseModel): # Configuramos o modelo: definimos regras de validação e processamento de campos. model_config = ConfigDict( extra="forbid", # Proíbe novos campos na resposta da API. populate_by_name=True, # Permite usar tanto aliases de campos quanto seus nomes. alias_generator=lambda field_name: field_name.upper() # Gera alias (UPPER_CASE) para corresponder ao formato dos campos no modelo e na API. ) # Apenas números positivos são permitidos user_id: PositiveInt # String com comprimento entre 8 e 32 caracteres username: str = Field(min_length=8, max_length=32) display_name: str = Field(min_length=8, max_length=32) # Formato de email válido. Por baixo dos panos, usa EmailStr ou email-validator. email: EmailStr # Campo opcional. note: str | None = None # Data e hora no formato datetime. create_date: datetime # Valor do enum UserStatuses status: UserStatuses
Agora, o projeto possui um módulo com a descrição do contrato para a criação de usuários. Ele responde à pergunta: "Como deve ser a resposta?". Essencialmente, é uma documentação executável para a API, uma descrição do contrato do lado do cliente.
O teste atualizado verifica a estrutura através do modelo Pydantic, e os asserts são responsáveis apenas pela lógica de negócio.
python# ../your_project/tests/user/test_crud_user.py from ..models.user.response import UserResponseModel def test_create_user(): user_creation_body = { "USERNAME": "John_Smith", "EMAIL": "john@example.com", "DISPLAY_NAME": "John Smith", } response = requests.post(f"{SOME_URL}/user", json=user_creation_body) response.raise_for_status() data = response.json() user_model = UserResponseModel.model_validate(data) # Campos do corpo da resposta - atributos do modelo Pydantic assert user_model.USERNAME == user_creation_body["USERNAME"] assert user_model.EMAIL == user_creation_body["EMAIL"] assert user_model.DISPLAY_NAME == user_creation_body["DISPLAY_NAME"]
Agora, se o campo "CREATE_DATE" retornar o valor "31-03-2026" e um novo campo "IS_BLOCKED": false for adicionado, o teste falhará, mesmo que não haja asserts para esses campos. Para ilustrar, vejamos a exceção bruta ValidationError:
pydantic_core._pydantic_core.ValidationError: 2 validation errors for UserResponseModel
CREATE_DATE
Input should be a valid datetime or date, invalid character in year [type=datetime_from_date_parsing, input_value='31-03-2026', input_type=str]
For further information visit https://errors.pydantic.dev/2.11/v/datetime_from_date_parsing
IS_BLOCKED
Extra inputs are not permitted [type=extra_forbidden, input_value=False, input_type=bool]
For further information visit https://errors.pydantic.dev/2.11/v/extra_forbidden
Quanto mais modelos, mais fácil se torna adicionar novos testes. Como os modelos são abstraídos em uma camada separada, eles podem ser reutilizados em outros modelos, estendendo seu contrato, muitas vezes sem a necessidade de escrever novo código. Por exemplo, NotificationModel utiliza os modelos UserResponseModel e TemplateModel já prontos:
python# ../your_project/models/notifications/response.py from datetime import datetime from ..models.user.response import UserResponseModel from ..models.templates.response import TemplateResponseModel from pydantic import BaseModel, ConfigDict class NotificationResponseModel(BaseModel): model_config = ConfigDict( extra="forbid", populate_by_name=True, alias_generator=lambda field_name: field_name.upper() ) notification_id: str display_name: str create_date: datetime # Validações recursivas automáticas para cada item da lista recipients: list[UserResponseModel] templates: list[TemplateResponseModel]
E UserResponseModel participa tanto da criação de NotificationResponseModel quanto do teste test_add_notification sem duplicação de código. Qualquer alteração no modelo Pydantic é automaticamente aplicada a todos os testes e modelos que o utilizam.
python# ../your_project/tests/notification/test_add_notification.py from ..models.notifications.response import NotificationResponseModel def test_add_notification(): notification_add_body = { "DISPLAY_NAME": "John Smith", "RECIPIENTS": [..], "TEMPLATES": [..], } response = requests.post(f"{SOME_URL}/notification", json=notification_add_body) data = response.json() notification_model = NotificationResponseModel.model_validate(data) assert notification_model.DISPLAY_NAME == notification_add_body["DISPLAY_NAME"]
Nossa experiência com a implementação da abordagem Pydantic:
Desmembramos o épico em tarefas e implementamos gradualmente o suporte ao Pydantic no framework do projeto, descrevemos os endpoints mais importantes para entidades complexas, adicionamos modelos conforme as alterações nos testes existentes e extraímos estruturas repetitivas para modelos comuns. Não foi necessário reescrever tudo de uma vez; um endpoint requer aproximadamente 1 a 3 modelos, dependendo da estrutura. Uma excelente ferramenta para acelerar a geração de modelos foi o datamodel-code-generator. A partir da especificação OpenAPI, ele gera modelos em uma única chamada, que podem então ser adaptados ao estilo do projeto e complementados com validação. Exemplo:
bashdatamodel-codegen \ --input your_openapi_spec.yaml \ # caminho para a especificação OpenAPI de origem --input-file-type openapi \ # especificamos o tipo da especificação --output your_path/ \ # caminho para a geração dos modelos --target-python-version 3.13 \ # versão do Python para usar sintaxe atual --output-model-type pydantic_v2.BaseModel \ # usar Pydantic v2 BaseModel como classe base dos modelos --snake-case-field \ # converter nomes de campos para snake_case --use-double-quotes \ # usar aspas duplas no código gerado --use-schema-description \ # transferir a descrição do schema para docstring ou Field(description=...) --formatters ruff-check ruff-format # verificar o código via Ruff (linters + autoformatação)
Em aproximadamente seis meses, conseguimos escrever mais de 280 modelos que cobriram mais de 1740 campos e grande parte do contrato. Um valioso efeito colateral foi que revisamos a API do produto e descobrimos uma série de problemas não óbvios e anteriormente não detectados: tipos incompatíveis, valores inesperados, campos e lógicas ausentes na documentação.
Como resultado, mudamos a própria abordagem de teste de API do produto principal da InfoWatch:
- As verificações são divididas em duas camadas independentes: validação da estrutura da resposta e validação da lógica de negócio.
- Monitoramos as alterações na API de forma automática e centralizada.
- Reduzimos o número de
asserts nos testes, transferindo as verificações de estrutura para os modelos. - Aumentamos a cobertura através da reutilização de modelos.
Essa abordagem escala particularmente bem em projetos grandes onde a API está em constante desenvolvimento.





