terça-feira, 27 de setembro de 2022

Como fazer refatoração de codigo usando Python

❝Depois de ter tocado uma grande quantidade de notas e mais notas, é a simplicidade que surge como a recompensa máxima da arte.❞
Frédéric Chopin

Mergulhando

Goste ou não, bugs acontecem. Apesar de seus melhores esforços para escrever testes de unidade abrangentes, bugs acontecem. O que quero dizer com “bug”? Um bug é um caso de teste que você ainda não escreveu.


>>> import roman7
>>> roman7.from_roman('') ①
0
  • ① Este é um erro. Uma string vazia deve gerar uma exceção InvalidRomanNumeralError, assim como qualquer outra sequência de caracteres que não representa um numeral romano válido.

Após reproduzir o bug, e antes de corrigi-lo, você deve escrever um caso de teste que falhe, ilustrando assim o bug.


class FromRomanBadInput(unittest.TestCase):  
    .
    .
    .
    def testBlank(self):
        '''from_roman should fail with blank string'''
        self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, '') ①
① Coisas bem simples aqui. Chame from_roman() com uma string vazia e verifique se ela gera uma exceção InvalidRomanNumeralError. A parte difícil foi encontrar o bug; agora que você sabe sobre isso, testá-lo é a parte mais fácil.

Como seu código tem um bug e agora você tem um caso de teste que testa esse bug, o caso de teste falhará:

you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... FAIL
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

======================================================================
FAIL: from_roman should fail with blank string
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest8.py", line 117, in test_blank
    self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, '')
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 11 tests in 0.171s

FAILED (failures=1)

Agora você pode corrigir o bug.


def from_roman(s):
    '''convert Roman numeral to integer'''
    if not s:                                                                  ①
        raise InvalidRomanNumeralError('Input can not be blank')
    if not re.search(romanNumeralPattern, s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.format(s))  ②

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
  • ① Apenas duas linhas de código são necessárias: uma verificação explícita de uma string vazia e uma instrução raise.
  • ② Acho que ainda não mencionei isso em nenhum lugar deste livro, então deixe isso servir como sua lição final sobre formatação de strings. A partir do Python 3.1, você pode pular os números ao usar índices posicionais em um especificador de formato. Ou seja, em vez de usar o especificador de formato {0} para se referir ao primeiro parâmetro do método format(), você pode simplesmente usar {} e o Python preencherá o índice posicional adequado para você. Isso funciona para qualquer número de argumentos; o primeiro {} é {0}, o segundo {} é {1}, e assim por diante.
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... ok  ①
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 11 tests in 0.156s

OK  ②
  • ① O caso de teste de string em branco agora passa, então o bug foi corrigido.
  • ② Todos os outros casos de teste ainda passam, o que significa que essa correção de bug não quebrou mais nada. Pare de codificar.

Codificar dessa maneira não facilita a correção de bugs. Bugs simples (como este) requerem casos de teste simples; bugs complexos exigirão casos de teste complexos. Em um ambiente centrado em testes, pode parecer que leva mais tempo para corrigir um bug, já que você precisa articular no código exatamente o que é o bug (para escrever o caso de teste) e, em seguida, corrigir o próprio bug. Então, se o caso de teste não passar imediatamente, você precisa descobrir se a correção estava errada ou se o próprio caso de teste tem um bug nele. No entanto, a longo prazo, esse vai-e-vem entre o código de teste e o código testado se paga, porque torna mais provável que os bugs sejam corrigidos corretamente na primeira vez. Além disso, como você pode facilmente executar novamente todos os casos de teste junto com o novo, é muito menos provável que você quebre o código antigo ao corrigir o novo. O teste de unidade de hoje é o teste de regressão de amanhã.

Manipulação de Requisitos de Alteração

Apesar de seus melhores esforços para prender seus clientes no chão e extrair os requisitos exatos deles sob pena de coisas horríveis e desagradáveis envolvendo tesoura e cera quente, os requisitos mudarão. A maioria dos clientes não sabe o que quer até vê-lo e, mesmo que saiba, não é tão bom em articular o que quer com precisão suficiente para ser útil. E mesmo se o fizerem, eles vão querer mais no próximo lançamento de qualquer maneira. Portanto, esteja preparado para atualizar seus casos de teste à medida que os requisitos mudam.

Suponha, por exemplo, que você queira expandir o intervalo das funções de conversão de numeral romano. Normalmente, nenhum caractere em um numeral romano pode ser repetido mais de três vezes seguidas. Mas os romanos estavam dispostos a abrir uma exceção a essa regra, tendo 4 caracteres M seguidos para representar 4000. Se você fizer essa alteração, poderá expandir o intervalo de números conversíveis de 1..3999 para 1..4999. Mas primeiro, você precisa fazer algumas alterações em seus casos de teste.


class KnownValues(unittest.TestCase):
  known_values = ( (1, 'I'),
                    .
                    .
                    .
                   (3999, 'MMMCMXCIX'),
                   (4000, 'MMMM'),                                      ①
                   (4500, 'MMMMD'),
                   (4888, 'MMMMDCCCLXXXVIII'),
                   (4999, 'MMMMCMXCIX') )

class ToRomanBadInput(unittest.TestCase):
  def test_too_large(self):
      '''to_roman should fail with large input'''
      self.assertRaises(roman8.OutOfRangeError, roman8.to_roman, 5000)  ②

  .
  .
  .

class FromRomanBadInput(unittest.TestCase):
  def test_too_many_repeated_numerals(self):
      '''from_roman should fail with too many repeated numerals'''
      for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):     ③
          self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, s)

  .
  .
  .

class RoundtripCheck(unittest.TestCase):
  def test_roundtrip(self):
      '''from_roman(to_roman(n))==n for all n'''
      for integer in range(1, 5000):                                    ④
          numeral = roman8.to_roman(integer)
          result = roman8.from_roman(numeral)
          self.assertEqual(integer, result)
  • ① Os valores conhecidos existentes não mudam (todos ainda são valores razoáveis para testar), mas você precisa adicionar mais alguns no intervalo 4000. Aqui eu incluí 4000 (o mais curto), 4500 (o segundo mais curto), 4888 (o mais longo) e 4999 (o maior).
  • ② A definição de “grande entrada” mudou. Esse teste costumava chamar to_roman() e esperar 4000 um erro; agora que 4000-4999 são bons valores, você precisa aumentar isso para 5000.
  • ③ A definição de “muitos numerais repetidos” também mudou. Esse teste costumava chamar from_roman() e esperar 'MMMM' um erro; agora que MMMM é considerado um numeral romano válido, você precisa aumentar isso para 'MMMMM'.
  • ④ A verificação de sanidade percorre todos os números no intervalo, de 1 a 3999. Como o intervalo agora foi expandido, esse loop for também precisa ser atualizado para ir até 4999.

Agora seus casos de teste estão atualizados com os novos requisitos, mas seu código não está, então você espera que vários dos casos de teste falhem.

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ERROR          ①
to_roman should give known result with known input ... ERROR            ②
from_roman(to_roman(n))==n for all n ... ERROR                          ③
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

======================================================================
ERROR: from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 82, in test_from_roman_known_values
    result = roman9.from_roman(numeral)
  File "C:\home\diveintopython3\examples\roman9.py", line 60, in from_roman
    raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
roman9.InvalidRomanNumeralError: Invalid Roman numeral: MMMM

======================================================================
ERROR: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 76, in test_to_roman_known_values
    result = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)

======================================================================
ERROR: from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 131, in testSanity
    numeral = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)

----------------------------------------------------------------------
Ran 12 tests in 0.171s

FAILED (errors=3)
  • ① O teste from_roman() de valores conhecidos falhará assim que atingir 'MMMM', porque from_roman() ainda acha que este é um numeral romano inválido.
  • ② O teste to_roman() de valores conhecidos falhará assim que atingir 4000, porque to_roman() ainda acha que isso está fora do intervalo.
  • ③ A verificação de ida e volta também falhará assim que atingir 4000, porque to_roman() ainda acha que isso está fora do alcance.

Agora que você tem casos de teste que falham devido aos novos requisitos, você pode pensar em corrigir o código para alinhá-lo com os casos de teste. (Quando você começa a codificar testes de unidade, pode parecer estranho que o código que está sendo testado nunca esteja “à frente” dos casos de teste. casos, você para de codificar. Depois de se acostumar, você vai se perguntar como você programou sem testes.)


roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 Ms  ①
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    ''', re.VERBOSE)

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not isinstance(n, int):
        raise NotIntegerError('non-integers can not be converted')
    if not (0 < n < 5000):                        ②
        raise OutOfRangeError('number out of range (must be 1..4999)')

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result

def from_roman(s):
    .
    .
    .
  • ① Você não precisa fazer nenhuma alteração na função from_roman(). A única mudança é para roman_numeral_pattern. Se você observar atentamente, notará que alterei o número máximo de caracteres M opcionais de 3 para 4 na primeira seção da expressão regular. Isso permitirá que os números romanos equivalentes de 4999 em vez de 3999. A função from_roman() real é completamente genérica; ela apenas procura por caracteres numéricos romanos repetidos e os soma, sem se importar com quantas vezes eles se repetem. A única razão pela qual ele não lidou com 'MMMM' antes é que você o interrompeu explicitamente com a correspondência de padrões de expressão regular.
  • ② A função to_roman() precisa apenas de uma pequena alteração, na verificação de intervalo. Onde você costumava checar 0 < n < 4000, agora você checa 0 < n < 5000. E você altera a mensagem de erro raise para refletir o novo intervalo aceitável (1..4999 em vez de 1..3999). Você não precisa fazer nenhuma alteração no restante da função; ele já lida com os novos casos. (Ele adiciona alegremente 'M' para cada mil que encontra; dado 4000, ele cuspirá 'MMMM'. A única razão pela qual não fez isso antes é que você o interrompeu explicitamente com a verificação de intervalo.)

Você pode estar cético de que essas duas pequenas mudanças são tudo o que você precisa. Ei, não acredite na minha palavra; Veja por si mesmo.

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.203s

OK  ①
  • ① Todos os casos de teste passam. Pare de codificar.

Testes unitários abrangentes significam nunca ter que confiar em um programador que diz “Confie em mim”.

Refatoração

A melhor coisa sobre o teste de unidade abrangente não é a sensação que você tem quando todos os seus casos de teste finalmente passam, ou mesmo a sensação que você tem quando alguém o culpa por quebrar seu código e você pode realmente provar que não o fez. A melhor coisa sobre o teste de unidade é que ele lhe dá a liberdade de refatorar impiedosamente.

A refatoração é o processo de pegar o código de trabalho e fazê-lo funcionar melhor. Normalmente, “melhor” significa “mais rápido”, embora também possa significar “usando menos memória”, ou “usando menos espaço em disco”, ou simplesmente “com mais elegância”. Seja o que for que isso signifique para você, para seu projeto, em seu ambiente, a refatoração é importante para a saúde de longo prazo de qualquer programa.

Aqui, “melhor” significa “mais rápido” e “mais fácil de manter”. Especificamente, a função from_roman() é mais lenta e mais complexa do que eu gostaria, por causa daquela grande expressão regular desagradável que você usa para validar numerais romanos. Agora, você pode pensar: “Claro, a expressão regular é grande e cabeluda, mas de que outra forma eu posso validar que uma string arbitrária é um numeral romano válido?”

Resposta: há apenas 5.000 deles; por que você não cria uma tabela de pesquisa? Essa ideia fica ainda melhor quando você percebe que não precisa usar expressões regulares. À medida que você cria a tabela de pesquisa para converter números inteiros em numerais romanos, você pode construir a tabela de pesquisa inversa para converter numerais romanos em números inteiros. Quando você precisar verificar se uma string arbitrária é um numeral romano válido, você terá coletado todos os algarismos romanos válidos. A “validação” é reduzida a uma única pesquisa de dicionário.

E o melhor de tudo, você já tem um conjunto completo de testes unitários. Você pode alterar mais da metade do código no módulo, mas os testes de unidade permanecerão os mesmos. Isso significa que você pode provar – para si mesmo e para os outros – que o novo código funciona tão bem quanto o original.


class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
class InvalidRomanNumeralError(ValueError): pass

roman_numeral_map = (('M',  1000),
                     ('CM', 900),
                     ('D',  500),
                     ('CD', 400),
                     ('C',  100),
                     ('XC', 90),
                     ('L',  50),
                     ('XL', 40),
                     ('X',  10),
                     ('IX', 9),
                     ('V',  5),
                     ('IV', 4),
                     ('I',  1))

to_roman_table = [ None ]
from_roman_table = {}

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]

def build_lookup_tables():
    def to_roman(n):
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)
        to_roman_table.append(roman_numeral)
        from_roman_table[roman_numeral] = integer

build_lookup_tables()

Vamos quebrar isso em pedaços digeríveis. Indiscutivelmente, a linha mais importante é a última:

build_lookup_tables()

Você notará que é uma chamada de função, mas não há nenhuma instrução if em torno dela. Este não é um bloco if __name__ == '__main__'; ele é chamado quando o módulo é importado. (É importante entender que os módulos são importados apenas uma vez e, em seguida, armazenados em cache. Se você importar um módulo já importado, ele não fará nada. Portanto, esse código só será chamado na primeira vez que você importar este módulo.)

Então, o que a função build_lookup_tables() faz? Estou feliz que você perguntou.


to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
    def to_roman(n):                                ①
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)          ②
        to_roman_table.append(roman_numeral)       ③
        from_roman_table[roman_numeral] = integer
  • ① Esta é uma programação inteligente... talvez inteligente demais. A função to_roman() é definida acima; ela procura valores na tabela de pesquisa e os retorna. Mas a função build_lookup_tables() redefine a função to_roman() para realmente funcionar (como os exemplos anteriores fizeram, antes de você adicionar uma tabela de pesquisa). Dentro da função build_lookup_tables(), chamará to_roman() esta versão redefinida. Uma vez que a função build_lookup_tables() é encerrada, a versão redefinida desaparece — ela é definida apenas no escopo local da função build_lookup_tables().
  • ② Essa linha de código chamará a função to_roman() redefinida, que na verdade calcula o numeral romano.
  • ③ Depois de obter o resultado (da função redefinida to_roman()), você adiciona o inteiro e seu equivalente em numeral romano a ambas as tabelas de pesquisa.

Depois que as tabelas de pesquisa são criadas, o restante do código é fácil e rápido.


def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]                                            ①

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]                                          ②
  • ① Depois de fazer a mesma verificação de limites de antes, a função to_roman() simplesmente encontra o valor apropriado na tabela de pesquisa e o retorna.
  • ② Da mesma forma, a função from_roman() é reduzida a algumas verificações de limites e uma linha de código. Não há mais expressões regulares. Não há mais looping. O(1) conversão de e para algarismos romanos.

Mas isso funciona? Por que sim, sim ele faz. E eu posso provar isso.

you@localhost:~/diveintopython3/examples$ python3 romantest10.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.031s                                                  ①

OK
  • ① Não que você tenha pedido, mas é rápido também! Tipo, quase 10 vezes mais rápido. Claro, não é uma comparação totalmente justa, porque esta versão demora mais para importar (quando constrói as tabelas de pesquisa). Mas como a importação é feita apenas uma vez, o custo de inicialização é amortizado sobre todas as chamadas para as funções to_roman() e from_roman(). Como os testes fazem vários milhares de chamadas de função (somente o teste de ida e volta faz 10.000), essa economia aumenta rapidamente!

A moral da história?

  • A simplicidade é uma virtude.
  • Especialmente quando expressões regulares estão envolvidas.
  • Os testes de unidade podem dar a você a confiança para fazer refatoração em larga escala.

Resumo

O teste unitário é um conceito poderoso que, se implementado corretamente, pode reduzir os custos de manutenção e aumentar a flexibilidade em qualquer projeto de longo prazo. Também é importante entender que o teste de unidade não é uma panacéia, um Magic Problem Solver ou uma bala de prata. Escrever bons casos de teste é difícil, e mantê-los atualizados exige disciplina (especialmente quando os clientes estão gritando por correções críticas de bugs). O teste de unidade não substitui outras formas de teste, incluindo testes funcionais, testes de integração e testes de aceitação do usuário. Mas é viável, e funciona, e uma vez que você o tenha visto funcionar, você se perguntará como conseguiu viver sem ele.

Esses poucos capítulos cobriram muito terreno, e muitos deles nem eram específicos do Python. Existem estruturas de teste de unidade para muitas linguagens, todas as quais exigem que você entenda os mesmos conceitos básicos:

  • Projetar casos de teste específicos, automatizados e independentes
  • Escrevendo casos de teste antes do código que estão testando
  • Escrevendo testes que testam uma boa entrada e verificam os resultados adequados
  • Escrevendo testes que testam entradas incorretas e verificam as respostas de falhas adequadas
  • Escrevendo e atualizando casos de teste para refletir novos requisitos
  • Refatorando impiedosamente para melhorar o desempenho, escalabilidade, legibilidade, manutenibilidade ou qualquer outra -ilidade que esteja faltando

Traduzido por Acervo Lima. O original pode ser acessado aqui.

0 comentários:

Postar um comentário