Compartilhe:

Publicado em 20 de April de 2022

Escrevendo testes com Pytest e Mock

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

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.


Referências

Tags:PythonTestes

Publicado por: PagBank Engineering