❝ 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:
- Existe apenas uma maneira correta de representar um número específico como um numeral romano.
- 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).
- Há um intervalo limitado de números que podem ser expressos como algarismos romanos, especificamente de
1
a3999
. 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 por1000
. Para os propósitos deste capítulo, vamos estipular que os algarismos romanos vão de1
até3999
. - Não há como representar 0 em algarismos romanos.
- Não há como representar números negativos em algarismos romanos.
- 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:
- A função
to_roman()
deve retornar a representação de numeral romano para todos os inteiros de1
ate3999
.
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ódulounittest
. 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çãoto_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 chamarto_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. Seto_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 classeTestCase
fornece um método,assertEqual
, para verificar se dois valores são iguais. Se o resultado retornado deto_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 deto_roman()
corresponder ao valor conhecido que você espera,assertEqual
nunca gera uma exceção, entãotest_to_roman_known_values
eventualmente sai normalmente, o que significa que a funçãoto_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 reservadapass
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 noromantest.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 deunittest.TestCase
. - ② Para cada caso de teste, o módulo
unittest
imprimirá adocstring
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 paraassertEqual()
levantou umAssertionError
porque esperava queto_roman(1)
retornasse'I'
, mas não retornou. (Como não havia uma instrução de retorno explícita, a função retornouNone
, 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étodoassertXYZ
, comoassertEqual
ouassertRaises
, 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, deM
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 comoCM
(“cem menos que mil”). Isso torna o código da funçãoto_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 iterarroman_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 umOutOfRangeError
quando dado um número inteiro maior que3999
.
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étodoassertRaises
, 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 paraassertRaises
, 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étodoassertRaises()
, 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étodoassertRaises()
falhou. Ele nunca teve a chance de testar a funçãoto_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 baseException
), 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çãoto_roman()
. - ② Claro, a função
to_roman()
não está levantando a exceçãoOutOfRangeError
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 que3999
, lance uma exceçãoOutOfRangeError
. 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étodotest_too_large()
, ele diz ao métodoassertRaises()
definido emunittest.TestCase
para chamar nossa funçãoto_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çãoto_roman()
. Se um desses novos testes não gerar umOutOfRangeError
(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 umint
, levante nossa exceçãoNotIntegerError
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
eIII
é3
.VI
é6
(literalmente, “5
e1
”),VII
é7
, eVIII
é8
. - Os caracteres de dezenas (
I
,X
,C
eM
) podem ser repetidos até três vezes. Em4
, você precisa subtrair do próximo caractere cinco mais alto. Você não pode representar4
comoIIII
; em vez disso, é representado comoIV
(“1
menor que5
”).40
é escrito comoXL
(“10
menor que50
”),41
comoXLI
,42
comoXLII
,43
comoXLIII
e depois44
comoXLIV
(“10
menor que50
, então1
menor que5
”). - À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
, mas9
éIX
(“1
menor que10
”), nãoVIIII
(já que o caractereI
não pode ser repetido quatro vezes).90
éXC
,900
éCM
. - Os cinco caracteres não podem ser repetidos.
10
é sempre representado comoX
, nunca comoVV
.100
é sempreC
, nuncaLL
. - 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 que500
”).CI
é101
;IC
nem é um numeral romano válido (porque você não pode subtrair1
diretamente de100
; você precisaria escrevê-lo comoXCIX
, “10
menor que100
, então1
menor que10
”).
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