O que eu aprendi nos cursos de testes do Real Python

João Guilherme Berti Sczip
13 min readJan 31, 2024

Recentemente finalizei os 3 principais cursos da plataforma Real Python sobre escrita de testes automatizados. Como eu já trabalho com essas ferramentas no meu dia a dia, realizei os cursos com o intuito de entender, de maneira geral, se há formas melhores de escrever testes do que como eu faço diariamente.

Abaixo você encontrará o meu overview sobre os 3 cursos que concluí, sendo eles:

Testing your code with pytest

pytest é um framework para escrita de testes automatizados em Python que surgiu em contrapoposta ao framework unittest, que faz parte da biblioteca padrão da linguagem.

Ao contrário do anterior que traz uma abordagem bastante verbosa, onde todo test case deve estender de uma classe de teste, o pytest carrega consigo uma simplicidade muito maior, casando muito mais com o conceito de simplicidade imposta pela linguagem.

Escrita de testes

Abaixo um exemplo de escrita de teste utilizando unittest vs pytest.

def say_hello(to):
return f'Hello, {to}'

# unittest
import unittest
class TestMyFunc(unittest.TestCase):
def test_say_hello(self):
self.assertEqual(say_hello('World'), 'Hello, World')

# pytest
def test_say_hello():
assert say_hello('World') == 'Hello, World'

Com o exemplo acima é possível observar a simplicidade do pytest em relação à sua alternativa. Conseguimos escrever um teste apenas por declarar uma função com o prefixo de test_ e utilizamos o palavra reservada assert para validar que a comparação é verdadeira, fazendo com que o teste passe.

Em contrapartida também é possível fazer condições negativas apenas trocando o sinal da comparação:

def say_hello(to):
return f'Hello, {to}'

# pytest
def test_say_hello():
assert say_hello('World') != 'Hello! World'

Conseguimos efetuar comparações entre objetos distintos, como listas, tuplas, dicionários, etc.

def test_list_equal():
assert [1, 2, 3] == [1, 2, 3]

def test_tuple_equal():
assert (1, 2, 3) == (1, 2, 3)

def test_dict_equal():
assert {'a': 1, 'b': 2} == {'a': 1, 'b': 2}

De forma resumida, escrever testes no pytest funciona da seguinte forma:

  • Escrever uma função com o prefixo de test_;
  • Utilizar a palavra assert para assegurar que a comparação está retornando True.

Simples assim!

Utilizando fixtures

Em muitos dos casos queremos escrever testes baseando-se em um mesmo argumento de entrada. Por exemplo, dada uma lista, queremos testar diferentes situações envolvendo-a.

Uma forma seria definir essa lista em todos os testes e utilizá-la, como nos exemplos abaixo.

def test_list_pop():
my_list = [1, 2, 3]
my_list.pop(1)
assert my_list == [1, 3]

def test_list_append():
my_list = [1, 2, 3]
my_list.append(4)
assert my_list == [1, 2, 3, 4]

def test_list_reverse():
my_list = [1, 2, 3]
my_list.reverse()
assert my_list == [3, 2, 1]

Embora funcione, escrever testes dessa maneira acaba trazendo alguns problemas, principalmente relacionados a dificuldade na manutenção e evolução dos mesmos, visto que para cada novo teste será preciso definir o mesmo objeto. Além do fato que caso ocorra alguma alteração no objeto em que estamos testando, será preciso alterá-lo em todos os testes, ocasionando em uma péssima manutenabilidade.

pytest fornece a possiblidade de definir fixtures que serão reaproveitadas entre os testes, a fim de resolver os problemas destacados acima. Utilizando as fixtures, podemos definir qualquer tipo de objeto que queremos reaproveitar durante os testes, fazendo com que qualquer alteração nesse objeto seja efetuada em apenas um único lugar.

Para tal basta utilizar o decorator @pytest.fixture e definir o objeto em que iremos trabalhar.

import pytest

@pytest.fixture
def my_list():
return [1, 2, 3]

def test_list_pop(my_list):
my_list.pop(1)
assert my_list == [1, 3]

def test_list_append(my_list):
my_list.append(4)
assert my_list == [1, 2, 3, 4]

def test_list_reverse(my_list):
my_list.reverse()
assert my_list == [3, 2, 1]

Para declarar uma nova fixture basta apenas adicionar o decorator logo acima da declaração de uma função que retorna o objeto desejado. Uma vez declarada, precisamos fornecer o nome da fixture nos testes onde desejamos utilizá-la que o pytest encarrega-se de carregá-la dentro dos testes.

Além de poder definir fixtures personalizadas, o pytest também fornece uma lista de fixtures built-in que podem ser consultadas aqui.

Parametrizando testes

Dependendo da função que deseja testar, é interessante testá-la com diversos parâmetros diferentes, a fim de estressá-la o máximo que puder e garantir que a mesma não terá efeitos colaterais.

Se por exemplo quisermos testar uma função que faz a subtração entre dois números, poderíamos seguir pela abordagem abaixo.

def subtract(a, b):
return a - b

def test_subtract_2_and_1():
assert subtract(2, 1) == 1

def test_subtract_5_and_2():
assert subtract(5, 2) == 3

def test_subtract_10_and_5():
assert subtract(10, 5) == 5

Embora essa abordagem funcione, acabamos voltando ao problema da manutenabilidade, pois trabalhando dessa forma deixamos os nossos testes mais difíceis de manter, uma vez que qualquer alteração na assinatura ou retorno da função ocasionaria em alterações em mais de um teste, e consequentemente em um trabalho maior de refatoração.

Para solucionar esse problema o pytest nos fornece um outro decorator responsável por fornecer diversos parâmetros a um mesmo teste. Utilizando esse decorator podemos informar quais os parâmetros de entrada e qual a saída que a função terá para parâmetros fornecidos.

def subtract(a, b):
return a - b

import pytest
@pytest.mark.parametrize("param_a,param_b,expected_result", [
(2, 1, 1),
(5, 2, 3),
(10, 5, 5),
])
def test_subtract(param_a, param_b, expected_result):
assert subtract(param_a, param_b) == expected_result

O decorator @pytest.mark.parametrize, recebe dois argumentos:

  1. Uma string representando os parâmetros que serão passados à função de teste.
  2. Uma lista que representa cada um dos parâmetros que serão fornecidos em cada iteração do teste. Quando há mais de um parâmetro como no exemplo acima, utilizamos uma tupla para distingui-los dentro da lista.

Conclusão do curso

O curso é um bom conteúdo introdutório, pois passa por todos os tópicos fundamentais da ferramenta. Ao finalizar esse curso a pessoa dev já está apta a escrever os seus primeiros testes utilizando o framework.

Pelo fato do pytest ser bastante simples de utilizar, e também pelo fato de sua documentação ser bem completa, não há como um curso se destacar de tal forma que pareça algo indispensável no aprendizado. Entretanto senti falta de alguns tópicos como:

  • Instalação e setup do pytest, incluindo arquivo de configuração e seus parâmetros;
  • Coleta de cobertura de testes;
  • Debugging;
  • Boas práticas de escrita de testes;
  • Demais comandos e funcionalidades da ferramenta.

De forma geral eu diria que você pode aprender tudo o que o curso mostra apenas através da documentação do framework. Visto que é uma ferramenta bem simples de aprender, o curso poderia muito bem ter abordado os tópicos que comentei acima para fazer com que o aluno não apenas saiba como utilizar a ferramenta em si, mas também como escrever testes bem escritos e úteis.

Improve Your Tests With the Python Mock Object Library

Ao trabalhar com testes automatizados, muitas das vezes é preciso “simular” o comportamento de algum objeto ou dependência para que o teste possa ser implementado. Essa simulação ocorre principalmente nos casos onde o seu Subject Under Test (SUT) possui dependências externas, como chamadas HTTP, banco de dados, etc.

Nos testes chamamos essa simulação de Mock, fazemos o Mock de um determinado objeto quando podemos especificar qual será o seu comportamento durante aquele teste específico, determinando resultados de chamadas, parâmetros utilizados para chamá-lo, etc.

Para esclarecer mais qual a necessidade de realizar esse procedimento dou como exemplo um método que precisa fazer um GET em outra API. Nesse caso nosso teste não deve realizar a chamada em si, pois dessa forma para que o mesmo funcione, estaria dependendo fortemente da qualidade de conexão e da API que irá chamar. Isso pode trazer problemas visto que caso a API não esteja funcionando de forma correta ou não esteja disponível no momento de executar o teste, o mesmo irá falhar. Há também o problema relacionado às requisições em si, uma vez que uma grande carga de teste pode impactar o desempenho da API que está executando em um ambiente produtivo, trazendo efeitos inesperados.

Por esses e outros motivos que utilizamos um Mock para simular o comportamento da função que realiza essa chamada GET para que não faça a chamada em si, mas apenas retorne um resultado predeterminado que faça sentido para nosso teste.

O objeto Mock

O objeto Mock é importado e utilizado através da biblioteca padrão da linguagem (a partir da versão 3.3, para as versões anteriores é preciso instalá-la como uma dependência), e possui uma documentação bem clara e ampla sobre como utilizá-lo.

A classe Mock é bastante flexível, uma vez instanciada podemos efetuar chamadas em métodos que não existem e esses métodos serão atribuídos à instância. Através dessa atribuição é possível realizar todas as operações de mocking que desejarmos.

Uma das funções do Mock é assumir o papel de uma instância real (classe ou método de produção) e fazê-los retornarem resultados específicos, como no exemplo abaixo.

from unittest.mock import Mock

class MyProductionClass:
def some_method(self):
pass

my_instance = MyProductionClass()
my_instance = Mock()
my_instance.some_method.return_value = "it's not the original return"

print(my_instance.some_method() == "it's not the original return")
# True

Além de especificar qual o retorno dos métodos, também é possível efetuar asserts em cima das chamadas do método em questão.

from unittest.mock import Mock

class MyProductionClass:
def some_method(self, a, b, c):
return a + b + c

my_instance = MyProductionClass()
my_instance = Mock()
my_instance.some_method.return_value = 15

assert my_instance.some_method(10, 15, 20) == 15
my_instance.some_method.assert_called_once()
my_instance.some_method.assert_called_once_with(10, 15, 20)

Mock possui uma lista de métodos que podem ser utilizados para garantir que as chamadas foram efetuadas da forma que desejamos.

Todo objeto para qual uma instância de Mock é atribuído, herda todos os métodos de tal, portanto, como nos exemplos demonstrados acima, my_instance passa ser uma instância de Mock e não mais de MyProductionClass. Ao listar os métodos da instância my_instance o resultado é o seguinte:

print(dir(my_instance))
"""
[
'assert_any_call', 'assert_called', 'assert_called_once',
'assert_called_once_with', 'assert_called_with', 'assert_has_calls',
'assert_not_called', 'attach_mock', 'call_args', 'call_args_list',
'call_count', 'called', 'configure_mock', 'method_calls', 'mock_add_spec',
'mock_calls', 'reset_mock', 'return_value', 'side_effect', 'some_method'
]
"""

A lista retornada são os métodos que podem ser utilizados a partir de my_instance para testar o comportamento da instância em questão.

Além de retornar valores, em alguns casos quereremos cobrir cenários de falhas, ou melhor, cenários de efeitos colaterais, ou side effects. É possível lançar exceções para simular esses comportamentos através do objeto Mock também.

Voltando ao nosso exemplo de requisição GET, quando temos uma chamada HTTP diversos erros podem acontecer, como timeout, serviço indisponível, etc. Nós podemos simular um timeout em nossa chamada GET através do objeto Mock.

Por exemplo, queremos fazer uma requisição para buscar todos os feriados do ano de 2024, e para tal utilizaremos uma API que retorne todos os feriados para determinado ano. Podemos forçar um erro de timeout uma vez que efetuamos o mock do objeto requests.

import requests
import pytest
from unittest.mock import Mock

def get_holidays():
r = requests.get('<http://holidays.com/api/holidays?year=2024>')
if r.status_code == 200:
return r.json()
return None

requests = Mock()

def test_get_holidays_timeout():
requests.get.side_effect = Timeout("too much time to complete")
with pytest.raises(Timeout):
get_holidays()

Dessa forma simulamos um comportamento indesejado do método e podemos trabalhar e cima disso, sem realizar a chamada HTTP de fato.

Continuando em nosso exemplo de chamada HTTP, podemos utilizar o mock para retornar objetos mais complexos, como a resposta dessa chamada.

import requests
import pytest
from unittest.mock import Mock

def get_holidays():
r = requests.get('<http://holidays.com/api/holidays?year=2024>')
if r.status_code == 200:
return r.json()
return None

requests = Mock()

def test_get_holidays():
mocked_response = Mock()
mocked_response.status_code = 200
mocked_response.json.return_value = [{'Christmas': '25/12'}]
requests.get.return_value = mocked_response
assert get_holidays() == [{'Christmas': '25/12'}]
mocked_response.json.assert_called_once()

Primeiro instânciamos o uma resposta simulada, mocked_response, e utilizamos a flexibilidade da classe Mock para especificar qual o retorno desejado.

O método side_effect não serve apenas para lançar exceções, mas também para casos onde fazemos a chamada para o mesmo método mais de uma vez e recebemos resultados diferentes, ou até o mesmo.

No exemplo abaixo, a primeira requisição retornou um timeout, por conta do erro tentamos novamente e na segunda requisição temos o resultado esperado.

import requests
from requests.exceptions import Timeout
import pytest
from unittest.mock import Mock

def get_holidays():
r = requests.get('<http://holidays.com/api/holidays?year=2024>')
if r.status_code == 200:
return r.json()
return None

requests = Mock()

def test_get_holidays_retry():
mocked_response = Mock()
mocked_response.status_code = 200
mocked_response.json.return_value = [{'Christmas': '25/12'}]
requests.get.side_effect = [Timeout, mocked_response]
with pytest.raises(Timeout):
get_holidays()
resp = get_holidays()
assert resp.json() == [{'Christmas': '25/12'}]

Na primeira chamada ocorre um timeout e a exceção é lançada. Já a segunda chamada é realizada com sucesso e temos o retorno esperado.

Esse comportamento também pode ser atribuído no momento de instânciar Mock, fornecendo os retornos desejados diretamente em seu construtor.

def test_get_holidays_retry():
mocked_response = Mock(status_code=200, json=Mock(return_value=[{'Christmas': '25/12'}]))
requests = Mock(get=Mock(side_effect=[Timeout, mocked_response]))

with pytest.raises(Timeout):
get_holidays()
resp = get_holidays()
assert resp.json() == [{'Christmas': '25/12'}]

Patching

Atribuir uma instância de Mock para o objeto que desejamos simular o comportamento funciona bem nos casos onde o módulo do teste possui acesso às mesmas dependências do módulo de produção. Entretando em grande parte dos casos ambos os módulos estarão separados, e o teste dificilmente terá acesso às mesmas instâncias de que a classe de produção tem.

Por isso podemos utilizar uma técnica denominada de patching para que o Mock da dependência seja injetado diretamente em nosso teste.

Nos exemplos acima da chamada GET, tanto a função get_holidays como o seu teste test_get_holidays estão no mesmo arquivo, portanto o teste tem acesso à mesma instância de requests que a função possui. Caso isso não fosse verdade, seria necessário fazer uso do patching.

O patching pode ser utilizado como um decorator ou um context manager. Abaixo temos exemplos utilizando ambas as abordagens.

# holidays.py
import requests

def get_holidays():
r = requests.get('<http://holidays.com/api/holidays?year=2024>')
if r.status_code == 200:
return r.json()
return None

# test_holidays.py
from unittest import mock

@mock.patch('holidays.requests') # using as a decorator
def test_get_holidays(mocked_requests: mock.Mock):
mocked_response = mock.Mock(
status_code=200,
json=mock.Mock(return_value=[{'Christmas': '25/12'}]),
)
mocked_requests.get.return_value = mocked_response
assert get_holidays() == [{'Christmas': '25/12'}]
mocked_response.json.assert_called_once()

Quando utilizada como um decorator o objeto será injetado na lista de argumentos da função de teste. Esse objeto será uma instância de Mock e todas as operações de mocking poderão ser executadas normalmente a partir dele.

# test_holidays.py
from unittest import mock

def test_get_holidays():
mocked_response = mock.Mock(
status_code=200,
json=mock.Mock(return_value=[{'Christmas': '25/12'}]),
)
# using as a context a manager
with mock.patch('holidays.requests') as mocked_requests:
mocked_requests.get.return_value = mocked_response
assert get_holidays() == [{'Christmas': '25/12'}]
mocked_response.json.assert_called_once()

Utilizar como um context manager é bastante parecido, com a grande diferença sendo que a instância é criada dentro do teste, ao invés de ser injetada.

Além de realizar o mocking do objeto completo, podemos fazer apenas do método que desejamos testar, através do patch.object. No exemplo abaixo, ao invés de “mockar” o objeto requests por completo, fazemos isso apenas em seu método get.

from unittest import mock

@mock.patch.object(requests, 'get')
def test_get_holidays(mocked_get: mock.Mock):
mocked_response = Mock(status_code=200, json=Mock(return_value=[{'Christmas': '25/12'}]))
mocked_get.return_value = mocked_response
assert get_holidays() == [{'Christmas': '25/12'}]
mocked_response.json.assert_called_once()

Essa opção é bastante útil quando queremos manter o comportamento do restante da classe, simulando o comportamento apenas de métodos específicos.

Autospeccing

Como vimos, o objeto Mock é bastante flexível, podendo “assumir a identidade” de diferentes tipos de objetos. Com isso algumas falhas podem ocorrer, como por exemplo alterar o comportamento de um método inexistente da classe “mockada”.

from unittest.mock import Mock

class MyProductionClass:
def sum_2(self, num):
return num + 2

my_instance = MyProductionClass()
my_instance = Mock()
my_instance.sum2.return_value = 5
assert my_instance.sum2(3) == 5

No exemplo acima MyProductionClass possui o método sum_2, porém ao efetuar o mocking houve um erro de digitação e o método utilizado é um método inexistente na classe. Entretanto, pela natureza flexível do Mock essa operação foi permitida e por mais que o assert esteja retornando verdadeiro, o teste não está simulando o comportamento corretamente devido a sua utilização em um método que não existe na classe original.

Para se prevenir desse tipo de problema utilizamos o autospeccing que nada mais é do que forçar a instância de Mock a seguir a especificação do seu objeto real.

Existem diferentes formas para utilizá-lo, uma delas é representada no exemplo abaixo, onde informamos qual a especificação (spec) que o Mock deverá seguir. Nesse caso é a especificação da classe MyProductionClass.

from unittest.mock import Mock

class MyProductionClass:
def sum_2(self, num):
return num + 2

my_instance = MyProductionClass()
my_instance = Mock(spec=MyProductionClass) # speccing the MyProductionClass
my_instance.sum2.return_value = 5
assert my_instance.sum2(3) == 5
"""
raise AttributeError("Mock object has no attribute %r" % name)
AttributeError: Mock object has no attribute 'sum2'. Did you mean: 'sum_2'?
"""

Nesse caso um AttributeError é lançado, especificando que a classe não possui o método sum2, mas sim o sum_2, que é o método verdadeiro.

As diferentes formas de utilização do autospeccing são encontrada na documentação. Essa é uma forma bastante útil de prevenir diversos erros durante a escrita dos testes.

Conclusão do curso

É um curso muito mais extenso do que o primeiro, cobrindo praticamente todos os tópicos relacionados ao objeto Mock. Por mais que todo o conteúdo do curso também possa ser encontrado na documentação, eu ainda achei o curso bastante interessante por sintetizar diversos tópicos de maneira que facilite o entendimento do aluno.

Entretanto ainda senti falta dos dois tópicos abaixo:

  • MagicMock
  • AsyncMock

É um ótimo curso para quem está iniciando a escrever testes em Python.

Test-Driven Development With pytest

Esse foi o curso que eu estava mais animado, pelo fato de trabalhar com TDD diariamente, e infelizmente foi a minha maior decepção.

Não irei me ater ao conteúdo do curso, pois em momento algum o TDD foi coberto. O curso é basicamente o instrutor escrevendo o teste e logo após, implementando totalmente os métodos da classe, sem se preocupar com ciclos red-green-refactor, e demais práticas relacionadas ao TDD em si. O conteúdo é bastante raso, e cobre de maneira superficial apenas o pytest.

Embora seja bastante curto e você consiga finalizá-lo em apenas algumas horas em frente ao computador, não vale a pena se você estiver interessado em aprender apenas o TDD, pois não é coberto aqui.

Conclusão geral

Os dois primeiros cursos que apresentei são bastante interessantes para iniciar com testes automatizados em Python, principalmente o segundo que é bastante completo e cobre uma biblioteca padrão da linguagem.

Apenas com os dois primeiros você já se torna de capaz de se movimentar sozinho no aprendizado e aperfeiçoamento na sua escrita de testes, pois embora sejam bons conteúdos, não da para ficar apenas com eles, é preciso pesquisar mais e continuar se especializando para se tornar um bom escritor de testes.

Como comentei anteriormente, os cursos são bastante técnicos, aprensentam as ferramentas e como utilizá-las. Embora tenha sentido falta de tópicos de como escrever bons testes, entendo que não era o intuito dos mesmos, que o objetivo era de apenas apresentar os frameworks, e não ensinar como escrever bons testes.

Em relação ao útlimo curso, tive uma grande decepção. Não apenas pelo conteúdo ser fraco, mas por pessoas acreditarem que TDD seja apenas escrever os testes e logo após, o código completo de produção, ao invés do processo criativo que acredito ser.

--

--