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 |
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