Uma introdução ao mocking em Python

Publicados: 2022-03-11

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

mocking e testes de unidade na biblioteca python unittest

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 ter chamado com sucesso a função do sistema para ejetar um CD em vez de experimentar sua bandeja de CD aberta toda vez que um teste é executado.

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:

  1. Simule o próprio método RemovalService.rm .
  2. 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.