Entendendo mock.assret

Sabe aquelas tarefas que aparecem com certa frequência, mas que nunca lembramos o passo a passo de cabeça e sempre temos que pesquisar como fazer? Para mim, escrever testes usando o unittest.mock do Python é uma dessas tarefas. Eu nunca lembro se posso importar patch de unittest ou se é de unittest.mock e, principalmente, quais são os diferentes métodos assert disponíveis.

Certa vez, eu queria testar se, durante a execução de uma função A, um outra função B estava sendo executada, apenas uma vez e com os argumentos corretos. Usar mock resolveria o meu problema, só que eu não lembrava qual método do mock checava o que eu queria.

Então fui até o site da documentação (que está disponível em Português) e enquanto pesquisava por assert_, prefixo da função que eu precisava, cheguei no exemplo abaixo:

>>> mock = Mock(name='Thing', return_value=None)
>>> mock(1, 2, 3)
>>> mock.assret_called_once_with(4, 5, 6)

Eu já estou acostumado com a biblioteca de unittest do Python e sei me virar bem enquanto escrevo testes com ela. Assim que bati o olho no exemplo, vi que assert_called_once_with era exatamente o método que eu queria no meu teste. Porém, também notei o erro na documentação, onde assert estava escrito assret.

Como alguém que adora contribuir para projetos de código aberto, imediatamente pensei que seria um erro em que eu que poderia arrumar. Então, abrir o meu fork do Python, fui direto no arquivo unittest.mock.rst e pesquisei por assret. Minha surpresa: 7 palavras encontradas. Achei isso muito estranho, como escreveram assert errado 7 vezes na documentação? Como isso passou nas revisões dos pull requests?

A minha curiosidade me levou para o caminho errado. Ao invés de reler a documentação, fui olhar o histório de commits com os erros de digitação. E ai então tudo ficou claro:

Tradução livre:

bpo-41877 Verifica por asert, aseert, assrt em mocks (GH-23165) - 4662fa9b

Atualmente, um objeto Mock que não é inseguro, levantará a exceção AttributeError se o atributo acessado tiver os prefixos assert ou assret. Isso é uma proteção contra erros de digitação em testes reais usando assert, o que leva a testes passarem silenciosamente mesmo que o código testado não satisfaça seus requisitos.

Recentemente foi checado em uma grande base de código (Google) e três outros erros de digitação comuns foram encontrados: asert, aseert, asssrt. Esses agora fazem parte da verificação existente.

Na verdade, o assret na documentação descreve uma funcionalidade da linguagem que tornar mais seguro o uso dos mocks!

Por conta da sua natureza, podemos dizer que um mock pode assumir qualquer forma, ou, ter qualquer atributo ou método necessário em seu contexto. Porém, os mocks também tem alguns atributos e métodos próprios, que podem ser usados para, por exemplo, verificar se uma função foi executada e com quais argumentos.


In [1]: requests = Mock()

In [2]: requests.get("https://rgth.co")
Out[2]: <Mock name='mock.get()' id='4374546224'>

In [3]: requests.get.called
Out[3]: True

In [4]: requests.get.assert_called()

No exemplo acima, na primeira linha criei um nova instância de Mock para simular a biblioteca requests. Já na segunda linha, executei o método get(), simulando uma requisição HTTP do tipo GET na url https://rgth.co. Como a variável requests é apenas um mock, nenhuma requisição foi de fato realizada, porém, podemos verificar se o método get foi executado conforme mostro nas linhas 3 e 4.

Ao criar testes unitários, algo parecido com esse exemplo pode ser feito quando precisamos testar o comportamento de uma função a partir do resultado de uma requisição HTTP. Nesses casos, a requisição em si não importa, e sim como a função que depende dela se comporta.

A função assert_called() não retorna nada se o método testado foi executado, porém gera uma exceção no caso oposto. Seguindo o exemplo, como o método post não foi executado, chamar assert_called() gera a exceção AssertionError:

In [6]: requests.post.assert_called()
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
/.../lib/python3.9/unittest/mock.py in assert_called(self)
    874             msg = ("Expected '%s' to have been called." %
    875                    (self._mock_name or 'mock'))
--> 876             raise AssertionError(msg)
    877
    878     def assert_called_once(self):

AssertionError: Expected 'post' to have been called.

Se no exemplo acima eu escrevesse requests.post.assret_called(), com erro de digitação, nenhuma exceção seria retornada como esperado, já que o método post não foi executado. Esse seria um comportamento inseguro, já que o teste precisa justamente checar se aquela função foi chamada ou não.

Mas não é o caso em Python. No código fonte, alguns erros comuns na escrita de assert são verificados quando se usa mocks. Se eu de fato escrever requests.post.assret_called(), com erro de digitação, o Python levanta uma exceção e pergunta se o que você queria dizer era assert_called:

In [7]: requests.post.assret_called()
Traceback (most recent call last):
    ...
AttributeError: 'assret_called' is not a valid assertion. Use a spec for the mock if 'assret_called' is meant to be an attribute.. Did you mean: 'assert_called'?

A implementação dessa verificação é bem simples e pode ser vista aqui. O trecho mais importante é:

if name.startswith(('assert', 'assret', 'asert', 'aseert', 'assrt')):
    raise AttributeError(
        f"{name!r} is not a valid assertion. Use a spec "
        f"for the mock if {name!r} is meant to be an attribute.")

Eu fiquei admirado como uma simples funcionalidade, mas de tamanho impacto, reflete tão bem um dos princípios no Zen do Python: erros não devem nunca passar silenciosamente.

o/