O Pytest é uma das bibliotecas voltadas para testes e TDD mais populares do ecossistema Python. Com ele podemos validar tipos de dados, retornos de funções, parâmetros recebidos por funções internas aos métodos testados, exceções e ainda modificar o comportamento de métodos externos para que não sejam necessárias requisições externas, poupando tempo e recursos. Alguns dos recursos oferecidos pelo Pytest são:
- Asserts
- Exceptions
- Fixtures
- Markers
- Parametrize
- Spies
- Mock Functions
- Mock Builtins Methods
- Mock Classes
- Executando os testes
- Configurações extras
Asserts
Os asserts são validações booleanas simples que podem ser usadas para verificar condições, como por exemplo, se um objeto é uma instância de uma classe específica, se uma chave corresponde a um método existente e ainda se o valor de retorno da função é igual ao esperado:
import pytest
from app.worker import Worker
def test_smoke():
""" Testing the instance of Worker class """
worker = Worker()
assert isinstance(worker, Worker)
def test_methods_smoke():
""" Testing if worker.transform is a method """
worker = Worker()
assert hasattr(worker.transform, '__call__')
def test_transform():
""" Testing if the result of transform method is equal 1 """
worker = Worker()
result = worker.transform(value='1.0', convert_to='int')
assert result == 1
Também é possível adicionar warnings que serão exibidos caso o teste acuse um erro:
def test_smoke():
worker = Worker()
assert isinstance(worker, Worker), 'Erro na validação da instância da classe Worker'
def test_transform():
worker = Worker()
result = worker.transform(value='1.0', convert_to='int')
assert result == 1, f'Erro na validação do método test_transform. Resultado: {result}, Esperado: 1'
Exceptions
Para testar exceções você pode usar pytest.raises. Nesse caso o assert
verificará se o erro está sendo disparado:
def test_transform_with_exception():
""" Testing if the method raise an Exception """
worker = Worker()
with pytest.raises(Exception):
assert worker.transform(convert_to='invalid_value')
Fixtures
Fixtures são funções que serão executadas antes e depois de cada teste. Os blocos que serão executados no início e no final dos testes são separados pela palavra-chave yield
. Nesse caso temos a classe sendo instanciada e retornada pelo yield
e após a execução dos testes, temos a instância sendo deletada da memória. Isso garante ambientes desacoplados e independentes.
Para informar aos testes que ela deve ser executada, a fixture deve ser solicitada como parâmetro (opcionalmente, a instância retornada pela fixture pode ser tipada para facilitar o uso):
@pytest.fixture
def worker():
""" Testing the instance of Worker class """
worker = Worker()
yield worker
del worker
def test_smoke(worker: Worker):
""" Testing the instance of Worker class """
assert isinstance(worker, Worker)
def test_transform_smoke(worker: Worker):
""" Testing if the worker.transform is a method """
assert hasattr(worker.transform, '__call__')
def test_transform(worker: Worker):
""" Testing if the result of transform method is equal 1 """
result = worker.transform(value='1.0', convert_to="int")
assert result == 1
Markers
Os custom markers são decorators usados para selecionar quais testes serão executados. Nesse caso, os testes marcados com @pytest.mark.worker
serão executados individualmente através do comando pytest -m worker
:
@pytest.mark.worker
def test_smoke(worker: Worker):
assert isinstance(worker, Worker)
As marcações requerem uma configuração extra no arquivo pytest.ini
na raiz do projeto:
[pytest]
markers =
worker: marks tests from Worker module
Parametrize
O parametrize é um tipo de marcação que altera o comportamento do teste. Com ela podemos enviar conjuntos de parâmetros através de tuplas e recuperar esses valores dentro do teste. Cada tupla de parâmetros adiciona uma execução àquele teste.
O decorator parametrize
recebe 2 parâmetros, sendo o primeiro uma string com o nome das váriaveis separadas por vírgula e o segundo um array de tuplas, aonde os valores serão passados (o número de parametros na tupla deve ser igual ao número de variáveis).
Com esse recurso é possível enviar um parâmetro para ser passado à função e outro para comparar com o resultado:
@pytest.mark.parametrize('initial, expected', [
('table_name_a', True),
('table_name_b', False)
])
def test_table_exists(worker: Worker, initial, expected):
"""
Testing table_exists method twice
Passing table_name_a and expecting True as return
Passing table_name_b and expecting False as return
"""
result = worker.table_exists(initial)
assert result == expected
Spies
Os spies são como observables capazes de registrar o número de chamadas de uma função. Assim, é possível identificar se as funções internas são executadas como deveriam. No caso abaixo sabemos que o método worker.table_exists
executa internamente o worker.client.query
apenas uma vez, então podemos verificar se isso ocorre corretamente:
Obs: Para utilizá-los é necessário instalar o plugin pytest-mock
através do comando pip install pytest-mock
. Após executar esse comando, basta adicionar o parâmetro mocker
como dependência do teste.
def test_table_exists(worker: Worker, mocker):
"""
The method worker.table_exists should run internally the method worker.client.query
"""
query_spy = mocker.spy(worker.client, "query")
worker.table_exists('table_name')
assert query_spy.call_count == 1
Mock Functions
Mocks são substitutos para os métodos internos. Eles podem ser utilizados para sobrescrever funções assíncronas, funções de bibliotecas externas e acesso a bancos de dados. O método mock
pode receber o parâmetro return_value
que definirá qual valor será retornado pela função sobrescrita:
import mock
def test_table_exists(worker: Worker):
"""
The Mock method return a fake function
"""
worker.client.query = mock.Mock(return_value=[{'a': 1},{'b': 2}])
worker.table_exists('database_name')
"""
The call_args_list store a array with passed args as a Call object
A Call object can be compared with a tuple of tuples or dicts
"""
assert worker.client.query.call_args_list == [
(('SELECT * FROM database_name',),)
]
def test_get_file_content_to_file(worker: Worker):
worker.client.get_blob = mock.Mock()
worker.get_file_content_to_file(file_name="file_name.txt")
assert worker.client.get_blob.call_args_list == [
({ 'blob_name': 'file_name.txt' },)
]
Mock Builtins Methods
Para sobrescrever métodos nativos é possível utilizar o método patch
do módulo mock
acessando os metodos em builtins
. Por padrão, esses métodos não precisam ser importados, como por exemplo o método builtins.open
, entretando, para sobrescrevê-los é necessário importar o módulo padrão.
import mock
import builtins
"""
The method open should run internally of get_file_content_to_file method
"""
def test_get_file_content_to_file(worker: Worker):
"""
Overwrite the open builtin method
"""
with mock.patch('builtins.open', mock.mock_open()):
worker.client.get_blob = mock.Mock()
worker.get_file_content_to_file(file_name="file_name.txt")
assert worker.client.get_blob.call_args_list == [
({ 'blob_name': 'file_name.txt' },)
]
Mock Classes
Mocks de classes tem o mesmo objetivo dos mocks de funções, mas tem uma sintaxe um pouco diferente. Para sobrescrever o funcionamento de uma classe usada internamente ao método testado, primeiro precisamos acessar sua instância, o que pode ser feito sobrescrevendo o método init
da classe testada (lembrando que isso não deve alterar o funcionamento principal da classe). Para isso podemos utilizar o método patch.object
do módulo mock. Esse método permite sobrescrever qualquer método de uma classe, inclusive o construtor.
Outra forma de sobrescrever uma classe é criando uma classe fake e substituindo a instância da classe original por ela. No exemplo abaixo podemos ver isso sendo feito na classe FakeWorkerClient
. Dessa forma, somente os métodos utilizados pelos nossos testes permanecerão durante o processo e nenhum deles terá dependências externas.
import mock
"""
Create a fake class to overwrite the external SDK.
"""
class FakeWorkerClient:
def query(self, query_string):
return [{}]
def get_blob(self, blob_name):
return 'Content from blob'
@pytest.fixture
def worker():
"""
Overwrite the init method.
"""
with mock.patch.object(Worker, "__init__", lambda x: None):
worker = Worker()
worker.client = FakeWorkerClient()
yield worker
del worker
Executando os testes
Para rodar os testes basta executar na raiz do projeto o comando pytest
ou então pytest -vv
para executar em modo verbose
.
Configurações extras
Para executar os testes em seu projeto é recomendado torná-lo um pacote. Para isso, basta adicionar um arquivo __init__.py
no diretório de testes. Também é recomendado utilizar o sistema de módulos e pacotes no código principal a ser testado para evitar problemas com os paths dos arquivos.