- Biblioteca Built-in de Test Doubles
- Incorporada desde a versão 3.3
- Oferece os recursos básicos para testes unitários
- Baseado no padrão Arrange-Act-Assert
- Permite a criação de Dublês de Teste de forma simples
- Cria todos os atributos e métodos conforme são usados
import json
from unittest import mock
from urllib.request import urlopen
class ContaDaInternet:
def obtem_status():
token = "ad18ce48280b0ab4cd19e719bec348b82e19ee56f05af78c9aef6d7f5bc444fd"
response = urlopen(
f"https://provedordeinternet.com.br/conta/{token}/?status")
if response.status == 200:
resp_json = json.loads(response.read())
return resp_json.get("status")
conta = ContaDaInternet()
conta.obtem_status = mock.Mock(return_value="Paga")
conta.obtem_status()
- Permite definir o retorno fixo de:
- uma chamada de função ou método
- de uma instanciação de classe
- de um atributo
- de uma propriedade
- Subclasse de Mock, com o mesmo construtor
- Todos os magic methods são pré-criados e prontos para uso. Consulte a documentação para saber os valores de retorno padrão de cada método mágico.
O execute()
terá que acessar o link do site para gravar o arquivo CSV. Como o link pode mudar, vamos usar uma variável em um arquivo de configuração. Assim, é só alterar a configuração. Podemos pensar nos seguintes passos para realizar a tarefa:
- Pegar link em config.py
- HTTP GET no link
- Ex:
urlopen(config.INCENDIOS_CSV_FILE_LINK)
E como vou saber se o urlopen foi executado corretamente, passando a URL certa?
- Pode ser usado decorando uma método de teste, uma classe de teste ou em um context manager
- O target deve ser uma string com o caminho de import do módulo/objeto. Ex: 'package.module.ClassName'
- Se new não é informado, o objeto target é substituído com um MagicMock
Então, vamos ao test_app.py
[...]
class TestExecuteOK(TestCase):
def setUp(self):
with mock.patch('core.app.urlopen') as self.mock_urlopen:
self.result = app.execute()
[...]
def test_gets_csvfile_from_urllink_in_config(self):
self.mock_urlopen.assert_called_with(config.INCENDIOS_CSV_FILE_LINK)
[...]
Estamos usando primeiro o patch em um context manager, o que garante que somente neste momento o urlopen do core.app será substituído.
Rode o teste e corrija os erros até que a falha ocorra:
======================================================================
FAIL: test_gets_csvfile_from_urllink_in_config (tests.test_app.TestExecuteOK)
----------------------------------------------------------------------
Traceback (most recent call last):
File "my_project/tests/test_app.py", line 16, in test_gets_csvfile_from_urllink_in_config
self.mock_urlopen.assert_called_once_with(config.INCENDIOS_CSV_FILE_LINK)
File "/home/username/.pyenv/versions/3.7.4/lib/python3.7/unittest/mock.py", line 844, in assert_called_once_with
raise AssertionError(msg)
AssertionError: Expected 'urlopen' to be called once. Called 0 times.
----------------------------------------------------------------------
Agora é hora de implementar o objeto do teste:
from urllib.request import urlopen
from . import config
def execute():
msg = "Bem vindo ao Tutorial de Mocks!"
urlopen(config.INCENDIOS_CSV_FILE_LINK)
return msg
Rodando os testes novamente, não deve haver mais falhas.
test_gets_csvfile_from_urllink_in_config (tests.test_app.TestExecuteOK) ... ok
test_returns_hello_app (tests.test_app.TestExecuteOK) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.002s
OK
Neste nosso exemplo, também poderíamos usar o patch como decorador:
[...]
class TestExecuteOK(TestCase):
@mock.patch('core.app.urlopen')
def setUp(self, mock_urlopen):
self.mock_urlopen = mock_urlopen
self.result = app.execute()
[...]
def test_gets_csvfile_from_urllink_in_config(self):
self.mock_urlopen.assert_called_once_with(config.INCENDIOS_CSV_FILE_LINK)
[...]
Ou ainda criar um patcher, usando o start()
e stop()
:
[...]
class TestExecuteOK(TestCase):
@classmethod
def setUpClass(cls):
cls.mock_urlopen_patcher = mock.patch('core.app.urlopen')
cls.mock_urlopen = cls.mock_urlopen_patcher.start()
@classmethod
def tearDownClass(cls):
cls.mock_urlopen_patcher.stop()
def setUp(self):
self.result = app.execute()
[...]
def test_gets_csvfile_from_urllink_in_config(self):
self.mock_urlopen.assert_called_once_with(config.INCENDIOS_CSV_FILE_LINK)
[...]
O execute()
deverá salvar o arquivo CSV baixado em disco. Para isso, podemos:
- Abrir um arquivo
- Escrever o conteúdo recebido no arquivo
Vamos utilizar aqui pathlib
para manipular o arquivo CSV. Nesta implementação, vamos usar especificamente pathlib.Path
.
[...]
class TestExecuteOK(TestCase):
@classmethod
def setUpClass(cls):
[...]
cls.mock_path_patcher = mock.patch.object(
app.pathlib, 'Path', spec=app.pathlib.Path, name="MockPath"
)
cls.mock_path = cls.mock_path_patcher.start()
@classmethod
def tearDownClass(cls):
[...]
cls.mock_path_patcher.stop()
def setUp(self):
self.result = app.execute()
[...]
def test_creates_path_to_csvfile(self):
self.mock_path.assert_called_with("dados_incendios_cf.csv")
def test_writes_csvfile_content(self):
self.mock_path.return_value.write_text.assert_called_with(
self.mock_urlopen.return_value.read.return_value.decode.return_value
)
[...]
Pode ser usado decorando uma método de teste, uma classe de teste ou em um context manager
O target deve ser o caminho de import do módulo que contém o objeto a ser simulado. Atenção aqui que este caminho não é uma string!
O attribute deve ser uma string com o nome do módulo/objeto a ser simulado
Se new não é informado, o attribute é substituído com um MagicMock
O spec é um argumento na definição de um dublê de teste. Pode ser uma lista de strins ou um objeto existente (uma classe ou instancia) que atua como a especificação para o dublê. Se um objeto é passado, então uma lista de strings é formada pela chamada do dir() do objeto, excluindo os atributos e métodos mágicos não suportados. Acessando qualquer atributo ou método que não estiver na lista resultará na exceção AttributeError.
Um outro detalhe é que usando o spec com um objeto, o atributo mágico __class__ retorna a classe definida no spec. Isso permite que, por exemplo, a função isinstance() funcione.
Rodando os testes, devem ocorrer os erros:
======================================================================
FAIL: test_creates_path_to_csvfile (tests.test_app.TestExecuteOK)
----------------------------------------------------------------------
Traceback (most recent call last):
File "my_project/tests/test_app.py", line 37, in test_creates_path_to_csvfile
self.mock_path.assert_called_with("dados_incendios_cf.csv")
File "/home/username/.pyenv/versions/3.7.4/lib/python3.7/unittest/mock.py", line 825, in assert_called_with
raise AssertionError('Expected call: %s\nNot called' % (expected,))
AssertionError: Expected call: open('dados_incendios_cf.csv')
Not called
======================================================================
FAIL: test_writes_csvfile_content (tests.test_app.TestExecuteOK)
----------------------------------------------------------------------
Traceback (most recent call last):
File "my_project/tests/test_app.py", line 40, in test_writes_csvfile_content
self.mock_path.return_value.write_text.assert_called_with(self.mock_urlopen())
File "/home/username/.pyenv/versions/3.7.4/lib/python3.7/unittest/mock.py", line 825, in assert_called_with
raise AssertionError('Expected call: %s\nNot called' % (expected,))
AssertionError: Expected call: write(<MagicMock name='urlopen()' id='140481532837584'>)
Not called
----------------------------------------------------------------------
E agora é implementar para o teste passar:
[...]
def execute():
[...]
csvfile = pathlib.Path("dados_incendios_cf.csv")
csvfile.write_text(response.read().decode("utf-8"))
return msg
Agora os testes devem passar.
test_creates_path_to_csvfile (tests.test_app.TestExecuteOK) ... ok
test_gets_csvfile_from_urllink_in_config (tests.test_app.TestExecuteOK) ... ok
test_returns_hello_app (tests.test_app.TestExecuteOK) ... ok
test_writes_csvfile_content (tests.test_app.TestExecuteOK) ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.007s
OK
É uma outra forma de utilizar o spec, checando, inclusive, a assinatura dos métodos, por exemplo. Para usá-lo, basta definir autospec=True
.
O execute()
deverá salvar o arquivo CSV somente se conseguir recebê-lo. Então, para que a aplicação informe corretamente que o CSV está indisponível, é necessário verificar a resposta HTTP. Vamos então:
- Ao fazer a requisição HTTP do arquivo CSV do link, verificar se ocorre erro
- Se ocorrer erro, exibir mensagem com detalhes do problema e não tentar gravar o arquivo
E como é possível simular um erro, uma exceção?
Permite definir um comportamento ao chamar um objeto, que pode ser:
- o lançamento de uma exceção
- retornos diferentes a cada chamada, através da definição de uma lista (ou tupla)
- uma função a ser executada
Então vamos ao teste:
[...]
class TestExecuteErrors(TestCase):
@mock.patch('core.app.urlopen')
@mock.patch('core.app.pathlib.Path')
def test_url_does_not_exist_should_not_create_path(self, MockPath, mock_urlopen):
mock_urlopen.side_effect = urllib.error.URLError(
"[Errno -2] Name or service not known"
)
self.result = app.execute()
self.assertEqual(
self.result,
"Could not get CSV file: <urlopen error [Errno -2] Name or service not known>"
)
MockPath.assert_not_called()
[...]
Rodando os testes, o seguinte erro deve ser apresentado:
======================================================================
ERROR: test_url_does_not_exist_should_not_create_path (tests.test_app.TestExecuteErrors)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/username/.pyenv/versions/3.7.4/lib/python3.7/unittest/mock.py", line 1209, in patched
return func(*args, **keywargs)
File "my_project/tests/test_app.py", line 49, in test_url_does_not_exist_should_not_create_path
self.result = app.execute()
File "my_project/core/app.py", line 17, in execute
response = urlopen(config.INCENDIOS_CSV_FILE_LINK)
File "/home/username/.pyenv/versions/3.7.4/lib/python3.7/unittest/mock.py", line 965, in __call__
return _mock_self._mock_call(*args, **kwargs)
File "/home/username/.pyenv/versions/3.7.4/lib/python3.7/unittest/mock.py", line 1025, in _mock_call
raise effect
urllib.error.URLError: <urlopen error [Errno -2] Name or service not known>
----------------------------------------------------------------------
Implementando para o teste passar...
[...]
def execute():
msg = "Bem vindo ao Tutorial de Mocks!"
try:
response = urlopen(config.INCENDIOS_CSV_FILE_LINK)
except URLError as exc:
msg = f"Could not get CSV file: {exc}"
else:
csvfile = pathlib.Path("dados_incendios_cf.csv")
csvfile.write_text(response)
return msg
... e eles devem passar!
test_url_does_not_exist_should_not_create_path (tests.test_app.TestExecuteErrors) ... ok
test_creates_path_to_csvfile (tests.test_app.TestExecuteOK) ... ok
test_gets_csvfile_from_urllink_in_config (tests.test_app.TestExecuteOK) ... ok
test_returns_hello_app (tests.test_app.TestExecuteOK) ... ok
test_writes_csvfile_content (tests.test_app.TestExecuteOK) ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.007s
OK
O execute()
deverá ler o arquivo CSV e enviar os dados para um serviço externo, que armazenará e cuidará de disponibilizar os dados para usuários. Foi determinado que é importante organizar os dados por:
- categoria da UC
- grupo de proteção
- bioma referencial
Portanto, podemos definir nosso domínio:
- UnidadeConservacao
- Categoria
- GrupoProtecao
- BiomaReferencial
A API REST para o serviço externo disponibiliza os seguintes endpoints:
PUT /entity
: cria entidade com a lista de campos informados, cada um com seu tipoPOST /add
: adiciona dados para a uma entidade
Vamos ao teste!
[...]
@mock.patch('core.app.urlopen', autospec=True)
@mock.patch('core.app.Request', autospec=True)
class TestServiceAdapter(TestCase):
@classmethod
def setUpClass(cls):
cls.headers = {
"Content-Type": "application/json",
}
cls.service_config = {
"host": "http://datastoreservice:8000",
}
cls.adapter = app.ServiceAdapter(**cls.service_config)
def test_init(self, MockRequest, mock_urlopen):
self.assertEqual(self.adapter._host, self.service_config["host"])
self.assertEqual(self.adapter._headers, {"Content-Type": "application/json"})
def test_create_entity(self, MockRequest, mock_urlopen):
fields = {
"field_1": {
"type": "string",
"mandatory": True,
},
"field_2": {
"type": "integer",
"mandatory": True,
},
"field_3": {
"type": "date",
"mandatory": False,
},
"field_4": {
"type": "boolean",
"mandatory": False,
},
}
self.adapter.create_entity(name="test", fields=fields)
MockRequest.assert_called_once_with(
f'{self.service_config["host"]}/entity',
data=fields,
headers=self.headers,
method="PUT",
)
mock_urlopen.assert_called_once_with(MockRequest.return_value)
def test_add_data(self, MockRequest, mock_urlopen):
data = {
"field_1": "APA Costa das Algas",
"field_2": "integer",
"field_4": "boolean",
}
self.adapter.add_data(name="test", data=data)
MockRequest.assert_called_once_with(
f'{self.service_config["host"]}/add',
data=data,
headers=self.headers,
method="POST",
)
mock_urlopen.assert_called_once_with(MockRequest.return_value)
def test_fetch_data(self, MockRequest, mock_urlopen):
data_dict = {
"field_1": "APA Costa das Algas",
"field_2": "integer",
"field_4": "boolean",
}
data = str(data_dict).encode("utf-8")
mock_handler = mock.Mock()
mock_handler.read.return_value = data
MockResponse = mock.MagicMock()
MockResponse.__enter__.return_value = mock_handler
mock_urlopen.return_value = MockResponse
result = self.adapter.fetch_data(name="test", id="1234")
self.assertIsNotNone(result)
self.assertEqual(result, data)
mock_urlopen.assert_called_once_with(f'{self.service_config["host"]}/test/1234')
[...]
Antes da implementação, os testes devem resultar em erros:
======================================================================
ERROR: setUpClass (tests.test_app.TestServiceAdapter)
----------------------------------------------------------------------
Traceback (most recent call last):
File "my_project/tests/test_app.py", line 21, in setUpClass
cls.adapter = app.ServiceAdapter(**cls.service_config)
AttributeError: module 'core.app' has no attribute 'ServiceAdapter'
----------------------------------------------------------------------
Depois da implementação...
class ServiceAdapter:
def __init__(self, **config):
self._host = config.get("host")
self._headers = {"Content-Type": "application/json"}
def create_entity(self, name, fields):
req = Request(
self._host + "/entity",
data=fields,
headers=self._headers,
method="PUT",
)
response = urlopen(req)
def add_data(self, name, data):
req = Request(
self._host + "/add",
data=data,
headers=self._headers,
method="POST",
)
response = urlopen(req)
def fetch_data(self, name, id):
response = urlopen(f'{self._host}/{name}/{id}')
... os testes passam!
test_url_does_not_exist_should_not_create_path (tests.test_app.TestExecuteErrors) ... ok
test_creates_path_to_csvfile (tests.test_app.TestExecuteOK) ... ok
test_gets_csvfile_from_urllink_in_config (tests.test_app.TestExecuteOK) ... ok
test_gets_service_adapter (tests.test_app.TestExecuteOK) ... ok
test_returns_hello_app (tests.test_app.TestExecuteOK) ... ok
test_writes_csvfile_content (tests.test_app.TestExecuteOK) ... ok
test_add_data (tests.test_app.TestServiceAdapter) ... ok
test_create_entity (tests.test_app.TestServiceAdapter) ... ok
test_init (tests.test_app.TestServiceAdapter) ... ok
----------------------------------------------------------------------
Ran 9 tests in 0.045s
OK
E agora a gente faz as alterações no execute()
. Primeiro, os testes:
[...]
class TestExecuteOK(TestCase):
@classmethod
def setUpClass(cls):
[...]
cls.mock_service_adapter_patcher = mock.patch(
'core.app.ServiceAdapter',
name="MockServiceAdapter",
)
cls.mock_service_adapter = cls.mock_service_adapter_patcher.start()
cls.service_config = {
"host": "https://localhost:8888",
}
cls.mock_service_config_patcher = mock.patch.dict(
'core.config.SERVICE_CONFIG', **cls.service_config
)
cls.mock_service_config = cls.mock_service_config_patcher.start()
@classmethod
def tearDownClass(cls):
[...]
cls.mock_service_adapter_patcher.stop()
cls.mock_service_config_patcher.stop()
def setUp(self):
self.result = app.execute()
def test_gets_service_adapter(self):
self.mock_service_adapter.assert_called_with(**self.service_config)
[...]
... que, ao ser rodado...
======================================================================
FAIL: test_gets_service_adapter (tests.test_app.TestExecuteOK)
----------------------------------------------------------------------
Traceback (most recent call last):
File "my_project/tests/test_app.py", line 137, in test_gets_service_adapter
self.mock_service_adapter.assert_called_with(**self.service_config)
File "/home/username/.pyenv/versions/3.7.4/lib/python3.7/unittest/mock.py", line 825, in assert_called_with
raise AssertionError('Expected call: %s\nNot called' % (expected,))
AssertionError: Expected call: MockServiceAdapter(host='https://localhost:8888')
Not called
----------------------------------------------------------------------
Aí, implementamos:
def execute():
[...]
else:
csvfile = pathlib.Path("dados_incendios_cf.csv")
csvfile.write_text(response)
adapter = ServiceAdapter(**config.SERVICE_CONFIG)
return msg
E os testes passam!
test_url_does_not_exist_should_not_create_path (tests.test_app.TestExecuteErrors) ... ok
test_creates_path_to_csvfile (tests.test_app.TestExecuteOK) ... ok
test_gets_csvfile_from_urllink_in_config (tests.test_app.TestExecuteOK) ... ok
test_gets_service_adapter (tests.test_app.TestExecuteOK) ... ok
test_returns_hello_app (tests.test_app.TestExecuteOK) ... ok
test_writes_csvfile_content (tests.test_app.TestExecuteOK) ... ok
test_add_data (tests.test_app.TestServiceAdapter) ... ok
test_create_entity (tests.test_app.TestServiceAdapter) ... ok
test_fetch_data (tests.test_app.TestServiceAdapter) ... ok
test_init (tests.test_app.TestServiceAdapter) ... ok
----------------------------------------------------------------------
Ran 10 tests in 0.097s
OK
Permite definir um dicionário com a cadeia de return_value
de chamadas e retornar um outro objeto Mock
>>> class Something:
... def __init__(self):
... self.backend = BackendProvider()
... def method(self):
... response = self.backend.get_endpoint('foobar').create_call('spam', 'eggs').start_call()
... # more code
Olha o tamanho da linha necessária!
mock_backend.get_endpoint.return_value.create_call.return_value.start_call.return_value = mock_response
Usando o Mock.configure_mock():
>>> something = Something()
>>> mock_response = Mock(spec=open)
>>> mock_backend = Mock()
>>> config = {'get_endpoint.return_value.create_call.return_value.start_call.return_value': mock_response}
>>> mock_backend.configure_mock(**config)
Objetos Mock criam os atributos conforme a utilização deles, o que permite simular objetos de qualquer tipo. Porém, às vezes precisamos da ausência de um atributo. Para isso, podemos deletar o atributo com del
dessa forma:
>>> mock = MagicMock()
>>> hasattr(mock, 'is_python_brasil')
True
>>> del mock.is_python_brasil
>>> hasattr(mock, 'is_python_brasil')
False