dublê

Test Double - O termo genérico Link para o cabeçalho

O termo Test Double (dublê de teste) foi cunhado por Gerard Meszaros em seu livro “xUnit Test Patterns”, como analogia aos dublês de cinema (stunt doubles). Um Test Double é qualquer objeto que substitui um objeto de produção para propósitos de teste.

Assim como no cinema diferentes dublês são usados para diferentes cenas (dublês de ação, de corpo, etc.), nos testes temos diferentes tipos de dublês, cada um com seu propósito específico.

Normalmente quando falamos de mocks, estamos nos referindo a um componente simulado de software. Porém existem vários tipos de simulações que podem ser feitas que podem ajudar a escrever os testes.

⚠️ Os códigos deste post utilizam pytest-mock e requests que são libs de terceiros. Instale com:

pip install pytest-mock requests

Como base para nossos testes, vamos definir a seguinte função que realiza uma requisição web e retorna o conteúdo de sua resposta. Porém caso algum erro ocorra, deve retornar um conteúdo vazio e imprimir um alerta com o erro ocorrido.

import requests
from logging import warning


def fetch_page_content(url, timeout=1):
    try:
        response = requests.get(url, timeout=timeout)
        response.raise_for_status()
    except IOError as exc:
        warning(exc)
        return ""
    else:
        return response.text

Vamos conceituar cada um dos tipos de dublês de teste e como poderiam ser utilizados para testar a função apresentada acima.

💡 As definições em inglês foram retiradas de: https://martinfowler.com/bliki/TestDouble.html.

Dummy Link para o cabeçalho

Dummy objects are passed around but never actually used. Usually they are just used to fill parameter lists.

São objetos “dummy”, ou seja falsos, fictícios, que serão utilizados apenas para preencher a lista de parâmetros obrigatórios, mas não serão utilizados.

Para ilustrar, vamos imaginar uma versão da nossa função que recebe um logger como parâmetro, mas em certos fluxos ele não é usado:

class TestFetchPageContentDummy:
    def test_dummy_parameter_example(self, mocker):
        """Exemplo onde passamos um objeto que nunca é usado"""
        # Criamos um objeto dummy - será passado mas nunca usado
        dummy_logger = mocker.MagicMock()
        
        # Substituímos a requisição real
        mocked_get = mocker.patch.object(requests, "get", autospec=True)
        mocked_get.return_value.status_code = 200
        mocked_get.return_value.text = "content"
        
        # A função não usa o logger, então é um dummy
        # Ele só está ali para satisfazer a assinatura
        assert fetch_page_content("url") == "content"
        
        # Confirmamos que o dummy nunca foi chamado
        dummy_logger.assert_not_called()

Fake Link para o cabeçalho

Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an InMemoryTestDatabase is a good example).

São objetos falsos, com implementações concretas, porém simplificadas. Um bom exemplo são objetos que representam bancos de dados ou arquivos, porém com implementações em memória.

Para utilização da técnica de objetos fakes, vamos primeiro fazer uma inversão de dependência no código de modo a receber o objeto responsável por realizar as requisições como parâmetro.

import requests
from logging import warning


def requests_fetcher(url, timeout=1):
    response = requests.get(url, timeout=timeout)
    response.raise_for_status()
    return response


def fetch_page_content(fetcher, url, timeout=1):
    try:
        response = fetcher(url, timeout)
    except IOError as exc:
        warning(exc)
        return ""
    else:
        return response.text

Nos testes podemos substituir a função fetcher, por uma função fake com uma implementação simplificada.

class TestFetchPageContentFake:
    def test_when_request_is_ok(self):
        # preparamos nosso teste definindo o retorno esperado
        expected_content = "page_content"

        def fake_fetcher(url, timeout=1):
            # definimos uma função falsa que substituirá a requisição real

            # definimos objeto de resposta falso com o conteúdo definido
            # como conteúdo esperado
            class FakeResponse:
                def __init__(self):
                    self.text = expected_content
            # a função retornará este objeto como resposta quando invocada
            return FakeResponse()

        # por fim fazemos a afirmação do nosso teste
        # verificamos se o resultado da função é igual ao conteúdo esperado
        assert (
            fetch_page_content(fake_fetcher, "dummy url") == expected_content
        )

    def test_when_request_fail(self):
        # em nossa preparação estamos definindo que o retorno esperado
        # é uma string vazia
        expected_content = ""

        # nossa função fake agora é simplificada lançando uma exceção
        # pois esse é o comportamento esperado se essa requisição fosse real
        def fake_fetcher(url, timeout=1):
            raise IOError("Failure on request")
        # por fim comparamos se o resultado obtido foi o mesmo do esperado
        assert (
            fetch_page_content(fake_fetcher, "dummy url") == expected_content
        )

Stub Link para o cabeçalho

Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what’s programmed in for the test.

São substitutos que fornecem respostas previamente definidas, simulando assim o comportamento esperado.

Considerando novamente nossa função original:

import requests
from logging import warning


def fetch_page_content(url, timeout=1):
    try:
        response = requests.get(url, timeout=timeout)
        response.raise_for_status()
    except IOError as exc:
        warning(exc)
        return ""
    else:
        return response.text

Nosso teste substituirá o método get da biblioteca requests, retornando respostas programadas e em alguns casos lançando uma exceção.

class TestFetchPageContentStub:
    def test_when_request_is_ok(self, mocker):
        # preparamos nosso teste definindo o retorno esperado
        expected_content = "page_content"

        # substituimos o método get da biblioteca requests por um stub
        # o parâmetro autospec garante que caso cometemos
        # algum erro de digitação
        # a especificação da função será respeitado
        mocked_get = mocker.patch.object(requests, "get", autospec=True)
        # definimos que o objeto retornado possuirá código de status 200
        mocked_get.return_value.status_code = 200
        # o objeto retornado possuirá o texto esperado
        mocked_get.return_value.text = expected_content

        # comparamos o retorno esperado e o obtido
        assert fetch_page_content("dummy url") == expected_content

    def test_when_request_fail(self, mocker):
        # em nossa preparação estamos definindo que o retorno esperado
        # é uma string vazia
        expected_content = ""

        # substituimos o método get por um stub
        mocked_get = mocker.patch.object(requests, "get", autospec=True)
        # quando invocado o método raise_for_status do retorno do nosso stub
        # uma exceção será lançada
        mocked_get.return_value.raise_for_status.side_effect = (
            requests.HTTPError()
        )

        # por fim comparamos se o resultado obtido foi o mesmo do esperado
        assert fetch_page_content("dummy url") == expected_content

Spies Link para o cabeçalho

Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.

São stubs mas “espionam” como são invocados e mantém isto como informação a ser utilizada nas asserções. A diferença principal é que Spies gravam informações sobre as chamadas para verificação posterior.

from unittest.mock import MagicMock

class TestFetchPageContentSpy:
    def test_when_request_is_ok(self, mocker):
        """Spy = Stub + gravação de informações sobre chamadas"""
        expected_content = "page_content"

        # Criamos um spy - ele vai gravar as chamadas
        mocked_get = mocker.patch.object(requests, "get", autospec=True)
        
        # Também configuramos o retorno (comportamento de Stub)
        mocked_get.return_value.status_code = 200
        mocked_get.return_value.text = expected_content
        
        result = fetch_page_content("dummy url")
        
        # Verificamos o estado (como Stub)
        assert result == expected_content
        # E também verificamos como foi chamado (diferencial do Spy)
        assert mocked_get.call_count == 1

    def test_when_request_fail(self, mocker):
        """Spy também registra chamadas em casos de erro"""
        expected_content = ""

        mocked_get = mocker.patch.object(requests, "get", autospec=True)
        mocked_get.return_value.raise_for_status.side_effect = (
            requests.HTTPError()
        )

        assert fetch_page_content("dummy url") == expected_content
        # O spy registrou que houve uma tentativa de chamada
        assert mocked_get.called

    def test_when_timeout_is_not_passed(self, mocker):
        """Spy verifica os parâmetros usados nas chamadas"""
        dummy_url = "https://example.com"

        mocked_get = mocker.patch.object(requests, "get", autospec=True)
        mocked_get.return_value.status_code = 200
        mocked_get.return_value.text = "content"

        fetch_page_content(dummy_url)

        # A asserção verifica como a função foi chamada
        # Spies "espionam" tanto o estado quanto o comportamento
        mocked_get.assert_called_once_with(dummy_url, timeout=1)

Diferença entre Spy e Mock Link para o cabeçalho

Antes de falarmos sobre Mocks, é importante entender a diferença conceitual:

Spy: Registra informações sobre as chamadas para verificação POSTERIOR. É como ter uma câmera gravando - você analisa depois do fato acontecer.

Mock: Define expectativas PRÉVIAS e pode falhar se não forem atendidas. É como ter um roteiro - o comportamento esperado já está definido antes da execução, e qualquer desvio é detectado.

Mocks Link para o cabeçalho

Mocks are pre-programmed with expectations which form a specification of the calls they are expected to receive. They can throw an exception if they receive a call they don’t expect and are checked during verification to ensure they got all the calls they were expecting.

A preocupação de mocks é verificar se o comportamento do dublê foi o esperado, fazendo asserções se o mock foi invocado, se os parâmetros na invocação foram corretos e o número de vezes em que foi invocado. Mocks definem expectativas antes da execução.

class TestFetchPageContentMock:
    def test_when_timeout_is_not_passed(self, mocker):
        """when invoked, default timeout should be considered"""
        # preparamos nosso teste modificando o método get por um mock
        dummy_url = "dummy url"
        mocked_get = mocker.patch.object(requests, "get", autospec=True)

        # invocamos nossa função
        fetch_page_content(dummy_url)

        # a asserção é feita verificando se o método foi chamado
        # com os parâmetros corretos
        # ou seja, verificamos o comportamento do método
        mocked_get.assert_called_once_with(dummy_url, timeout=1)

    def test_when_called_verify_if_status_code_is_ok(self, mocker):
        # preparamos nosso teste modificando o método get por um mock
        dummy_url = "dummy url"
        mocked_get = mocker.patch.object(requests, "get", autospec=True)

        # invocamos nossa função
        fetch_page_content(dummy_url)

        # a asserção é feita verificando se o método raise_for_status
        # do retorno do nosso método
        # foi invocado somente uma vez
        mocked_get.return_value.raise_for_status.assert_called_once()

Resumo Comparativo Link para o cabeçalho

Para facilitar o entendimento, aqui está uma tabela comparativa dos tipos de dublês:

TipoQuando usarVerificaExemplo de uso
DummyParâmetros obrigatórios não usadosNadaLogger que não será chamado
FakeImplementação simplificada funcionalEstadoBanco de dados em memória
StubRespostas programadas para chamadasEstadoAPI que retorna dados fixos
SpyStub + registro de chamadasEstado + ComportamentoVerificar se email foi enviado
MockExpectativas rigorosas de comportamentoComportamentoValidar ordem de chamadas

Regra prática:

  • Use Stub/Fake para testar ESTADOS (o que é retornado)
  • Use Mock/Spy para testar COMPORTAMENTOS (como foi chamado)

Conclusão Link para o cabeçalho

Temos dois tipos de verificações quando utilizamos dublês: verificações de comportamento (como em mocks e spies) e verificações de estado (como em stubs e fakes). Qual o tipo de dublê que será utilizado vai depender do que você está testando.

Os códigos aqui apresentados são apenas exemplos de como implementar os padrões de dublês, podendo ser implementados de forma diferente dependendo do framework e linguagem utilizados.

Então é isso pessoal!

Até a próxima!

[]’s