Teste e empacotamento de uma biblioteca Python
Mark Smith8 min read • Published Jan 04, 2024 • Updated Aug 14, 2024
APLICATIVO COMPLETO
Avalie esse Tutorial
Este é o segundo tutorial de uma série! Sinta-se à vontade para conferir o primeiro tutorial, se quiser, mas ele não é necessário se você quiser apenas continuar lendo.
Este tutorial é vagamente baseado no segundo episódio de uma nova transmissão ao vivo que apresento, chamada "Coding with Mark". Estou transmitindo às quartas-feiras às 2 GMT (ou seja, 9 horário do leste ou 6 do Pacífico, se você acordar cedo!). Se esse tempo não funcionar para você, você sempre pode acompanhar assistindo à gravação!
Atualmente, estou criando uma biblioteca experimental de camada de acesso a dados que deve fornecer um kit de ferramentas para abstrair modelos de documentos complexos da camada de lógica comercial do aplicativo que os está usando.
O teste é mais fácil quando o código que você está testando é relativamente autônomo e pode ser testado isoladamente. Tristemente, o código que funciona com dados dentro do MongoDB está no outro lado do esquema — é um teste de integração por definição porque você está testando sua integração com o MongoDB.
Você tem duas opções ao escrever um teste que funcione com o MongoDB:
- Elimine o MongoDB, então, em vez de trabalhar com o MongoDB, seu código funciona com um objeto que se parece com o MongoDB, mas que não armazena dados. mongomock é uma boa solução se você estiver seguindo esta técnica.
- Trabalhe diretamente com o MongoDB, mas certifique-se de que o banco de dados esteja em um estado conhecido antes da execução dos testes (carregando dados de teste em um banco de dados vazio) e, em seguida, limpe todas as alterações feitas depois que os testes forem executados.
A primeira abordagem é arquiteturalmente mais simples — seus testes não são executados no MongoDB, então você não precisa configurar ou executar um servidor MongoDB real. Por outro lado, você precisa gerenciar um objeto que finge ser um
MongoClient
ou um Database
ou um Collection
, para que ele responda de maneiras precisas a quaisquer chamadas feitas contra ele. E como não é uma conexão real do MongoDB, é fácil usar esses objetos de maneiras que não refletem com precisão uma conexão real do MongoDB.minha abordagem preferida é a última: meus testes serão executados em uma instância real do MongoDB e a estrutura de teste limpará meu banco de dados após cada execução usando transações. Isso torna mais difícil executar os testes e eles podem ser executados mais lentamente, mas deve fazer um trabalho melhor ao destacar problemas reais que interagem com o próprio MongoDB.
Antes de correr e decidir escrever meu próprio plugin para o pytest, decidimos ver o que outros fizeram antes de eu. Apesar de tudo, estou construindo meu próprio ODM — só há um limite para o Não Inventado Aqui. na minha vida. Há duas integrações de pytest razoáveis para uso com o MongoDB: pytest-mongo e pytest-mongodb. Infelizmente, nenhum dos dois fez exatamente o que eu queria. Mas ambos parecem bons – se eles fizerem o que você deseja, recomendamos usá-los.
Pytest-mongo é um plug-in pytest que permite testar código que depende de um MongoDB database em execução. Ele permite que você especifique fixtures para o processo e o cliente do MongoDB e iniciará um processo MongoDB para executar testes, se você o configurar para fazer isso.
Pytest-mongo é um plug- plugin pytest que permite testar código que depende de uma conexão de banco de dados com um MongoDB e espera que determinados dados estejam presentes. Permite a você especificar acessórios para coleções de banco de dados no formato JSON/BSON ou YAML. Internamente, ele usa o mongomock para simular uma conexão MongoDB, ou você pode usar uma conexão MongoDB, se preferir.
Ambos oferecem recursos úteis — especialmente a capacidade de fornecer dados de fixtures especificados em arquivos no disco. O Pytest-mongo oferece até mesmo a capacidade de limpar o banco de dados após cada teste! No entanto, quando examinei um pouco mais, ele faz isso excluindo todas as collection no banco de dados de teste, que não é o comportamento que eu estava procurando.
Pretendo usar transações do MongoDB para reverter automaticamente todas as alterações feitas em cada teste. Dessa forma, o teste não confirmará nenhuma alteração no MongoDB, e apenas as alterações que ele teria feito serão revertidas, de modo que o banco de dados será eficientemente deixado no estado correto após cada execução de teste.
Vou usar o recursode fixtures do pytest para fornecer um objeto de conexão MongoDB e uma sessão de transação para cada teste que os exija. Nos bastidores, cada objeto de fixação será limpo quando terminar.
As fixtures no pytest são definidas como funções, geralmente em um arquivo chamado
conftest.py
. O que muitas vezes surpreende as pessoas que não conhecem as fixtures, no entanto, é que o pytest as fornecerá magicamente a qualquer função de teste com um parâmetro com o mesmo nome da fixture. É uma forma de injeção de dependência e provavelmente é mais fácil de mostrar do que de descrever:1 # conftest.py 2 def sample_fixture(): 3 4 assert sample_fixture == "Hello, World"
Além de pytest fornecer valores de fixture para testar funções, ele também fará o mesmo com outras funções de fixture. Farei uso disso no segundo fixture que escrevo.
Os fixtures são chamados uma vez para seu escopo e, por padrão, o escopo de um fixture é "function", o que significa que ele será chamado uma vez para cada função de teste. Quero que meu fixture de "sessão" seja chamado (e revertido) para cada função, mas será muito mais eficiente para meu fixture de cliente "mongodb" ser chamado uma vez por sessão - ou seja, no início de todo o meu teste.
A última parte da teoria de fixação do pytest que quero explicar é que, se você quiser que algo seja limpo após o término de um escopo - por exemplo, quando a função de teste for concluída -, a maneira mais fácil de fazer isso é escrever uma função geradora usando yield em vez de return, assim:
1 def sample_fixture(): 2 # Any code here will be executed *before* the test run 3 yield "Hello, World" 4 # Any code here will be executed *after* the test run
Não sei quanto a você, mas, apesar da magia, eu realmente gosto dessa configuração. É bom e consistente, quando você sabe como usá-lo.
O primeiro dispositivo de que preciso é um que retorne uma instância MongoClient que está conectada a um cluster MongoDB.
A propósito, os clustersMongoDB Atlas Serverless são perfeitas para isso, pois não custam nada quando você não as está usando. Se você estiver executando seus testes apenas algumas vezes por dia, ou até menos, essa pode ser uma boa maneira de economizar nos custos de hospedagem da infraestrutura de testes.
Quero fornecer configuração ao executor de teste por meio de uma variável de ambiente,
MDB_URI
, que será a string de conexão fornecida pelo Atlas. No futuro, talvez eu queira fornecer a string de conexão por meio de um sinalizador de linha de comando, que é algo que você pode fazer com o pytest, mas vou deixar isso para depois.Como mencionei antes, o escopo do dispositivo deve ser "sessão" para que o cliente seja configurado uma vez no início da execução de teste e fechado no final. Na verdade, vamos deixar a limpeza para o Python, então não faria isso explicitamente.
Aqui está o acessório:
1 import pytest 2 import pymongo 3 import os 4 5 6 def mongodb(): 7 client = pymongo.MongoClient(os.environ["MDB_URI"]) 8 assert client.admin.command("ping")["ok"] != 0.0 # Check that the connection is okay. 9 return client
O código acima significa que posso escrever um teste que lê de um cluster MongoDB:
1 # test_fixtures.py 2 3 def test_mongodb_fixture(mongodb): 4 """ This test will pass if MDB_URI is set to a valid connection string. """ 5 assert mongodb.admin.command("ping")["ok"] > 0
Como mencionei, o acessório acima é adequado para leitura de um banco de dados existente, mas quaisquer alterações feitas nos dados seriam mantidas após a conclusão dos testes. Para limpar corretamente após a execução do teste, preciso iniciar uma transação antes da execução do teste e, em seguida, anular a transação após a execução do teste para que todas as alterações sejam revertidas. É assim que o executor de testes do Django funciona com bancos de dados relacionais!
No MongoDB, para criar uma transação, primeiro você precisa iniciar uma sessão, o que é feito com o método
start_session
no objeto MongoClient. Depois de ter uma sessão, você pode chamar seu métodostart_transaction
para iniciar uma transação e seu métodoabort_transaction
para reverter quaisquer atualizações de banco de dados que foram executadas entre as duas chamadas.Um aviso aqui: Você deve fornecer o objeto de sessão a todas as suas consultas ou elas não serão consideradas parte da sessão que você iniciou. Tudo isso junto tem a seguinte aparência:
1 session = mongodb.start_session() 2 session.start_transaction() 3 my_collection.insert_one( 4 {"this document": "will be erased"}, 5 session=session, 6 ) 7 session.abort_transaction()
Isso não é tão ruim. Agora, vou mostrar como encerrar essa lógica em um fixture.
O fixture pega o código acima, substitui o meio por uma instrução
yield
e o envolve em uma função de fixture:1 2 def rollback_session(mongodb): 3 session = mongodb.start_session() 4 session.start_transaction() 5 try: 6 yield session 7 finally: 8 session.abort_transaction()
Desta vez, não especifiquei o escopo do acessório, portanto, o padrão é "function", o que significa que a chamada
abort_transaction
será feita depois que cada função de teste for executada.Apenas para ter certeza de que o dispositivo de teste reverte as alterações e também permite que as consultas subsequentes acessem os dados inseridos durante a transação, tenho um teste em meu arquivo
test_docbridge.py
:1 def test_update_mongodb(mongodb, rollback_session): 2 mongodb.docbridge.tests.insert_one( 3 { 4 "_id": "bad_document", 5 "description": "If this still exists, then transactions aren't working.", 6 }, 7 session=rollback_session, 8 ) 9 assert ( 10 mongodb.docbridge.tests.find_one( 11 {"_id": "bad_document"}, session=rollback_session 12 ) 13 != None 14 )
Observe que as chamadas para
insert_one
e find_one
fornecem o valor de fixação rollback_session
como um argumentosession
. Se você esquecer, coisas inesperadas acontecerão!Empacotar uma biblioteca Python sempre foi um pouco trabalhoso, e isso é ainda mais comum pelo fato de que, atualmente, o ecossistema de empacotamento muda bastante. No momento em que escrevemos, um bom backend para criar pacotes Python está sendo criado no projeto Hatch.
Em termos gerais, para um pacote Python simples, as etapas para publicar seu pacote são estas:
- Descreva seu pacote.
- Crie seu pacote.
- Envie o pacote para o PyPI.
Antes de Go por essas etapas, vale a pena instalar os seguintes pacotes em seu ambiente de desenvolvimento:
- build - usado para instalar suas dependências de compilação e empacotar seu projeto
- stringe - usado para enviar seus pacotes com segurança para o PyPI
Você pode instalar ambos com:
1 python -m pip install –upgrade build twine
Primeiro, você precisa descrever seu projeto. Anteriormente, isso exigiria um arquivo
setup.py
. Atualmente, pyproject.toml
é o caminho a percorrer. Só direi um link para o arquivopyproject.toml
no Github. Você verá que o arquivo descreve o projeto. Ela lista pymongo
como uma dependência. Ele também afirma que "hatchling.build" é o backend de construção em algumas linhas no topo do arquivo.Não é muito interessante, mas permite que você siga para a próxima etapa...
Depois de descrever seu projeto, você pode criar uma distribuição a partir dele executando o seguinte comando:
1 $ python -m build 2 * Creating venv isolated environment... 3 * Installing packages in isolated environment... (hatchling) 4 * Getting build dependencies for sdist... 5 * Building sdist... 6 * Building wheel from sdist 7 * Creating venv isolated environment... 8 * Installing packages in isolated environment... (hatchling) 9 * Getting build dependencies for wheel... 10 * Building wheel... 11 Successfully built docbridge-0.0.1.tar.gz and docbridge-0.0.1-py3-none-any.whl
Depois que os tarballs wheel e gzip forem criados, eles poderão ser publicados no PyPI (supondo que o nome da biblioteca ainda seja exclusivo!) executando o Twine:
1 $ python -m twine upload dist/* 2 Uploading distributions to https://upload.pypi.org/legacy/ 3 Enter your username: bedmondmark 4 Enter your password: 5 Uploading docbridge-0.0.1-py3-none-any.whl 6 100% ━━━━━━━━━━━━━━━━━━━━ 6.6/6.6 kB • 00:00 • ? 7 Uploading docbridge-0.0.1.tar.gz 8 100% ━━━━━━━━━━━━━━━━━━━━8.5/8.5 kB • 00:00 • ? 9 View at: 10 https://pypi.org/project/docbridge/0.0.1/
E é isso! Não saberia você, mas sempre Go para ver se realmente funcionou.
O trabalho desta semana foi super satisfativo. Sei que esse trabalho feito antecipadamente para fazer com que os testes sejam executados em transações e para publicar a biblioteca, embora ainda seja relativamente simples, se pagará com o tempo.
No meu próximo tutorial, começarei a observar dados incorporados em documentos. Estou estendendo a estrutura do docbridge para que ela possa lidar com arrays incorporadas, como trabalho para o que realmente deseja ser feito a seguir. Tentarei mostrar ao docbridge que os arrays nem sempre estão inteiramente contidos em um documento -- às vezes são subconjuntose, às vezes, são referências estendidas!
Estou muito ansioso com alguns dos blocos de construção de abstração que planejei, então certifique-se de ler meu próximo tutorial ou, se preferir, acompanhe-me na transmissão ao vivo às 2 tarde GMT às quartas-feiras!
Principais comentários nos fóruns
Ainda não há comentários sobre este artigo.
Relacionado
Tutorial
Migração fácil: do banco de dados relacional para o MongoDB com o MongoDB Relational Migrator
Jan 04, 2024 | 6 min read
Tutorial
Codificação com Mark: abstraindo junções e subconjuntos em Python
Mar 19, 2024 | 11 min read