Kodumaro :: Quebra de Contrato

Publicado em 13 de Maio, 2016
As sombras da programação.
Python

É muito comum programadores inexperientes desrespeitarem contratosestabelecidos, algumas vezes por não saberem como resolver uma situação de outra forma, outras por Dunning-Kruger.

Um dos erros mais comuns que tenho visto no mundo Python é a quebra do contrato da classe TestCase.

Métodos hook

Geralmente a quebra de contrato ocorre porque o programador não entende o conceito de hook ou não sabe como evitar chamar super na herança.

Hook é aquele método preenchido pelo código principal para ser chamado por um framework. Por definição, o hook não deve ser chamado no código de aplicação, ou deve ser evitado. Em seu lugar é feita chamada para alguma rotina do framework que, por sua fez, usa o hook dentro da conveniência do mesmo.

Na biblioteca de unit test de Python, os métodos de TestCase setUp e tearDown (de instância), e setUpClass e tearDownClass são métodos hook, evocados pelo framework de teste unitário.

O problema

O programador júnior, muito satisfeito com seu entendimento sobre herança,quer então aplicar o mesmo no máximo de soluções possíveis e encontrou uma abordagem em que a herança se encaixa como uma luva: nos testes unitários, ele precisa iniciar uma sessão com o banco (um SQLite) antes de cada teste, e encerrá-lo ao final de cada um.

Ele sabe que o método setUp é executado antes de cada teste, enquanto o método tearDown é executado ao final, havendo erro, falha ou sucesso. Perfeito!

Mas ele precisa que todas as classes de teste usem esses métodos, então ele pensa numa solução que parece perfeita: implementar os métodos hook numa classe pai, que será herdada pelas demais classes de teste.

A coisa fica mais ou menos assim:

import unittest
import sqlite3
import my_app

__all__ = ['TestCase']


class TestCase(unittest.TestCase):

    def setUp(self):
        conn = my_app.conn = sqlite3.connect(':memory:')
        conn.execute("""CREATE TABLE t_user (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            name TEXT,
                            birth DATE,
                            register INTEGER
                        )""")
        conn.commit()

    def tearDown(self):
        my_app.conn.close()

O novo problema

Tudo ia às mil maravilhas, até nosso programador descobrir que, em um de seustestes, ele precisa simular o comportamento de um acesso a um servidor Redis, o que sobrescreve sua perfeita solução de banco de dados!

Qual a nova solução? O que se faz em herança: evocar super!

from unittest.mock import patch
from my_test_case import TestCase
from my_app import CacheManager

__all__ = ['TestCacheManager']


class TestCacheManager(TestCase):

    def setUp(self):
        super().setUp()
        redis_patch = self.redis_patch = patch('my_app.Redis')
        self.redis = redis_patch.start()

    def tearDown(self):
        self.redis_patch.stop()
        super().tearDown()

    """Seguem os testes..."""

O que nosso resoluto programador júnior não percebeu – ou percebeu, mas não sacou como resolver – é que o contrato do unittest foi quebrado.

A abordagem correta

A regra de ouro do unittest é: jamais implemente métodos hook em classes que não sejam testes de fato, que sejam abstrações de comportamento para casos de teste de fato.

— Mas como resolver então? 😯

Como sua classe pai tem o objetivo de alterar o comportamento do framework, sobrescreva rotinas internas do framework.

No caso, o método cujo comportamento você quer mudar é run. A alteração será parecida com a criação de um contexto usando contextlib.contextmanager, apenas trocando o yield por super:

class TestCase(unittest.TestCase):

    def run(self, result=None):
        conn = my_app.conn = sqlite3.connect(':memory:')
        conn.execute("""CREATE TABLE t_user (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            name TEXT,
                            birth DATE,
                            register INTEGER
                        )""")
        conn.commit()

        try:
            return super().run(result=result)
        finally:
            conn.close()

Pronto! O problema já está resolvido! Agora nossa classe TestCase já possui comportamento de framework, e os hooks podem ser usados corretamente:

class TestCacheManager(TestCase):

    def setUp(self):
        redis_patch = self.redis_patch = patch('my_app.Redis')
        self.redis = redis_patch.start()

    def tearDown(self):
        self.redis_patch.stop()

          """Seguem os testes..."""

Conclusão

Esses problemas são causados por desentendimento dos contratos edesconhecimento da linguagem, mas são facilmente resolvidos com um pouco de pesquisa e leitura dos códigos das bibliotecas padrão, que são muito bem documentadas.