Como simplificar minhas classes stateful complexas e seus testes?

9

Estou em um projeto de sistema distribuído escrito em java, onde temos algumas classes que correspondem a objetos de negócios do mundo real muito complexos. Esses objetos têm muitos métodos correspondentes às ações que o usuário (ou outro agente) pode aplicar a esses objetos. Como resultado, essas classes se tornaram muito complexas.

A abordagem da arquitetura geral do sistema levou a muitos comportamentos concentrados em poucas classes e muitos cenários possíveis de interação.

Como exemplo, e para manter as coisas fáceis e claras, digamos que Robot e Car eram classes no meu projeto.

Portanto, na classe Robot, eu teria muitos métodos no seguinte padrão:

  • dormir(); isSleepAvaliable ();
  • acordado(); isAwakeAvaliable ();
  • caminhada (direção); isWalkAvaliable ();
  • atirar (direção); isShootAvaliable ();
  • turnOnAlert (); isTurnOnAlertAvailable ();
  • turnOffAlert (); isTurnOffAlertAvailable ();
  • recarrega(); isRechargeAvailable ();
  • desligar(); isPowerOffAvailable ();
  • stepInCar (Car); isStepInCarAvailable ();
  • stepOutCar (carro); isStepOutCarAvailable ();
  • Auto-destruição(); isSelfDestructAvailable ();
  • morrer(); isDieAvailable ();
  • Está vivo(); está acordado(); isAlertOn (); getBatteryLevel (); getCurrentRidingCar (); getAmmo ();
  • ...

Na classe Car, seria semelhante:

  • Ligar(); isTurnOnAvaliable ();
  • desligar(); isTurnOffAvaliable ();
  • caminhada (direção); isWalkAvaliable ();
  • reabastecer(); isRefuelAvailable ();
  • Auto-destruição(); isSelfDestructAvailable ();
  • batida(); isCrashAvailable ();
  • isOperational (); isOn (); getFuelLevel (); getCurrentPassenger ();
  • ...

Cada um deles (robô e carro) é implementado como uma máquina de estado, onde algumas ações são possíveis em alguns estados e outras não. As ações alteram o estado do objeto. Os métodos de ações são lançados IllegalStateExceptionquando chamados em um estado inválido e os isXXXAvailable()métodos informam se a ação é possível no momento. Embora alguns sejam facilmente dedutíveis do estado (por exemplo, no estado de sono, está disponível o estado de vigília), outros não são (para atirar, ele deve estar acordado, vivo, com munição e sem andar de carro).

Além disso, as interações entre os objetos também são complexas. Por exemplo, o carro pode conter apenas um passageiro do robô; portanto, se outro tentar entrar, uma exceção deve ser lançada; Se o carro bater, o passageiro deve morrer; Se o robô está morto dentro de um veículo, ele não pode sair, mesmo que o carro esteja ok; Se o robô estiver dentro de um carro, ele não poderá entrar em outro antes de sair; etc.

O resultado disso é que, como eu já disse, essas classes se tornaram realmente complexas. Para piorar as coisas, existem centenas de cenários possíveis quando o robô e o carro interagem. Além disso, grande parte dessa lógica precisa acessar dados remotos em outros sistemas. O resultado é que o teste de unidade tornou-se muito difícil e temos muitos problemas de teste, um causando o outro em um círculo vicioso:

  • As configurações dos casos de teste são muito complexas, porque precisam criar um mundo significativamente complexo para se exercitar.
  • O número de testes é enorme.
  • O conjunto de testes leva algumas horas para ser executado.
  • Nossa cobertura de teste é muito baixa.
  • O código de teste tende a ser escrito semanas ou meses depois do código que eles testam, ou nunca.
  • Muitos testes também são interrompidos, principalmente porque os requisitos do código testado foram alterados.
  • Alguns cenários são tão complexos que falham no tempo limite durante a instalação (configuramos um tempo limite em cada teste, nos piores casos com 2 minutos e, mesmo assim, com o tempo limite, garantimos que não seja um loop infinito).
  • Os erros entram regularmente no ambiente de produção.

Esse cenário de robô e carro é uma simplificação exagerada do que temos na realidade. Claramente, esta situação não é gerenciável. Então, estou pedindo ajuda e sugestões para: 1, reduzir a complexidade das classes; 2. Simplifique os cenários de interações entre meus objetos; 3. Reduza o tempo de teste e a quantidade de código a ser testado.

Edição:
Eu acho que não estava claro sobre as máquinas de estado. o robô é em si uma máquina de estado, com estados "adormecido", "acordado", "recarregando", "morto" etc. O carro é outra máquina de estado.

EDIT 2: Caso você esteja curioso sobre o que meu sistema realmente é, as classes que interagem são coisas como Servidor, Endereço IP, Disco, Backup, Usuário, SoftwareLicense, etc. O cenário Robô e Carro é apenas um caso que eu encontrei isso seria simples o suficiente para explicar meu problema.

Victor Stafusa
fonte
você pensou em perguntar no Code Review.SE ? Fora isso, para o projeto como o seu eu começar a pensar sobre Extract Classe tipo refatoração
mosquito
Eu considerei a Revisão de código, mas esse não é o lugar certo. O principal problema não está no código em si, mas na abordagem da arquitetura geral do sistema que leva a muitos comportamentos concentrados em poucas classes e a muitos cenários possíveis de interação.
Victor Stafusa
@gnat Você pode fornecer um exemplo de como eu implementaria Extract Class no cenário fornecido de Robot and Car?
Victor Stafusa
Eu extrairia coisas relacionadas a carros do Robot em uma classe separada. Eu também extrairia todos os métodos relacionados ao sono + acordado em uma classe dedicada. Outros "candidatos" que parecem merecer extração são os métodos de energia + recarga, coisas relacionadas ao movimento. Etc. Observe que, como se trata de refatoração, a API externa do robô provavelmente deve permanecer; no primeiro estágio, eu modificaria apenas os internos. BTDTGTTS
mosquito
Esta não é uma pergunta de revisão de código - a arquitetura está fora de tópico.
Michael K

Respostas:

8

O padrão de design do Estado pode ser útil, se você ainda não o estiver usando.

A idéia central é que você cria uma classe interna para cada estado diferente - assim para continuar suas exemplo, SleepingRobot, AwakeRobot, RechargingRobote DeadRobottudo seria aulas, a implementação de uma interface comum.

Os métodos na Robotclasse (like sleep()e isSleepAvaliable()) têm implementações simples que delegam à classe interna atual.

Alterações de estado são implementadas trocando a classe interna atual por outra diferente.

A vantagem dessa abordagem é que cada uma das classes de estado é dramaticamente mais simples (pois representa apenas um estado possível) e pode ser testada independentemente. Dependendo da linguagem de implementação (não especificada), você ainda pode estar restrito a ter tudo no mesmo arquivo ou pode dividir as coisas em arquivos de origem menores.

Bevan
fonte
Eu estou usando java.
Victor Stafusa
Boa sugestão. Dessa forma, cada implementação tem um foco claro que pode ser testado individualmente sem que uma classe junit de 2.000 linhas teste todos os estados simultaneamente.
27412 OliverS
3

Não conheço seu código, mas, tomando o exemplo do método "sleep", assumirei que é algo parecido com o seguinte código "simplista":

public void sleep() {
 if(!dead && awake) {
  sleeping = true;
  awake = false;
  this.updateState(SLEEPING);
 }
 throw new IllegalArgumentException("robot is either dead or not awake");
}

Eu acho que você precisa fazer a diferença entre testes de integração e testes de unidade . Escrever um teste executado em todo o estado da máquina é certamente uma grande tarefa. Escrever testes de unidade menores que testam se seu método de dormir funciona corretamente é mais fácil. Neste ponto, você não precisa saber se o estado da máquina foi atualizado corretamente ou se o "carro" respondeu corretamente ao fato de que o estado da máquina foi atualizado pelo "robô" ... etc.

Dado o código acima, eu zombaria do objeto "machineState" e meu primeiro teste seria:

testSleep_dead() {
 robot.dead = true;
 robot.awake = false;
 robot.setState(AWAKE);
 try {
  robot.sleep();
  fail("should have got an exception");
 } catch(Exception e) {
  assertTrue(e instanceof IllegalArgumentException);
  assertEquals("robot is either dead or not awake", e.getMessage());
 }
}

Minha opinião pessoal é que escrever esses pequenos testes unitários deve ser a primeira coisa a fazer. Você escreveu isso:

As configurações dos casos de teste são muito complexas, porque precisam criar um mundo significativamente complexo para se exercitar.

A execução desses pequenos testes deve ser muito rápida e você não deve ter nada para inicializar como seu "mundo complexo". Por exemplo, se for um aplicativo baseado em um contêiner do IOC (por exemplo, Spring), você não precisará inicializar o contexto durante os testes de unidade.

Depois de cobrir uma boa porcentagem do seu código complexo com testes de unidade, você poderá começar a criar testes de integração mais demorados e mais complexos.

Finalmente, isso pode ser feito se o seu código for complexo (como você disse que é agora) ou depois de refatorar.

Jalayn
fonte
Eu acho que não estava claro sobre as máquinas de estado. o robô é em si uma máquina de estado, com estados "adormecido", "acordado", "recarregando", "morto" etc. O carro é outra máquina de estado.
Victor Stafusa
@ Victor OK, fique à vontade para corrigir meu código de exemplo, se quiser. A menos que você me diga o contrário, acho que meu argumento sobre os testes de unidade ainda é válido, espero que pelo menos.
Jalayn
Eu corrigi o exemplo. Não tenho privilégios para torná-lo facilmente visível, por isso deve ser revisado por pares primeiro. Seu comentário é útil.
Victor Stafusa
2

Eu estava lendo a seção "Origem" do artigo da Wikipedia sobre o Princípio de Segregação de Interface , e me lembrei dessa pergunta.

Vou citar o artigo. O problema: "... uma classe principal de trabalho ... uma classe gorda com vários métodos específicos para uma variedade de clientes diferentes". A solução: "... uma camada de interfaces entre a classe Job e todos os seus clientes ..."

Seu problema parece uma permutação da que a Xerox tinha. Em vez de uma classe gorda, você tem duas, e essas duas classes gordas estão conversando entre si em vez de muitos clientes.

Você pode agrupar os métodos por tipo de interação e criar uma classe de interface para cada tipo? Por exemplo: RobotPowerInterface, RobotNavigationInterface, RobotAlarmInterface classes?

Jeff
fonte