Tuesday, December 24, 2019

Evitando chamadas custosas em testes automáticos

Ao escrever testes automáticos, é comum utilizarmos Test Doubles para substituir dependências diretas do código sob teste. Além de ter impacto direto na forma de pensar e construir tanto os casos de teste quanto o próprio código sob teste, essa prática também permite eliminar chamadas custosas e/ou não confiáveis durante a execução dos testes, garantindo assim mais eficiência e confiabilidade para a suíte de testes como um todo.

Por exemplo, suponha que precisemos testar uma função ou objeto que efetue uma requisição HTTP para uma API externa à nossa aplicação. Podemos criar um mock para o trecho de código que faz a requisição, de forma a simular o retorno esperado e, assim, não ter que fazer a chamada real de rede. Ganhamos em eficiência, pois, sem a requisição, não é necessário lidar com a latência de rede durante a execução dos testes. Ganhamos também em confiabilidade, pois, como chamadas HTTP podem falhar, qualquer requisição externa tem o potencial de gerar um falso positivo na suíte de testes; removendo a chamada, removemos essa possibilidade.

Pensando nisso, é de vital importância que numa base de código grande — com uma suíte de testes, em geral, igualmente grande — consigamos automaticamente garantir que chamadas custosas sejam apropriadamente mockadas. No caso de requisições de rede, podemos simplesmente bloquear ou restringir chamadas de socket durante a execução dos testes. É o que o pytest-socket, por exemplo, faz. Para quem usa o pytest, basta instalar o plugin e executar pytest --disable-socket. Qualquer teste em que uma chamada de socket for feita (mesmo por pacotes terceiros importados) irá falhar com SocketBlockedError.

Para códigos com custo de computação alto (ex: uma função com um pesado cálculo recursivo) que façam parte da aplicação, uma forma de evitar seu uso sem mock em testes é torna esse requisito uma parte do próprio código, isto é, permitir que a pessoa que o desenvolveu adicione alguma marcação que bloqueie a chamada em ambiente de teste. Não conhecendo uma solução já existente para isso em Python, me aventurei em criar a minha própria e daí surgiu o decorador enforce_mock_in_tests. Esse decorador pode ser aplicado a classes ou funções. A cada chamada do código decorado, ele avalia se o ambiente é de teste ou não e, se for, uma exceção é lançada, dando uma clara indicação ao desenvolvedor de que aquele código não deve ser chamado diretamente nos casos de teste, mas sim mockado. Além do código do decorador em si, o Gist abaixo contém também um arquivo com casos de teste para ele, permitindo ver como o decorador pode ser usado no código da aplicação.

import inspect
import functools
# This check is usually application dependent
def env_is_test():
pass
def enforce_mock_in_tests(callable_obj):
# If the callable is a class we override __new__. This way the error is only raised when
# class's objects are instantiated - not when the class is imported - allowing the class
# to be appropriately mocked in tests.
if inspect.isclass(callable_obj):
original__new__ = callable_obj.__new__
def overridden__new__(cls, *args, **kwargs):
if env_is_test():
raise RuntimeError('"{}" must be mocked during tests'.format(cls))
return original__new__(cls, *args, **kwargs)
callable_obj.__new__ = overridden__new__
return callable_obj
# If the callable is a function we proceed like on a regular decorator.
@functools.wraps(callable_obj)
def wrapper(*args, **kwargs):
if env_is_test():
raise RuntimeError('"{}" must be mocked during tests'.format(callable_obj))
return callable_obj(*args, **kwargs)
return wrapper
view raw enforce_mock.py hosted with ❤ by GitHub
import unittest
from unittest.mock import patch
from enforce_mock import enforce_mock_in_tests
class TestEnforceMockInTests(unittest.TestCase):
def setUp(self):
patcher = patch('enforce_mock.env_is_test')
self.env_is_test_mock = patcher.start()
self.addCleanup(patcher.stop)
def _assert_not_raises(self, callable_obj):
try:
callable_obj()
except RuntimeError:
self.fail('RuntimeError raised by {}'.format(callable_obj))
def test_decorated_function_in_test_env(self):
self.env_is_test_mock.return_value = True
@enforce_mock_in_tests
def dangerous():
pass
with self.assertRaises(RuntimeError):
dangerous()
def test_decorated_function_in_another_env(self):
self.env_is_test_mock.return_value = False
@enforce_mock_in_tests
def dangerous():
pass
self._assert_not_raises(dangerous)
def test_decorated_class_in_test_env(self):
self.env_is_test_mock.return_value = True
@enforce_mock_in_tests
class Dangerous:
pass
with self.assertRaises(RuntimeError):
Dangerous()
def test_decorated_class_in_another_env(self):
self.env_is_test_mock.return_value = False
@enforce_mock_in_tests
class Dangerous:
pass
self._assert_not_raises(Dangerous)
def test_subclass_of_decorated_class_in_test_env(self):
self.env_is_test_mock.return_value = True
@enforce_mock_in_tests
class Dangerous:
pass
class SubDangerous(Dangerous):
pass
with self.assertRaises(RuntimeError):
SubDangerous()
def test_subclass_of_decorated_class_in_another_env(self):
self.env_is_test_mock.return_value = False
@enforce_mock_in_tests
class Dangerous:
pass
class SubDangerous(Dangerous):
pass
self._assert_not_raises(SubDangerous)
def test_subclass_of_decorated_class_in_test_env_when_new_is_overriden(self):
self.env_is_test_mock.return_value = True
@enforce_mock_in_tests
class Dangerous:
pass
class SubDangerous(Dangerous):
def __new__(cls, *args, **kwargs):
return object.__new__(cls, *args, **kwargs)
self._assert_not_raises(SubDangerous)
if __name__ == '__main__':
unittest.main()

No comments

Post a Comment