May 7th, 2006TDD em Combate – Parte 2
É hora de codificar um pouco: mostraremos o passo-a-passo de como programar no ritmo de TDD, desenvolvendo as regras de ataque do jogo Combate. Vamos identificar também a preocupação constante com o Design Simples, e como ele se manifesta quando desenvolvemos os testes antes do código.
Pensando no Design
No último post, conhecemos as peças que compõem o jogo e as respectivas regras de ataque/defesa. Como a idéia de hoje é implementarmos essas regras de ataque, precisamos pensar um pouco sobre como modelar nossas classes (lembrem-se, é permitido pensar sobre o design antes de começar a implementação. O que XP tenta evitar é o chamado BDUF – Big Design Up-Front – onde o design do sistema inteiro é definido e escrito num documento, com diversas páginas e diagramas de classe que será lido por outra pessoa na hora da implementação). O Design Incremental de XP faz com que você se preocupe somente com a mÃnima quantidade de design necessária para atender suas necessidades atuais. No nosso caso, precisamos apenas pensar em onde implementar as regras de ataque.
Um ataque pode ter 3 desfechos diferentes: a peça que atacou vence e ocupa o lugar da peça de defesa (que é removida do tabuleiro); a peça que atacou perde e é removida do tabuleiro ou ambas as peças são removidas do tabuleiro no caso de um empate. Minha primeira idéia era definir um método de ataque que retornasse a peça perdedora, porém no caso do empate, eu precisaria retornar as duas peças, exigindo uma assinatura que devolvesse uma lista de peças ou um par de peças, o que não achei uma boa idéia. Tive então outra idéia para implementação: fazer com que o método de ataque avisasse cada peça perdedora, numa espécie de método callback sem retorno. Porém, como um objeto Peça faria para se remover do tabuleiro ao descobrir que perdeu? Ele precisaria conhecer o Tabuleiro ou avisar o Tabuleiro, criando dependência bi-direcional entre as classes, o que também não parece ser uma boa idéia (nem estamos pensando no design do Tabuleiro ainda, só queremos implementar as regras de ataque).
A solução sugerida, depois de algumas discussões com o RBP, foi implementar um método de comparação (no estilo de um cmp() em Python ou compareTo() em Java) que devolve 0 se houve empate, um número positivo se a peça que está atacando venceu ou um número negativo se a peça atacante perdeu. Só faltava escolher onde esse método seria definido. Minha primeira idéia foi colocá-lo numa classe Stratego que representa o jogo, porém depois de alguns minutos de codificação, percebi que o melhor lugar para definir esse método seria na própria peça. Uma peça sabe como se portar quando ataca um inimigo. A última decisão de design foi definir um rank numérico para cada peça, para representar a hierarquia utilizada durante os ataques. Com a tabela seguinte em mãos, pudemos recomeçar a implementação, no ritmo de TDD:
Rank |  | Peça |  | Rank |  | Peça |  | Rank |  | Peça |
---|---|---|---|---|---|---|---|---|---|---|
0 | Â | Bandeira | Â | 4 | Â | Sargento | Â | 8 | Â | Coronel |
1 |  | Espião |  | 5 |  | Tenente |  | 9 |  | General |
2 |  | Soldado |  | 6 |  | Capitão |  | 10 |  | Marechal |
3 | Â | Cabo-Armeiro | Â | 7 | Â | Major | Â | 11 | Â | Bomba |
Criando as peças
O primeiro passo é sempre escrever um teste que falha. Como queremos criar as peças acima, defini um conjunto de testes que verifica se as peças estão sendo criadas com o rank esperado, num arquivo chamado StrategoTest.py (que armazenará os testes):
# StrategoTest.py import unittest import unittestgui from Stratego import * class CreatePieceTest(unittest.TestCase): def testCreateSoldier(self): assert Piece("soldier").rank == 2 suite = unittest.makeSuite(CreatePieceTest) if __name__ == "__main__": unittestgui.main("StrategoTest.suite") |
Esse código não compila e não consegue ser executado. Percebam como o teste pede pela codificação: definindo um construtor para peça que recebe uma string, definindo uma propriedade rank para a peça e definindo o módulo onde a classe será definida. Com isso, precisamos do seguinte trecho de código para termos nossa primeira barra verde (definido no arquivo Stratego.py):
# Stratego.py class Piece: def __init__(self, name): self.rank = 2 |
Essa é a implementação mais simples que faz o nosso teste passar. Percebam como o TDD nos auxilia a evitar generalizações precoces no código. Mesmo sabendo que esse código não atende a todas as necessidades, precisamos nos disciplinar e focar no mÃnimo necessário. O terceiro passo seria a refatoração, mas ainda não temos nenhum “mal-cheiro” no código, então podemos partir para o próximo teste:
# StrategoTest.py class CreatePieceTest(unittest.TestCase): def testCreateSoldier(self): assert Piece("soldier").rank == 2 def testCreateMiner(self): assert Piece("miner").rank == 3 |
Ao executar a suite de testes, nos deparamos mais uma vez com uma barra vermelha. É hora de implementarmos um pouco mais para torná-la verde:
# Stratego.py class Piece: def __init__(self, name): if name == "soldier": self.rank = 2 else: self.rank = 3 |
Alguns podem dizer que o código acima já está começando a cheirar mal, porém ainda não vejo tanta repetição, então resolvo escrever o próximo teste que falha:
# StrategoTest.py class CreatePieceTest(unittest.TestCase): def testCreateSoldier(self): assert Piece("soldier").rank == 2 def testCreateMiner(self): assert Piece("miner").rank == 3 def testCreateSergeant(self): assert Piece("sergeant").rank == 4 |
Para fazer com que os testes fiquem verdes novamente, preciso codificar mais um pouco:
# Stratego.py class Piece: def __init__(self, name): if name == "soldier": self.rank = 2 elif name == "miner": self.rank = 3 else: self.rank = 4 |
Agora estou numa barra verde, porém o código não parece muito bonito. Fica evidente que a cada novo teste, uma nova ramificação elif precisará ser adicionada ao código do construtor. É hora de refatorar (a regra é: “sempre refatore no verde”), deixar o código mais limpo e simples sem alterar seu comportamento, ou seja, sem quebrar nenhum dos testes existentes:
# Stratego.py class Piece: __pieces = {"soldier":2, "miner":3, "sergeant":4} def __init__(self, name): self.rank = self.__pieces[name] |
Definindo um dicionário com as possÃveis peças, o código do construtor fica mais simples, pois se transforma numa simples consulta ao dicionário. Seguindo o mesmo ritmo para construir as outras peças e definindo uma exceção para a tentativa de criação de uma peça inválida, o Desenvolvimento Orientado a Testes nos leva ao seguinte código final:
# StrategoTest.py import unittest import unittestgui from Stratego import * class CreatePieceTest(unittest.TestCase): def testCreateSoldier(self): assert Piece("soldier").rank == 2 def testCreateMiner(self): assert Piece("miner").rank == 3 def testCreateSergeant(self): assert Piece("sergeant").rank == 4 def testCreateLieutenant(self): assert Piece("lieutenant").rank == 5 def testCreateCaptain(self): assert Piece("captain").rank == 6 def testCreateMajor(self): assert Piece("major").rank == 7 def testCreateColonel(self): assert Piece("colonel").rank == 8 def testCreateGeneral(self): assert Piece("general").rank == 9 def testCreateMarshal(self): assert Piece("marshal").rank == 10 def testCreateSpy(self): assert Piece("spy").rank == 1 def testCreateBomb(self): assert Piece("bomb").rank == 11 def testCreateFlag(self): assert Piece("flag").rank == 0 def testCreateInvalidPiece(self): try: Piece("invalid") except InvalidPiece: pass else: fail("expected InvalidPiece exception") suite = unittest.makeSuite(CreatePieceTest) if __name__ == "__main__": unittestgui.main("StrategoTest.suite") # Stratego.py class Piece: __pieces = {"flag":0, "spy":1, "soldier":2, "miner":3, "sergeant":4, "lieutenant":5, "captain":6, "major":7, "colonel":8, "general":9, "marshal":10, "bomb":11} def __init__(self, name): try: self.rank = self.__pieces[name] except KeyError: raise InvalidPiece() class InvalidPiece(Exception): pass |
Implementando o método de ataque
Agora que temos as peças criadas, podemos implementar as regras de ataque. Apenas para relembrar, as peças com maior rank vencem as de menor rank. Algumas exceções à regra são:
Â
- O espião vence o marechal quando o ataca
- As bombas e a bandeira não podem atacar
- Qualquer peça que atacar a bomba perde, exceto o cabo-armeiro
Para não perder o costume, começamos com um teste que falha:
# StrategoTest.py class AttackTest(unittest.TestCase): def testHigherRankWins(self): assert Piece("sergeant").attack(Piece("soldier")) > 0 |
Rapidamente, fazemos o teste passar:
# Stratego.py class Piece: # (...) Constructor def attack(self, defender): return 1 # (...) Exceptions |
Novamente, apesar desse código não ser o que desejamos, é o suficiente para fazer o teste passar e para permitir que possamos implementar o próximo teste:
# StrategoTest.py class AttackTest(unittest.TestCase): def testHigherRankWins(self): assert Piece("sergeant").attack(Piece("soldier")) > 0 def testLowerRankLoses(self): assert Piece("miner").attack(Piece("colonel")) < 0 |
Mais uma vez, implementamos o mais simples para fazer o teste passar:
# Stratego.py class Piece: # (...) Constructor def attack(self, defender): return self.rank - defender.rank # (...) Exceptions |
O teste para verificar uma situação de empate já funciona sem nenhuma mudança na implementação, assim como o teste para confirmar que o marechal vence o espião quando o ataca. Isso pode acontecer às vezes, permitindo que passemos para o próximo teste que deixará a barra de testes vermelha. Isso acontece com o teste que verifica se o espião vence um ataque contra o marechal:
# StrategoTest.py class AttackTest(unittest.TestCase): def testHigherRankWins(self): assert Piece("sergeant").attack(Piece("soldier")) > 0 def testLowerRankLoses(self): assert Piece("miner").attack(Piece("colonel")) < 0 def testTie(self): assert Piece("major").attack(Piece("major")) == 0 def testMarshalWinsSpy(self): assert Piece("marshal").attack(Piece("spy")) > 0 def testSpyWinsMarshal(self): assert Piece("spy").attack(Piece("marshal")) > 0 |
Uma simples validação do caso excepcional, faz com que os testes funcionem novamente:
# Stratego.py class Piece: # (...) Constructor def attack(self, defender): if self.rank == 1 and defender.rank == 10: return 1 else: return self.rank - defender.rank # (...) Exceptions |
O próximo caso excepcional acontece quando o cabo-armeiro ataca uma bomba:
# StrategoTest.py class AttackTest(unittest.TestCase): def testHigherRankWins(self): assert Piece("sergeant").attack(Piece("soldier")) > 0 def testLowerRankLoses(self): assert Piece("miner").attack(Piece("colonel")) < 0 def testTie(self): assert Piece("major").attack(Piece("major")) == 0 def testSpyWinsMarshal(self): assert Piece("spy").attack(Piece("marshal")) > 0 def testMarshalWinsSpy(self): assert Piece("marshal").attack(Piece("spy")) > 0 def testMinerWinsBomb(self): assert Piece("miner").attack(Piece("bomb")) > 0 |
O necessário para fazê-lo passar é adicionar mais uma validação para o novo caso excepcional:
# Stratego.py class Piece: # (...) Constructor def attack(self, defender): if (self.rank == 1 and defender.rank == 10) or (self.rank == 3 and defender.rank == 11): return 1 else: return self.rank - defender.rank # (...) Exceptions |
Com a barra verde, podemos refatorar o código. Nesse caso, podemos eliminar a duplicação na verificação dos casos excepcionais criando uma lista dos pares que vencem quando atacam (apesar de possuÃrem um rank menor). O código refatorado fica assim:
# Stratego.py class Piece: __pieces = {"flag":0, "spy":1, "soldier":2, "miner":3, "sergeant":4, "lieutenant":5, "captain":6, "major":7, "colonel":8, "general":9, "marshal":10, "bomb":11} __winsAttacking = [(1,10), (3,11)] # (...) Constructor def attack(self, defender): if (self.rank,defender.rank) in self.__winsAttacking: return 1 else: return self.rank - defender.rank # (...) Exceptions |
Para terminarmos nossa implementação, só falta lidar os casos de ataque inválido. Primeiro vamos escrever um teste para garantir que uma bomba não pode ter iniciativa de ataque:
# StrategoTest.py class AttackTest(unittest.TestCase): def testHigherRankWins(self): assert Piece("sergeant").attack(Piece("soldier")) > 0 def testLowerRankLoses(self): assert Piece("miner").attack(Piece("colonel")) < 0 def testTie(self): assert Piece("major").attack(Piece("major")) == 0 def testSpyWinsMarshal(self): assert Piece("spy").attack(Piece("marshal")) > 0 def testMarshalWinsSpy(self): assert Piece("marshal").attack(Piece("spy")) > 0 def testMinerWinsBomb(self): assert Piece("miner").attack(Piece("bomb")) > 0 def testBombCannotAtack(self): try: Piece("bomb").attack(Piece("miner")) except InvalidAttack: pass else: fail("expected InvalidAttack exception") |
E o código para fazer o teste passar é uma nova validação no método attack():
# Stratego.py class Piece: # (...) Constructor def attack(self, defender): if self.rank == 11: raise InvalidAttack() if (self.rank,defender.rank) in self.__winsAttacking: return 1 else: return self.rank - defender.rank # (...) Exceptions class InvalidAttack(Exception): pass |
O último teste é para evitar que uma bandeira tenha iniciativa de ataque:
# StrategoTest.py class AttackTest(unittest.TestCase): def testHigherRankWins(self): assert Piece("sergeant").attack(Piece("soldier")) > 0 def testLowerRankLoses(self): assert Piece("miner").attack(Piece("colonel")) < 0 def testTie(self): assert Piece("major").attack(Piece("major")) == 0 def testSpyWinsMarshal(self): assert Piece("spy").attack(Piece("marshal")) > 0 def testMarshalWinsSpy(self): assert Piece("marshal").attack(Piece("spy")) > 0 def testMinerWinsBomb(self): assert Piece("miner").attack(Piece("bomb")) > 0 def testBombCannotAtack(self): try: Piece("bomb").attack(Piece("miner")) except InvalidAttack: pass else: fail("expected InvalidAttack exception") def testFlagCannotAtack(self): try: Piece("flag").attack(Piece("lieutenant")) except InvalidAttack: pass else: fail("expected InvalidAttack exception") |
Podemos adicionar uma nova validação para evitar o ataque de uma bandeira:
# Stratego.py class Piece: # (...) Constructor def attack(self, defender): if self.rank == 11 or self.rank == 0: raise InvalidAttack() if (self.rank,defender.rank) in self.__winsAttacking: return 1 else: return self.rank - defender.rank # (...) Exceptions |
Para finalizar, podemos aproveitar a barra verde para remover uma última duplicação, na validação das peças que não podem atacar. Após a refatoração, o código completo e final fica assim:
# StrategoTest.py import unittest import unittestgui from Stratego import * class CreatePieceTest(unittest.TestCase): def testCreateSoldier(self): assert Piece("soldier").rank == 2 def testCreateMiner(self): assert Piece("miner").rank == 3 def testCreateSergeant(self): assert Piece("sergeant").rank == 4 def testCreateLieutenant(self): assert Piece("lieutenant").rank == 5 def testCreateCaptain(self): assert Piece("captain").rank == 6 def testCreateMajor(self): assert Piece("major").rank == 7 def testCreateColonel(self): assert Piece("colonel").rank == 8 def testCreateGeneral(self): assert Piece("general").rank == 9 def testCreateMarshal(self): assert Piece("marshal").rank == 10 def testCreateSpy(self): assert Piece("spy").rank == 1 def testCreateBomb(self): assert Piece("bomb").rank == 11 def testCreateFlag(self): assert Piece("flag").rank == 0 def testCreateInvalidPiece(self): try: Piece("invalid") except InvalidPiece: pass else: fail("expected InvalidPiece exception") class AttackTest(unittest.TestCase): def testHigherRankWins(self): assert Piece("sergeant").attack(Piece("soldier")) > 0 def testLowerRankLoses(self): assert Piece("miner").attack(Piece("colonel")) < 0 def testTie(self): assert Piece("major").attack(Piece("major")) == 0 def testSpyWinsMarshal(self): assert Piece("spy").attack(Piece("marshal")) > 0 def testMarshalWinsSpy(self): assert Piece("marshal").attack(Piece("spy")) > 0 def testMinerWinsBomb(self): assert Piece("miner").attack(Piece("bomb")) > 0 def testBombCannotAtack(self): try: Piece("bomb").attack(Piece("miner")) except InvalidAttack: pass else: fail("expected InvalidAttack exception") def testFlagCannotAtack(self): try: Piece("flag").attack(Piece("lieutenant")) except InvalidAttack: pass else: fail("expected InvalidAttack exception") suite = unittest.TestSuite((unittest.makeSuite(CreatePieceTest), unittest.makeSuite(AttackTest))) if __name__ == "__main__": unittestgui.main("StrategoTest.suite") # Stratego.py class Piece: __pieces = {"flag":0, "spy":1, "soldier":2, "miner":3, "sergeant":4, "lieutenant":5, "captain":6, "major":7, "colonel":8, "general":9, "marshal":10, "bomb":11} __winsAttacking = [(1,10), (3,11)] __cannotAttack = [0,11] def __init__(self, name): try: self.rank = self.__pieces[name] except KeyError: raise InvalidPiece() def attack(self, defender): if self.rank in self.__cannotAttack: raise InvalidAttack() if (self.rank,defender.rank) in self.__winsAttacking: return 1 else: return self.rank - defender.rank class InvalidPiece(Exception): pass class InvalidAttack(Exception): pass |
Conclusões e próximos passos
Terminamos o segundo post com 70 linhas de código de teste e 26 linhas de código de produção. Como vocês puderam notar, o ritmo imposto pelo TDD é baseado em passos pequenos e resultou num código simples e comunicativo. Por enquanto, temos apenas uma classe para representar uma peça do jogo. No próximo post implementaremos as regras de movimentação no tabuleiro, não percam!
Atualização 03-Out-06: Conforme sugestões, estou disponibilizando para download o código fonte final dos testes e das classes de produção para os interessados não precisarem copiar/colar/formatar tudo novamente.