Uma introdução ao mocking em Python
Publicados: 2022-03-11Como executar testes de unidade em Python sem testar sua paciência
Na maioria das vezes, o software que escrevemos interage diretamente com o que rotulamos como serviços “sujos”. Em termos leigos: serviços que são cruciais para nosso aplicativo, mas cujas interações têm efeitos colaterais intencionais, mas indesejados, ou seja, indesejados no contexto de uma execução de teste autônoma.
Por exemplo: talvez estejamos escrevendo um aplicativo social e queiramos testar nosso novo recurso 'Postar no Facebook', mas não queremos postar no Facebook toda vez que executarmos nosso conjunto de testes.
A biblioteca de unittest
do Python inclui um subpacote chamado unittest.mock
— ou se você o declarar como uma dependência, simplesmente mock
— que fornece meios extremamente poderosos e úteis para simular e eliminar esses efeitos colaterais indesejados.
Nota: mock
foi incluído recentemente na biblioteca padrão a partir do Python 3.3; distribuições anteriores terão que usar a biblioteca Mock para download via PyPI.
Chamadas do sistema versus simulação de Python
Para dar outro exemplo, e com o qual usaremos no restante do artigo, considere as chamadas de sistema . Não é difícil ver que esses são os principais candidatos para zombaria: se você está escrevendo um script para ejetar uma unidade de CD, um servidor da Web que remove arquivos de cache antiquados de /tmp
ou um servidor de soquete que se liga a uma porta TCP, esses chama todos os efeitos colaterais indesejados no contexto de seus testes de unidade.
Como desenvolvedor, você se importa mais com o fato de sua biblioteca chamar com sucesso a função do sistema para ejetar um CD (com os argumentos corretos, etc.) em vez de realmente experimentar sua bandeja de CD aberta toda vez que um teste é executado. (Ou pior, várias vezes, pois vários testes fazem referência ao código de ejeção durante uma única execução de teste de unidade!)
Da mesma forma, manter seus testes de unidade eficientes e com desempenho significa manter o máximo de “código lento” fora das execuções de teste automatizadas, ou seja, sistema de arquivos e acesso à rede.
Para nosso primeiro exemplo, refatoramos um caso de teste padrão do Python da forma original para um usando mock
. Vamos demonstrar como escrever um caso de teste com mocks tornará nossos testes mais inteligentes, rápidos e capazes de revelar mais sobre como o software funciona.
Uma função de exclusão simples
Todos nós precisamos excluir arquivos do nosso sistema de arquivos de tempos em tempos, então vamos escrever uma função em Python que tornará um pouco mais fácil para nossos scripts fazerem isso.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
Obviamente, nosso método rm
neste momento não fornece muito mais do que o método os.remove
subjacente, mas nossa base de código será aprimorada, permitindo-nos adicionar mais funcionalidades aqui.
Vamos escrever um caso de teste tradicional, ou seja, sem mocks:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")
Nosso caso de teste é bem simples, mas toda vez que ele é executado, um arquivo temporário é criado e depois deletado. Além disso, não temos como testar se nosso método rm
passa corretamente o argumento para a chamada os.remove
. Podemos supor que sim com base no teste acima, mas muito deixa a desejar.
Refatorando com Python Mocks
Vamos refatorar nosso caso de teste usando mock
:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")
Com esses refatorações, mudamos fundamentalmente a maneira como o teste opera. Agora, temos um insider , um objeto que podemos usar para verificar a funcionalidade de outro.
Potenciais armadilhas de simulação de Python
Uma das primeiras coisas que devem se destacar é que estamos usando o decorador de método mock.patch
para zombar de um objeto localizado em mymodule.os
e injetando esse simulado em nosso método de caso de teste. Não faria mais sentido apenas simular o próprio sistema operacional, em vez da referência a mymodule.os
os
Bem, o Python é uma espécie de cobra sorrateira quando se trata de importações e gerenciamento de módulos. Em tempo de execução, o módulo mymodule
tem seu próprio sistema os
que é importado para seu próprio escopo local no módulo. Assim, se simularmos os
, não veremos os efeitos do mock no módulo mymodule
.
O mantra para continuar repetindo é este:
Zombe de um item onde ele é usado, não de onde veio.
Se você precisar simular o módulo tempfile
para myproject.app.MyElaborateClass
, provavelmente precisará aplicar a simulação a myproject.app.tempfile
, pois cada módulo mantém suas próprias importações.
Com essa armadilha fora do caminho, vamos continuar zombando.
Adicionando validação a 'rm'
O método rm
definido anteriormente é bastante simplificado. Gostaríamos que ele validasse que um caminho existe e é um arquivo antes de tentar removê-lo cegamente. Vamos refatorar rm
para ser um pouco mais inteligente:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
Excelente. Agora, vamos ajustar nosso caso de teste para manter a cobertura.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")
Nosso paradigma de teste mudou completamente. Agora podemos verificar e validar a funcionalidade interna dos métodos sem nenhum efeito colateral.
Remoção de arquivos como serviço com patch simulado
Até agora, trabalhamos apenas com o fornecimento de mocks para funções, mas não para métodos em objetos ou casos em que o mocking é necessário para o envio de parâmetros. Vamos cobrir os métodos de objeto primeiro.
Começaremos com uma refatoração do método rm
em uma classe de serviço. Não há realmente uma necessidade justificável, por si só, de encapsular uma função tão simples em um objeto, mas pelo menos nos ajudará a demonstrar conceitos-chave em mock
. Vamos refatorar:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)
Você notará que não mudou muito em nosso caso de teste:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")
Ótimo, agora sabemos que o RemovalService
funciona conforme o planejado. Vamos criar outro serviço que o declare como uma dependência:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)
Como já temos cobertura de teste no RemovalService
, não vamos validar a funcionalidade interna do método rm
em nossos testes de UploadService
. Em vez disso, simplesmente testaremos (sem efeitos colaterais, é claro) que UploadService
chama o método RemovalService.rm
, que sabemos que “simplesmente funciona” em nosso caso de teste anterior.

Há duas maneiras de fazer isso:
- Simule o próprio método
RemovalService.rm
. - Forneça uma instância simulada no construtor de
UploadService
.
Como ambos os métodos são frequentemente importantes no teste de unidade, revisaremos ambos.
Opção 1: métodos de instância simulada
A biblioteca mock
tem um decorador de método especial para zombar de métodos e propriedades de instância de objeto, o decorador @mock.patch.object
:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")
Excelente! Validamos que o UploadService
chama com sucesso o método rm
da nossa instância. Notou algo interessante lá? O mecanismo de patch substituiu o método rm
de todas as instâncias RemovalService
em nosso método de teste. Isso significa que podemos realmente inspecionar as próprias instâncias. Se você quiser ver mais, tente inserir um ponto de interrupção em seu código simulado para ter uma boa ideia de como o mecanismo de correção funciona.
Armadilha do Patch Simulado: Ordem do Decorador
Ao usar vários decoradores em seus métodos de teste, a ordem é importante e é meio confuso. Basicamente, ao mapear decoradores para parâmetros de método, trabalhe de trás para frente. Considere este exemplo:
@mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass
Observe como nossos parâmetros são combinados com a ordem inversa dos decoradores? Isso se deve em parte à maneira como o Python funciona. Com vários decoradores de método, aqui está a ordem de execução em pseudocódigo:
patch_sys(patch_os(patch_os_path(test_something)))
Como o patch para sys
é o patch mais externo, ele será executado por último, tornando-o o último parâmetro nos argumentos reais do método de teste. Tome nota disso bem e use um depurador ao executar seus testes para garantir que os parâmetros corretos estejam sendo injetados na ordem correta.
Opção 2: criando instâncias simuladas
Em vez de zombar do método de instância específico, poderíamos apenas fornecer uma instância simulada para UploadService
com seu construtor. Eu prefiro a opção 1 acima, pois é muito mais precisa, mas há muitos casos em que a opção 2 pode ser eficiente ou necessária. Vamos refatorar nosso teste novamente:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")
Neste exemplo, não tivemos que corrigir nenhuma funcionalidade, simplesmente criamos uma especificação automática para a classe RemovalService
e, em seguida, injetamos essa instância em nosso UploadService
para validar a funcionalidade.
O método mock.create_autospec
cria uma instância funcionalmente equivalente à classe fornecida. O que isso significa, na prática, é que quando a instância retornada é interagida, ela levantará exceções se usada de forma ilegal. Mais especificamente, se um método for chamado com o número errado de argumentos, uma exceção será gerada. Isso é extremamente importante, pois os refatorações acontecem. À medida que uma biblioteca muda, os testes são interrompidos e isso é esperado. Sem usar uma especificação automática, nossos testes ainda serão aprovados, mesmo que a implementação subjacente esteja quebrada.
Armadilha: As classes mock.Mock
e mock.MagicMock
A biblioteca mock
também inclui duas classes importantes sobre as quais a maior parte da funcionalidade interna é construída: mock.Mock
e mock.MagicMock
. Quando tiver a opção de usar uma instância mock.Mock
, uma instância mock.MagicMock
ou uma especificação automática, sempre prefira usar uma especificação automática, pois ela ajuda a manter seus testes sãos para alterações futuras. Isso ocorre porque mock.Mock
e mock.MagicMock
aceitam todas as chamadas de método e atribuições de propriedade, independentemente da API subjacente. Considere o seguinte caso de uso:
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
Podemos testar isso com uma instância mock.Mock
como esta:
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")
Essa lógica parece sã, mas vamos modificar o método Target.apply
para receber mais parâmetros:
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
Execute novamente seu teste e você verá que ele ainda passa. Isso porque ele não é construído em relação à sua API real. É por isso que você deve sempre usar o método create_autospec
e o parâmetro autospec
com os decoradores @patch
e @patch.object
.
Exemplo de simulação do Python: zombando de uma chamada de API do Facebook
Para finalizar, vamos escrever um exemplo de simulação python mais aplicável no mundo real, que mencionamos na introdução: postar uma mensagem no Facebook. Vamos escrever uma boa classe wrapper e um caso de teste correspondente.
import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)
Aqui está nosso caso de teste, que verifica se postamos a mensagem sem realmente postar a mensagem:
import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")
Como vimos até agora, é muito simples começar a escrever testes mais inteligentes com mock
em Python.
Conclusão
A biblioteca mock
do Python, embora um pouco confusa de se trabalhar, é um divisor de águas para testes de unidade. Demonstramos casos de uso comuns para começar a usar mock
em testes de unidade, e esperamos que este artigo ajude os desenvolvedores de Python a superar os obstáculos iniciais e escrever um código excelente e testado.