sábado, 24 de setembro de 2022

Como fazer testes unitários com Python

A certeza não é o teste da certeza. Temos sido convencidos de muitas coisas que não eram assim.
Oliver Wendell Holmes Jr.

(Não) Mergulhando

Crianças hoje. Tão estragado por esses computadores rápidos e linguagens “dinâmicas” sofisticadas. Escreva primeiro, envie em segundo, depure em terceiro (se alguma vez). Na minha época, tínhamos disciplina. Disciplina, eu digo! Tínhamos que escrever programas à mão, no papel, e alimentá-los no computador em cartões perfurados. E nós gostavamos!

Neste capítulo, você escreverá e depurará um conjunto de funções utilitárias para converter para algarismos romanos. Você viu a mecânica de construção e validação de algarismos romanos em “Estudo de caso: algarismos romanos”. Agora dê um passo para trás e considere o que seria necessário para expandir isso em um utilitário de mão dupla.

As regras para algarismos romanos levam a uma série de observações interessantes:

  1. Existe apenas uma maneira correta de representar um número específico como um numeral romano.
  2. O inverso também é verdadeiro: se uma sequência de caracteres for um numeral romano válido, ela representa apenas um número (ou seja, só pode ser interpretada de uma maneira).
  3. Há um intervalo limitado de números que podem ser expressos como algarismos romanos, especificamente de 1 a 3999. Os romanos tinham várias maneiras de expressar números maiores, por exemplo, colocando uma barra sobre um numeral para representar que seu valor normal deveria ser multiplicado por 1000. Para os propósitos deste capítulo, vamos estipular que os algarismos romanos vão de 1 até 3999.
  4. Não há como representar 0 em algarismos romanos.
  5. Não há como representar números negativos em algarismos romanos.
  6. Não há como representar frações ou números não inteiros em algarismos romanos.

Vamos começar a mapear o que um módulo roman.py deve fazer. Ele terá duas funções principais, to_roman() e from_roman(). A função to_roman() deve receber um inteiro de 1 ate 3999 e retornar a representação de numeral romano como uma string…

Pare aí mesmo. Agora vamos fazer algo um pouco inesperado: escrever um caso de teste que verifique se a função to_roman() faz o que você quer. Você leu certo: você vai escrever um código que testa o código que você ainda não escreveu.

Isso é chamado de desenvolvimento orientado a testes, ou TDD. O conjunto de duas funções de conversão —  to_roman(), e posteriores from_roman() — pode ser escrito e testado como uma unidade, separada de qualquer programa maior que as importe. Python tem uma estrutura para teste de unidade, o módulo unittest com o nome apropriado.

O teste de unidade é uma parte importante de uma estratégia geral de desenvolvimento centrada em testes. Se você escrever testes de unidade, é importante escrevê-los antecipadamente e mantê-los atualizados à medida que o código e os requisitos mudam. Muitas pessoas defendem a escrita de testes antes de escreverem o código que estão testando, e esse é o estilo que vou demonstrar neste capítulo. Mas os testes de unidade são benéficos, não importa quando você os escreve.

  • Antes de escrever código, escrever testes de unidade força você a detalhar seus requisitos de maneira útil.
  • Ao escrever o código, os testes de unidade evitam que você codifique demais. Quando todos os casos de teste forem aprovados, a função estará concluída.
  • Ao refatorar o código, eles podem ajudar a provar que a nova versão se comporta da mesma maneira que a versão antiga.
  • Ao manter o código, fazer testes o ajudará a se proteger quando alguém vier gritando que sua última alteração quebrou o código antigo. (“Mas senhor, todos os testes de unidade passaram quando eu fiz o check-in...”)
  • Ao escrever código em uma equipe, ter um conjunto de testes abrangente diminui drasticamente as chances de seu código quebrar o código de outra pessoa, porque você pode executar seus testes de unidade primeiro. (Já vi esse tipo de coisa em sprints de código. Uma equipe divide a tarefa, todos pegam as especificações de sua tarefa, escrevem testes de unidade para ela e depois compartilham seus testes de unidade com o resto da equipe. Dessa forma, ninguém vai longe demais no desenvolvimento de código que não funciona bem com os outros.)

Uma única pergunta

Um caso de teste responde a uma única pergunta sobre o código que está testando. Um caso de teste deve ser capaz de ser...

  • ...executado completamente sozinho, sem qualquer intervenção humana. O teste de unidade é sobre automação.
  • ...determinar por si só se a função que está testando passou ou falhou, sem um humano interpretando os resultados.
  • ...executar isoladamente, separado de quaisquer outros casos de teste (mesmo que testem as mesmas funções). Cada caso de teste é uma ilha.

Dado isso, vamos construir um caso de teste para o primeiro requisito:

  1. A função to_roman() deve retornar a representação de numeral romano para todos os inteiros de 1 ate 3999.

Não é imediatamente óbvio como esse código funciona... bem, qualquer coisa. Ele define uma classe que não tem o método __init__(). A classe tem outro método, mas nunca é chamada. O script inteiro tem um bloco __main__, mas não faz referência à classe ou ao seu método. Mas faz alguma coisa, eu prometo.


import roman1
import unittest

class KnownValues(unittest.TestCase):               ①
    known_values = ( (1, 'I'),
                     (2, 'II'),
                     (3, 'III'),
                     (4, 'IV'),
                     (5, 'V'),
                     (6, 'VI'),
                     (7, 'VII'),
                     (8, 'VIII'),
                     (9, 'IX'),
                     (10, 'X'),
                     (50, 'L'),
                     (100, 'C'),
                     (500, 'D'),
                     (1000, 'M'),
                     (31, 'XXXI'),
                     (148, 'CXLVIII'),
                     (294, 'CCXCIV'),
                     (312, 'CCCXII'),
                     (421, 'CDXXI'),
                     (528, 'DXXVIII'),
                     (621, 'DCXXI'),
                     (782, 'DCCLXXXII'),
                     (870, 'DCCCLXX'),
                     (941, 'CMXLI'),
                     (1043, 'MXLIII'),
                     (1110, 'MCX'),
                     (1226, 'MCCXXVI'),
                     (1301, 'MCCCI'),
                     (1485, 'MCDLXXXV'),
                     (1509, 'MDIX'),
                     (1607, 'MDCVII'),
                     (1754, 'MDCCLIV'),
                     (1832, 'MDCCCXXXII'),
                     (1993, 'MCMXCIII'),
                     (2074, 'MMLXXIV'),
                     (2152, 'MMCLII'),
                     (2212, 'MMCCXII'),
                     (2343, 'MMCCCXLIII'),
                     (2499, 'MMCDXCIX'),
                     (2574, 'MMDLXXIV'),
                     (2646, 'MMDCXLVI'),
                     (2723, 'MMDCCXXIII'),
                     (2892, 'MMDCCCXCII'),
                     (2975, 'MMCMLXXV'),
                     (3051, 'MMMLI'),
                     (3185, 'MMMCLXXXV'),
                     (3250, 'MMMCCL'),
                     (3313, 'MMMCCCXIII'),
                     (3408, 'MMMCDVIII'),
                     (3501, 'MMMDI'),
                     (3610, 'MMMDCX'),
                     (3743, 'MMMDCCXLIII'),
                     (3844, 'MMMDCCCXLIV'),
                     (3888, 'MMMDCCCLXXXVIII'),
                     (3940, 'MMMCMXL'),
                     (3999, 'MMMCMXCIX'))           ②

    def test_to_roman_known_values(self):           ③
        '''to_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman1.to_roman(integer)       ④
            self.assertEqual(numeral, result)       ⑤

if __name__ == '__main__':
    unittest.main()
  
  • ① Para escrever um caso de teste, primeiro subclasse a classe TestCase do módulo unittest. Esta classe fornece muitos métodos úteis que você pode usar em seu caso de teste para testar condições específicas.
  • ② Esta é uma tupla de pares inteiros/numerais que verifiquei manualmente. Inclui os dez números mais baixos, o número mais alto, cada número que se traduz em um numeral romano de um caractere e uma amostragem aleatória de outros números válidos. Você não precisa testar todas as entradas possíveis, mas deve tentar testar todos os casos extremos óbvios.
  • ③ Cada teste individual é seu próprio método. Um método de teste não aceita parâmetros, não retorna nenhum valor e deve ter um nome começando com as quatro letras test. Se um método de teste sair normalmente sem gerar uma exceção, o teste será considerado aprovado; se o método gerar uma exceção, o teste será considerado com falha.
  • ④ Aqui você chama a função to_roman() real. (Bem, a função ainda não foi escrita, mas assim que for, esta é a linha que a chamará.) Observe que você definiu agora a API para a função to_roman(): ela deve receber um inteiro (o número a ser convertido) e retornar uma string (a representação em numeral romano). Se a API for diferente disso, este teste é considerado reprovado. Observe também que você não está interceptando nenhuma exceção ao chamar to_roman(). Isso é intencional. to_roman() não deve gerar uma exceção quando você o chama com entrada válida e esses valores de entrada são todos válidos. Se to_roman() gerar uma exceção, este teste é considerado reprovado.
  • ⑤ Assumindo que a função to_roman() foi definida corretamente, chamada corretamente, concluída com sucesso e retornou um valor, a última etapa é verificar se ela retornou o valor correto. Essa é uma pergunta comum, e a classe TestCase fornece um método, assertEqual, para verificar se dois valores são iguais. Se o resultado retornado de to_roman() (result) não corresponder ao valor conhecido que você esperava (numeral), assertEqual vai gerar uma exceção e o teste falhará. Se os dois valores forem iguais, assertEqual não fará nada. Se cada valor retornado de to_roman() corresponder ao valor conhecido que você espera, assertEqual nunca gera uma exceção, então test_to_roman_known_values eventualmente sai normalmente, o que significa que a função to_roman() passou neste teste.

Depois de ter um caso de teste, você pode começar a codificar a função to_roman(). Primeiro, você deve encerrá-lo como uma função vazia e certificar-se de que os testes falhem. Se os testes forem bem-sucedidos antes de você escrever qualquer código, seus testes não estão testando seu código! O teste de unidade é uma dança: os testes conduzem, o código segue. Escreva um teste que falhe, então codifique até que ele passe.


# roman1.py

def to_roman(n):
    '''convert integer to Roman numeral'''
    pass                                   ①
  
  • ① Neste estágio, você deseja definir a API da função to_roman(), mas ainda não deseja codificá-la. (Seu teste precisa falhar primeiro.) Para stub-lo, use a palavra reservada pass do Python, que não faz exatamente nada.

Execute romantest1.py na linha de comando para executar o teste. Se você chamá-lo com a opção -v de linha de comando, ele fornecerá uma saída mais detalhada para que você possa ver exatamente o que está acontecendo à medida que cada caso de teste é executado. Com alguma sorte, sua saída deve ficar assim:

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)                      ①
to_roman should give known result with known input ... FAIL            ②

======================================================================
FAIL: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest1.py", line 73, in test_to_roman_known_values
    self.assertEqual(numeral, result)
AssertionError: 'I' != None                                            ③

----------------------------------------------------------------------
Ran 1 test in 0.016s                                                   ④

FAILED (failures=1)                                                    ⑤
  • ① A execução do script executa unittest.main(), que executa cada caso de teste. Cada caso de teste é um método dentro de uma classe no romantest.py. Não há organização obrigatória dessas classes de teste; cada um deles pode conter um único método de teste ou você pode ter uma classe que contém vários métodos de teste. O único requisito é que cada classe de teste deve herdar de unittest.TestCase.
  • ② Para cada caso de teste, o módulo unittest imprimirá a docstring do método e se esse teste foi aprovado ou reprovado. Como esperado, este caso de teste falha.
  • ③ Para cada caso de teste com falha, unittest exibe as informações de rastreamento mostrando exatamente o que aconteceu. Nesse caso, a chamada para assertEqual() levantou um AssertionError porque esperava que to_roman(1) retornasse 'I', mas não retornou. (Como não havia uma instrução de retorno explícita, a função retornou None, o valor nulo do Python.)
  • ④ Após o detalhamento de cada teste, unittest exibe um resumo de quantos testes foram realizados e quanto tempo levou.
  • ⑤ No geral, a execução de teste falhou porque pelo menos um caso de teste não foi aprovado. Quando um caso de teste não passa, unittest distingue entre falhas e erros. Uma falha é uma chamada para um método assertXYZ, como assertEqual ou assertRaises, que falha porque a condição declarada não é verdadeira ou a exceção esperada não foi gerada. Um erro é qualquer outro tipo de exceção gerado no código que você está testando ou no próprio caso de teste de unidade.

Agora, finalmente, você pode escrever a função to_roman().


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))                 ①

def to_roman(n):
    '''convert integer to Roman numeral'''
    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:                     ②
            result += numeral
            n -= integer
    return result
  
  • roman_numeral_map é uma tupla de tuplas que define três coisas: as representações de caracteres dos numerais romanos mais básicos; a ordem dos algarismos romanos (em ordem decrescente de valor, de M ate todo o caminho até I); o valor de cada numeral romano. Cada tupla interna é um par de (numeral, value). Não são apenas algarismos romanos de um caractere; também define pares de dois caracteres como CM (“cem menos que mil”). Isso torna o código da função to_roman() mais simples.
  • ② Aqui é onde a rica estrutura de dados de roman_numeral_map compensa, porque você não precisa de nenhuma lógica especial para lidar com a regra de subtração. Para converter para algarismos romanos, basta iterar roman_numeral_map procurando o maior valor inteiro menor ou igual à entrada. Uma vez encontrado, adicione a representação em numeral romano ao final da saída, subtraia o valor inteiro correspondente da entrada, ensaboe, enxágue, repita.

Se você ainda não entendeu como a função to_roman() funciona, adicione uma chamada print() ao final do loop while:


while n >= integer:
    result += numeral
    n -= integer
    print('subtracting {0} from input, adding {1} to output'.format(integer, numeral))
  

Com as instruções de depuração print(), a saída se parece com isso:


>>> import roman1
>>> roman1.to_roman(1424)
subtracting 1000 from input, adding M to output
subtracting 400 from input, adding CD to output
subtracting 10 from input, adding X to output
subtracting 10 from input, adding X to output
subtracting 4 from input, adding IV to output
'MCDXXIV'
  

Portanto, a função to_roman() parece funcionar, pelo menos nesta verificação manual. Mas ele passará no caso de teste que você escreveu?

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok               ①

----------------------------------------------------------------------
Ran 1 test in 0.016s

OK
  
  • ① Viva! A função to_roman() passa no caso de teste de “valores conhecidos”. Não é abrangente, mas coloca a função em prática com uma variedade de entradas, incluindo entradas que produzem todos os algarismos romanos de um único caractere, a maior entrada possível (3999) e a entrada que produz o maior número romano possível (3888). Neste ponto, você pode estar razoavelmente confiante de que a função funciona para qualquer bom valor de entrada que você possa lançar nela.

Entrada “boa”? Hum. E a entrada ruim?

“Pare e pegue fogo”

Não é suficiente testar se as funções são bem-sucedidas quando recebem uma boa entrada; você também deve testar se eles falham quando recebem uma entrada incorreta. E não qualquer tipo de fracasso; eles devem falhar da maneira que você espera.


>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000)  ①
'MMMMMMMMM'
  
  • ① Isso definitivamente não é o que você queria - isso nem é um numeral romano válido! Na verdade, cada um desses números está fora do intervalo de entrada aceitável, mas a função retorna um valor falso de qualquer maneira. Retornar valores ruins silenciosamente é ruim; se um programa vai falhar, é muito melhor se ele falhar rápida e ruidosamente. “Pare e pegue fogo”, como diz o ditado. A maneira Pythonic de parar e pegar fogo é gerar uma exceção.

A pergunta a se fazer é: “Como posso expressar isso como um requisito testável?” Como é isso para iniciantes:

A função to_roman() deve gerar um OutOfRangeError quando dado um número inteiro maior que 3999.

Como seria esse teste?


import unittest, roman2
class ToRomanBadInput(unittest.TestCase):                                 ①
    def test_too_large(self):                                             ②
        '''to_roman should fail with large input'''
        self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)  ③
  
  • ① Como no caso de teste anterior, você cria uma classe que herda de unittest.TestCase. Você pode ter mais de um teste por classe (como você verá mais adiante neste capítulo), mas optei por criar uma nova classe aqui porque este teste é algo diferente do anterior. Manteremos todos os bons testes de entrada juntos em uma classe e todos os testes de entrada ruins juntos em outra.
  • ② Como no caso de teste anterior, o teste em si é um método da classe, com um nome começando com test.
  • ③ A classe unittest.TestCase fornece o método assertRaises, que recebe os seguintes argumentos: a exceção que você espera, a função que você está testando e os argumentos que você está passando para essa função. (Se a função que você está testando receber mais de um argumento, passe-os todos para assertRaises, em ordem, e ele os passará diretamente para a função que você está testando.)

Preste muita atenção a esta última linha de código. Em vez de chamar to_roman() direta e manualmente verificando se ele gera uma exceção específica (envolvendo-a em um bloco try...except), o método assertRaises encapsula tudo isso para nós. Tudo o que você faz é dizer qual exceção você está esperando (roman2.OutOfRangeError), a função (to_roman()) e os argumentos da função (4000). O método assertRaises se encarrega de chamar to_roman() e verificar se ele levanta roman2.OutOfRangeError.

Observe também que você está passando a própria função to_roman() como um argumento; você não está chamando e não está passando o nome dela como uma string. Mencionei recentemente como é útil que tudo em Python seja um objeto?

Então, o que acontece quando você executa o conjunto de testes com esse novo teste?

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ERROR                         ①

======================================================================
ERROR: to_roman should fail with large input                          
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AttributeError: 'module' object has no attribute 'OutOfRangeError'      ②

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
  
  • ① Você deveria ter esperado que isso falhasse (já que você ainda não escreveu nenhum código para passá-lo), mas... na verdade não “falhou”, teve um “erro”. Esta é uma distinção sutil, mas importante. Na verdade, um teste de unidade tem três valores de retorno: aprovado, reprovado e erro. Aprovar, é claro, significa que o teste foi aprovado – o código fez o que você esperava. “Fail” é o que o caso de teste anterior fez (até que você escreveu o código para fazê-lo passar) – ele executou o código, mas o resultado não foi o que você esperava. “Erro” significa que o código nem foi executado corretamente.
  • ② Por que o código não foi executado corretamente? O traceback diz tudo. O módulo que você está testando não tem uma exceção chamada OutOfRangeError. Lembre-se, você passou essa exceção para o método assertRaises(), porque é a exceção que você deseja que a função levante com uma entrada fora do intervalo. Mas a exceção não existe, então a chamada para o método assertRaises() falhou. Ele nunca teve a chance de testar a função to_roman(); não foi tão longe.

Para resolver esse problema, você precisa definir a exceção OutOfRangeError no roman2.py.


class OutOfRangeError(ValueError):  ①
    pass                            ②
  
  • ① As exceções são as classes. Um erro “fora do intervalo” é um tipo de erro de valor — o valor do argumento está fora de seu intervalo aceitável. Portanto, essa exceção herda da exceção interna ValueError. Isso não é estritamente necessário (pode apenas herdar da classe base Exception), mas parece certo.
  • ② As exceções não fazem nada, mas você precisa de pelo menos uma linha de código para criar uma classe. A chamada pass não faz exatamente nada, mas é uma linha de código Python, então isso a torna uma classe.

Agora execute o conjunto de testes novamente.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... FAIL                          ①

======================================================================
FAIL: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AssertionError: OutOfRangeError not raised by to_roman                 ②

----------------------------------------------------------------------
Ran 2 tests in 0.016s

FAILED (failures=1)
  
  • ① O novo teste ainda não está passando, mas também não está retornando um erro. Em vez disso, o teste está falhando. Isso é progresso! Isso significa que a chamada para o método assertRaises() foi bem-sucedida desta vez e a estrutura de teste de unidade realmente testou a função to_roman().
  • ② Claro, a função to_roman() não está levantando a exceção OutOfRangeError que você acabou de definir, porque você ainda não disse para fazer isso. Isso é uma excelente notícia! Isso significa que este é um caso de teste válido - ele falha antes de você escrever o código para fazê-lo passar.

Agora você pode escrever o código para fazer este teste passar.


def to_roman(n):
    '''convert integer to Roman numeral'''
    if n > 3999:
        raise OutOfRangeError('number out of range (must be less than 4000)')  ①

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  
  • ① Isso é direto: se a entrada fornecida (n) for maior que 3999, lance uma exceção OutOfRangeError. O teste de unidade não verifica a string legível que acompanha a exceção, embora você possa escrever outro teste que a verifique (mas fique atento a problemas de internacionalização para strings que variam de acordo com o idioma ou ambiente do usuário).

Isso faz o teste passar? Vamos descobrir.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok                            ①

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
  
  • ① Viva! Ambos os testes passam. Como você trabalhou iterativamente, alternando entre o teste e a codificação, você pode ter certeza de que as duas linhas de código que você acabou de escrever foram a causa daquele teste que passou de “falha” para “aprovado”. Esse tipo de confiança não sai barato, mas se pagará durante a vida útil do seu código.

Mais Parada, Mais Fogo

Além de testar números muito grandes, você precisa testar números muito pequenos. Como observamos em nossos requisitos funcionais, os algarismos romanos não podem expressar 0 ou números negativos.


>>> import roman2
>>> roman2.to_roman(0)
''
>>> roman2.to_roman(-1)
''
  

Bem, isso não é bom. Vamos adicionar testes para cada uma dessas condições.


class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 4000)  ①

    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)     ②

    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)    ③
  
  • ① O método test_too_large() não mudou desde a etapa anterior. Estou incluindo aqui para mostrar onde o novo código se encaixa.
  • ② Aqui está um novo teste: o método test_zero(). Assim como o método test_too_large(), ele diz ao método assertRaises() definido em unittest.TestCase para chamar nossa função to_roman() com um parâmetro de 0 e verificar se ele gera a exceção apropriada, OutOfRangeError.
  • ③ O método test_negative() é quase idêntico, exceto que passa -1 para a função to_roman(). Se um desses novos testes não gerar um OutOfRangeError (seja porque a função retorna um valor real ou porque gera alguma outra exceção), o teste é considerado com falha.

Agora verifique se os testes falham:

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... FAIL

======================================================================
FAIL: to_roman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 86, in test_negative
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman

======================================================================
FAIL: to_roman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 82, in test_zero
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)
AssertionError: OutOfRangeError not raised by to_roman

----------------------------------------------------------------------
Ran 4 tests in 0.000s

FAILED (failures=2)
  

Excelente. Ambos os testes falharam, como esperado. Agora vamos passar para o código e ver o que podemos fazer para que eles passem.


def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):                                              ①
        raise OutOfRangeError('number out of range (must be 1..3999)')  ②

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  
  • ① Este é um bom atalho Pythonic: várias comparações ao mesmo tempo. Isso é equivalente a if not ((0 < n) and (n < 4000)), mas é muito mais fácil de ler. Essa linha de código deve capturar entradas muito grandes, negativas ou zero.
  • ② Se você alterar suas condições, certifique-se de atualizar suas strings de erro legíveis para que correspondam. A estrutura unittest não se importará, mas dificultará a depuração manual se seu código estiver lançando exceções descritas incorretamente.

Eu poderia mostrar a você uma série inteira de exemplos não relacionados para mostrar que o atalho de várias comparações de uma vez funciona, mas em vez disso, vou apenas executar os testes de unidade e provar isso.

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.016s

OK
  

E mais uma coisa…

Havia mais um requisito funcional para converter números em algarismos romanos: lidar com números não inteiros.


>>> import roman3
>>> roman3.to_roman(0.5)  ①
''
>>> roman3.to_roman(1.0)  ②
'I'
  
  • ① Oh, isso é ruim.
  • ② Ah, isso é ainda pior. Ambos os casos devem levantar uma exceção. Em vez disso, eles dão resultados falsos.

O teste para não inteiros não é difícil. Primeiro, defina uma exceção NotIntegerError.


# roman4.py
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
  

Em seguida, escreva um caso de teste que verifique a exceção NotIntegerError.


class ToRomanBadInput(unittest.TestCase):
    .
    .
    .
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
  

Agora verifique se o teste falha corretamente.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

======================================================================
FAIL: to_roman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest4.py", line 90, in test_non_integer
    self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
AssertionError: NotIntegerError not raised by to_roman

----------------------------------------------------------------------
Ran 5 tests in 0.000s

FAILED (failures=1)
  

Escreva o código que faz o teste passar.


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

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  
  • ① A função isinstance() interna testa se uma variável é um tipo específico (ou, tecnicamente, qualquer tipo descendente).
  • ② Se o argumento n não for um int, levante nossa exceção NotIntegerError recém-criada.

Finalmente, verifique se o código realmente faz o teste passar.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK
  

A função to_roman() passa em todos os seus testes e não consigo pensar em mais nenhum teste, então é hora de passar para a função from_roman().

Uma Simetria Agradável

Converter uma string de um numeral romano para um inteiro parece mais difícil do que converter um inteiro para um numeral romano. Certamente há a questão da validação. É fácil verificar se um inteiro é maior que 0, mas um pouco mais difícil verificar se uma string é um numeral romano válido. Mas já construímos uma expressão regular para verificar numerais romanos, então essa parte está feita.

Isso deixa o problema de converter a própria string. Como veremos em um minuto, graças à rica estrutura de dados que definimos para mapear numerais romanos individuais para valores inteiros, o âmago da função from_roman() é tão direto quanto a função to_roman().

Mas primeiro, os testes. Precisaremos de um teste de “valores conhecidos” para verificar a precisão. Nosso conjunto de testes já contém um mapeamento de valores conhecidos; vamos reutilizar isso.


    def test_from_roman_known_values(self):
        '''from_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)
  

Há uma simetria agradável aqui. As funções to_roman() e from_roman() são inversas uma da outra. O primeiro converte inteiros em strings especialmente formatados, o segundo converte strings especialmente formatados em inteiros. Em teoria, deveríamos ser capazes de “retornar” um número passando para a função to_roman() para obter uma string, depois passando essa string para a função from_roman() para obter um número inteiro e terminar com o mesmo número.


n = from_roman(to_roman(n)) for all values of n

Nesse caso, “todos os valores” significa qualquer número entre 1..3999, pois esse é o intervalo válido de entradas para a função to_roman(). Podemos expressar essa simetria em um caso de teste que percorre todos os valores 1..3999, fazendo chamadas para to_roman(), from_roman() e verifica se a saída é igual à entrada original.


class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 4000):
            numeral = roman5.to_roman(integer)
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)
  

Esses novos testes ainda nem falharão. Nós ainda não definimos uma função from_roman(), então eles apenas irão gerar erros.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
E.E....
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 78, in test_from_roman_known_values
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 103, in test_roundtrip
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

----------------------------------------------------------------------
Ran 7 tests in 0.019s

FAILED (errors=2)

Uma função de stub rápida resolverá esse problema.


# roman5.py
def from_roman(s):
    '''convert Roman numeral to integer'''
  

(Ei, você notou isso? Eu defini uma função com nada além de uma docstring. Isso é Python legal. Na verdade, alguns programadores juram por isso. “Não stub; document!”)

Agora, os casos de teste realmente falharão.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
F.F....
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 79, in test_from_roman_known_values
    self.assertEqual(integer, result)
AssertionError: 1 != None

======================================================================
FAIL: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 104, in test_roundtrip
    self.assertEqual(integer, result)
AssertionError: 1 != None

----------------------------------------------------------------------
Ran 7 tests in 0.002s

FAILED (failures=2)
  

Agora é hora de escrever a função from_roman().


def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:  ①
            result += integer
            index += len(numeral)
    return result
  
  • ① O padrão aqui é o mesmo que a função to_roman(). Você itera através de sua estrutura de dados de numeral romano (uma tupla de tuplas), mas em vez de corresponder os valores inteiros mais altos com a maior frequência possível, você corresponde às cadeias de caracteres de numeral romano “mais alto” com a maior frequência possível.

Se você não está claro como funciona from_roman(), adicione uma instrução print ao final do loop while:


def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print('found', numeral, 'of length', len(numeral), ', adding', integer)
  

>>> import roman5
>>> roman5.from_roman('MCMLXXII')
found M of length 1, adding 1000
found CM of length 2, adding 900
found L of length 1, adding 50
found X of length 1, adding 10
found X of length 1, adding 10
found I of length 1, adding 1
found I of length 1, adding 1
1972
  

Hora de refazer os testes.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
.......
----------------------------------------------------------------------
Ran 7 tests in 0.060s

OK
  

Duas notícias interessantes aqui. A primeira é que a função from_roman() funciona para uma boa entrada, pelo menos para todos os valores conhecidos. A segunda é que o teste de “ida e volta” também passou. Combinado com os testes de valores conhecidos, você pode estar razoavelmente certo de que as funções to_roman() e from_roman() funcionam corretamente para todos os valores válidos possíveis. (Isso não é garantido; é teoricamente possível que to_roman() tenha um bug que produza o numeral romano errado para algum conjunto específico de entradas, e que from_roman() tenha um bug recíproco que produza os mesmos valores inteiros errados para exatamente esse conjunto de algarismos romanos que to_roman() tenha gerado incorretamente. Dependendo da sua aplicação e dos seus requisitos, essa possibilidade pode incomodá-lo; em caso afirmativo, escreva casos de teste mais abrangentes até que isso não o incomode.)

Mais entradas incorretas

Agora que a função from_roman() funciona corretamente com uma boa entrada, é hora de encaixar a última peça do quebra-cabeça: fazê-la funcionar corretamente com uma entrada ruim. Isso significa encontrar uma maneira de olhar para uma string e determinar se é um numeral romano válido. Isso é inerentemente mais difícil do que validar a entrada numérica na função to_roman(), mas você tem uma ferramenta poderosa à sua disposição: expressões regulares. (Se você não estiver familiarizado com expressões regulares, agora seria um bom momento para ler o capítulo sobre expressões regulares em python.)

Como você viu em Estudo de caso: algarismos romanos, existem várias regras simples para construir um numeral romano, usando as letras M, D, C, L, X, V e I. Vamos rever as regras:

  • Às vezes, os caracteres são aditivos. I é 1, II é 2 e III é 3. VI é 6 (literalmente, “5 e 1”), VII é 7, e VIII é 8.
  • Os caracteres de dezenas (I, X, C e M) podem ser repetidos até três vezes. Em 4, você precisa subtrair do próximo caractere cinco mais alto. Você não pode representar 4 como IIII; em vez disso, é representado como IV (“1 menor que 5”). 40 é escrito como XL (“10 menor que 50”), 41 como XLI, 42 como XLII, 43 como XLIII e depois 44 como XLIV (“10 menor que 50, então 1 menor que 5”).
  • Às vezes os caracteres são... o oposto de aditivos. Ao colocar certos caracteres antes de outros, você subtrai do valor final. Por exemplo, em 9, você precisa subtrair do próximo caractere de dezena mais alto: 8 é VIII, mas 9 é IX (“1 menor que 10”), não VIIII(já que o caractere I não pode ser repetido quatro vezes). 90 é XC, 900 é CM.
  • Os cinco caracteres não podem ser repetidos. 10 é sempre representado como X, nunca como VV. 100 é sempre C, nunca LL.
  • Os algarismos romanos são lidos da esquerda para a direita, então a ordem dos caracteres importa muito. DC é 600; CD é um número completamente diferente (400, “100 menor que 500”). CI é 101; IC nem é um numeral romano válido (porque você não pode subtrair 1 diretamente de 100; você precisaria escrevê-lo como XCIX, “10 menor que 100, então 1 menor que 10”).

Assim, um teste útil seria garantir que a função from_roman() falhe quando você passar uma string com muitos numerais repetidos. Quanto é "muitos" depende do numeral.


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

Outro teste útil seria verificar se certos padrões não se repetem. Por exemplo, IX é 9, mas IXIX nunca é válido.


    def test_repeated_pairs(self):
        '''from_roman should fail with repeated pairs of numerals'''
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
  

Um terceiro teste poderia verificar se os numerais aparecem na ordem correta, do maior para o menor valor. Por exemplo, CL é 150, mas LC nunca é válido, porque o numeral para 50 nunca pode vir antes do numeral para 100. Esse teste inclui um conjunto de antecedentes inválidos escolhidos aleatoriamente: I antes de M, V antes de X e assim por diante.


    def test_malformed_antecedents(self):
        '''from_roman should fail with malformed antecedents'''
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
  

Cada um desses testes depende da função from_roman() que gera uma nova exceção, InvalidRomanNumeralError, que ainda não definimos.


# roman6.py
class InvalidRomanNumeralError(ValueError): pass
  

Todos esses três testes devem falhar, pois a função from_roman() atualmente não possui nenhuma verificação de validade. (Se eles não falharem agora, então o que diabos eles estão testando?)

you@localhost:~/diveintopython3/examples$ python3 romantest6.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 113, in test_malformed_antecedents
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_repeated_pairs (__main__.FromRomanBadInput)
from_roman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 107, in test_repeated_pairs
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_too_many_repeated_numerals (__main__.FromRomanBadInput)
from_roman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 102, in test_too_many_repeated_numerals
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 10 tests in 0.058s

FAILED (failures=3)
  

Bom negócio. Agora, tudo o que precisamos fazer é adicionar a expressão regular para testar numerais romanos válidos na função from_roman().


roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,3}              # thousands - 0 to 3 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 from_roman(s):
    '''convert Roman numeral to integer'''
    if not roman_numeral_pattern.search(s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))

    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index : index + len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
  

E refaça os testes...

you@localhost:~/diveintopython3/examples$ python3 romantest7.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.066s

OK
  

E o prêmio anticlímax do ano vai para… a palavra “OK”, que é impressa pelo módulo unittest quando todas os testes passam.

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

0 comentários:

Postar um comentário