Quando desenvolvemos testes, é comum precisarmos de preparar algum cenário antes de fazermos o assert. Isso pode ser feito de diversas maneiras, e as fixtures do pytest são uma das melhores soluções para esse problema. Para demonstrar como utilizar e quais vantagens as fixtures nos proporcionam, é importante definir as etapas de um teste:
-
preparação (setup): nesta etapa, criamos todas as pré condições para executarmos nosso teste. As fixtures do pytest auxiliam aqui. Nos tradicionais frameworks de teste da família xUnit (incluindo unittest) isso é geralmente feito no método
setUp()
. - ação (act): execução de um método ou função que queremos testar.
- verificação (assert): aqui é, de fato, onde o teste ocorre. Verificamos se o resultado da ação é o que queríamos.
-
finalização (cleanup ou teardown): quando precisamos criar algum recurso para execução de um teste (criação de uma tabela de banco de dados, por exemplo), também precisamos garantir que isso seja desfeito para que não interfira nos próximos testes. As fixtures também auxiliam aqui. No unittest isso é feito no método
tearDown()
.
Preparação
Para demonstrar o uso das fixtures, vamos nos aprofundar em um exemplo que necessita da etapa de preparação. Vamos assumir um cenário onde temos uma função busca_pessoa
que recebe um nome
e busca um registro de pessoa em um banco de dados. Um possível teste para essa função ficaria assim:
def test_busca_pessoa():
# Para buscarmos uma pessoa, é necessário termos um
# registro de pessoa no banco.
# Essa é a etapa de preparação.
# Primeiro, precisamos criar a tabela de pessoas.
# Vamos assumir que temos uma função para isso.
# O conteúdo dessa função não é importante para o nosso
# teste.
cria_tabela()
# Também precisamos de uma session do banco.
session = Session()
# E criamos a pessoa, utilizando a session.
pessoa = Pessoa(nome="Maria")
session.add(pessoa)
session.commit()
# Finalizada a preparação, executamos nossa ação e
# guardamos o resultado em uma variável.
pessoa_db = busca_pessoa(nome="Maria")
# E verificamos se o resultado é o esperado.
assert pessoa_db, "Pessoa não encontrada"
assert pessoa_db.nome == "Maria"
# Por último, precisamos fechar a session e desfazer a
# criação da tabela.
session.close()
# Assim como a função cria_tabela, o conteúdo da função
# destroi_tabela não é relevante para o teste.
destroi_tabela()
Esse é um teste bem simples comparado aos testes que precisamos escrever no dia a dia, mas, repare que as etapas de ação e verificação (as mais importantes), são apenas três linhas, enquanto as outras etapas ocupam um espaço mais significativo no teste. Não queremos ter que repetir toda a preparação e finalização toda vez que precisarmos criar um teste parecido. Podemos simplificar tudo isso com fixtures:
import pytest
# Uma fixture é apenas uma função normal, marcada com o
# decorator @pytest.fixture.
@pytest.fixture
def session():
# preparação
cria_tabela()
session = Session()
return session
# Declaramos um parâmetro session, que receberá o retorno da
# fixture session.
def test_busca_pessoa(session):
# preparação
pessoa = Pessoa(nome="Maria")
session.add(pessoa)
session.commit()
# ação
pessoa_db = busca_pessoa(nome="Maria")
# verificação
assert pessoa_db, "Pessoa não encontrada"
assert pessoa_db.nome == "Maria"
# finalização
session.close()
destroi_tabela()
Com isso, reduzimos o trecho de preparação dentro do nosso teste e podemos reutilizar a session em outros testes, mas ainda estamos fechando a session e desfazendo a tabela dentro do teste. Podemos mover esse código para dentro da fixture, utilizando a keyword yield
:
@pytest.fixture
def session():
# preparação
cria_tabela()
session = Session()
# Agora trasformamos a fixture em um generator.
# Com isso, o pytest passa o valor de session para o
# nosso teste e continua para a etapa de finalização
# após o teste ser concluído.
yield session
# finalização
session.close()
destroi_tabela()
def test_busca_pessoa(session):
# preparação
pessoa = Pessoa(nome="Maria")
session.add(pessoa)
session.commit()
# ação
pessoa_db = busca_pessoa(nome="Maria")
# verificação
assert pessoa_db, "Pessoa não encontrada"
assert pessoa_db.nome == "Maria"
A utilização de generators é uma excelente forma de finalizar recursos criados dentro da fixture. Porém, ainda podemos ter problemas caso a fixture lance uma exceção e, em algumas situações, é possível que recursos não sejam finalizados. Para que isso não ocorra, é recomendado limitar a quantidade de ações que podem lançar exceções dentro de uma fixture. Na função session
, temos duas responsabilidades: criar as tabelas e instanciar a sessão. Podemos separar essas duas responsabilidades da seguinte forma:
@pytest.fixture
def tabela():
cria_tabela()
# Como não produzimos nenhum valor aqui, a palavra yield
# serve apenas para pausar a função e passar o controle
# para a função session.
yield
destroi_tabela()
# Declaramos a dependência da fixture tabela.
@pytest.fixture
def session(tabela):
session = Session()
yield session
session.close()
def test_busca_pessoa(session):
...
Vimos que uma fixture pode depender de outra. Podemos reduzir ainda mais a etapa de preparação dentro do teste, criando uma terceira fixture:
# Aqui, a fixture maria utiliza a fixture session.
@pytest.fixture
def maria(session):
# preparação
pessoa = Pessoa(nome="Maria")
session.add(pessoa)
session.commit()
return pessoa
# Agora nosso teste só precisa utilizar a fixture maria.
# Note que, como não precisamos do retorno, a fixture é
# declarada mas o parâmetro maria não é utilizado.
def test_busca_pessoa(maria):
# ação
pessoa_db = busca_pessoa(nome="Maria")
# verificação
assert pessoa_db, "Pessoa não encontrada"
assert pessoa_db.nome == "Maria"
Nosso teste agora já está bem menor e podemos reaproveitar as fixtures em outros testes. Uma outra melhoria que podemos fazer seria transformar a fixture maria
, utilizando o padrão factories como fixtures. Dessa forma, podemos reaproveitar o código para criar novos objetos com parâmetros diferentes.
# ...
@pytest.fixture
def cria_pessoa(session):
# Dentro da fixture, criamos uma função factory, que
# recebe os parâmetros desejados e cria o objeto.
def _cria_pessoa(nome):
pessoa = Pessoa(nome=nome)
session.add(pessoa)
session.commit()
return pessoa
# A fixture retorna a função factory.
return _cria_pessoa
def test_busca_pessoa(cria_pessoa):
# preparação
cria_pessoa("Maria")
# ação
pessoa_db = busca_pessoa(nome="Maria")
# verificação
assert pessoa_db, "Pessoa não encontrada"
assert pessoa_db.nome == "Maria"
Agora nosso teste ficou muito melhor! O grande trabalho de preparação foi abstraído para as fixtures, ficando como responsabilidade do teste apenas informar os parâmetros para criação do objeto pessoa, realizar uma ação e verificar o resultado.
Com isso, a sequência de dependências das nossas fixtures fica da seguinte forma:
test_busca_pessoa() -> cria_pessoa() -> session() -> tabela()
Com a criação de diferentes fixtures, cada uma com sua responsabilidade, fica fácil reaproveita-las em outros testes:
def test_busca_todas_as_pessoas(cria_pessoa):
# preparação
cria_pessoa("Maria")
cria_pessoa("Gabriel")
# ação
pessoas = busca_pessoas()
# verificação
assert len(pessoas) == 2
Conclusão
Não é segredo para ninguém que testes são uma excelente ferramenta para garantir a qualidade do nosso código. Para isso, é essencial que, não apenas eles existam e estejam funcionando, mas também que eles sejam legíveis e que seja fácil criar novos testes. Afinal de contas, os testes também fazem parte da base de código de um projeto e merecem total atenção. As fixtures do pytest são uma ótima maneira de simplificar as etapas de preparação e finalização dos testes e, consequentemente, melhorar a qualidade do código.
Essa é apenas uma parte do que é possível fazer utilizando fixtures. O pytest oferece uma série de fixtures por padrão, que podem ser muito úteis para diversas situações. Para saber mais, recomendo uma boa leitura da documentação do pytest.
Top comments (1)
Show! conseguir entender de maneira simples e fácil os conceitos de @pytest.fixture