¿Cómo simplificar mis clases complejas con estado y sus pruebas?


9

Estoy en un proyecto de sistema distribuido escrito en Java donde tenemos algunas clases que corresponden a objetos comerciales muy complejos del mundo real. Estos objetos tienen muchos métodos que corresponden a acciones que el usuario (o algún otro agente) puede aplicar a esos objetos. Como resultado, estas clases se volvieron muy complejas.

El enfoque de la arquitectura general del sistema ha llevado a muchos comportamientos concentrados en algunas clases y muchos escenarios de interacción posibles.

Como ejemplo y para mantener las cosas fáciles y claras, digamos que Robot y Car fueron clases en mi proyecto.

Entonces, en la clase Robot, tendría muchos métodos en el siguiente patrón:

  • dormir(); isSleepAvaliable ();
  • despierto(); isAwakeAvaliable ();
  • caminar (dirección); isWalkAvaliable ();
  • disparar (dirección); isShootAvaliable ();
  • turnOnAlert (); isTurnOnAlertAvailable ();
  • turnOffAlert (); isTurnOffAlertAvailable ();
  • recargar(); isRechargeAvailable ();
  • apagado(); isPowerOffAvailable ();
  • stepInCar (Auto); isStepInCarAvailable ();
  • stepOutCar (Auto); isStepOutCarAvailable ();
  • auto destrucción(); isSelfDestructAvailable ();
  • morir(); isDieAvailable ();
  • isAlive (); está despierto(); isAlertOn (); getBatteryLevel (); getCurrentRidingCar (); getAmmo ();
  • ...

En la clase Car, sería similar:

  • encender(); isTurnOnAvaliable ();
  • apagar(); isTurnOffAvaliable ();
  • caminar (dirección); isWalkAvaliable ();
  • repostar(); isRefuelAvailable ();
  • auto destrucción(); isSelfDestructAvailable ();
  • choque(); isCrashAvailable ();
  • isOperational (); Está encendido(); getFuelLevel (); getCurrentPassenger ();
  • ...

Cada uno de estos (Robot y Auto) se implementa como una máquina de estado, donde algunas acciones son posibles en algunos estados y otras no. Las acciones cambian el estado del objeto. Los métodos de acciones se lanzan IllegalStateExceptioncuando se llama en un estado no válido y los isXXXAvailable()métodos indican si la acción es posible en ese momento. Aunque algunos son fácilmente deducibles del estado (p. Ej., En el estado de sueño, la vigilia está disponible), otros no (para disparar, debe estar despierto, vivo, tener munición y no viajar en automóvil).

Además, las interacciones entre los objetos también son complejas. Por ejemplo, el automóvil solo puede contener un pasajero Robot, por lo que si otro intenta ingresar, se debe lanzar una excepción; Si el auto choca, el pasajero debe morir; Si el robot está muerto dentro de un vehículo, no puede salir, incluso si el auto está bien; Si el robot está dentro de un automóvil, no puede entrar en otro antes de salir; etc.

El resultado de esto, es como ya dije, estas clases se volvieron realmente complejas. Para empeorar las cosas, hay cientos de escenarios posibles cuando el robot y el automóvil interactúan. Además, gran parte de esa lógica necesita acceder a datos remotos en otros sistemas. El resultado es que las pruebas unitarias se volvieron muy difíciles y tenemos muchos problemas de prueba, uno causando al otro en un círculo vicioso:

  • Las configuraciones de los casos de prueba son muy complejas, ya que necesitan crear un mundo significativamente complejo para ejercitarse.
  • El número de pruebas es enorme.
  • El conjunto de pruebas tarda algunas horas en ejecutarse.
  • Nuestra cobertura de prueba es muy baja.
  • El código de prueba tiende a escribirse semanas o meses después del código que prueban, o nunca en absoluto.
  • Muchas pruebas también se rompen, principalmente porque los requisitos del código probado cambiaron.
  • Algunos escenarios son tan complejos que fallan en el tiempo de espera durante la configuración (configuramos un tiempo de espera en cada prueba, en el peor de los casos, 2 minutos de duración e incluso este tiempo de espera, nos aseguramos de que no sea un bucle infinito).
  • Los errores se deslizan regularmente en el entorno de producción.

Ese escenario de Robot y Automóvil es una gran simplificación excesiva de lo que tenemos en realidad. Claramente, esta situación no es manejable. Por lo tanto, pido ayuda y sugerencias para: 1, reducir la complejidad de las clases; 2. Simplificar los escenarios de interacciones entre mis objetos; 3. Reduzca el tiempo de prueba y la cantidad de código que se probará.

EDITAR:
Creo que no estaba claro acerca de las máquinas de estado. el robot es en sí mismo una máquina de estados, con estados "durmiendo", "despierto", "recargando", "muerto", etc. El automóvil es otra máquina de estados.

EDITAR 2: en caso de que tenga curiosidad sobre cuál es realmente mi sistema, las clases que interactúan son cosas como Servidor, Dirección IP, Disco, Copia de seguridad, Usuario, Licencia de software, etc. El escenario Robot y Coche es solo un caso que encontré eso sería lo suficientemente simple como para explicar mi problema.


¿consideró preguntar en Code Review.SE ? Aparte de eso, para un diseño como el tuyo, comenzaría a pensar en la refactorización del tipo de clase Extracto
mosquito

Considere la Revisión de Código, pero ese no es el lugar correcto. El problema principal no está en el código en sí, sino en el enfoque de la arquitectura general del sistema que conduce a muchos comportamientos concentrados en unas pocas clases y muchos escenarios de interacción posibles.
Victor Stafusa

@gnat ¿Puede proporcionar un ejemplo de cómo implementaría Extract Class en el escenario Robot y Car dado?
Victor Stafusa

Extraería cosas relacionadas con el automóvil de Robot en una clase separada. También extraería todos los métodos relacionados con sleep + wake en una clase dedicada. Otros "candidatos" que parecen merecer extracción son los métodos de potencia + recarga, cosas relacionadas con el movimiento. Etc. Tenga en cuenta que dado que esto es una refactorización, la API externa para robot probablemente debería permanecer; en la primera etapa solo modificaría lo interno. BTDTGTTS
mosquito

Esta no es una pregunta de revisión de código: la arquitectura está fuera de tema allí.
Michael K

Respuestas:


8

El patrón de diseño de estado podría ser útil, si aún no lo está utilizando.

La idea central es que se crea una clase interna para cada estado distinto - por lo que seguir tu ejemplo SleepingRobot, AwakeRobot, RechargingRoboty DeadRobottodo habría clases, la implementación de una interfaz común.

Los métodos en la Robotclase (like sleep()y isSleepAvaliable()) tienen implementaciones simples que delegan a la clase interna actual.

Los cambios de estado se implementan intercambiando la clase interna actual con una diferente.

La ventaja de este enfoque es que cada una de las clases de estado es dramáticamente más simple (ya que representa solo un estado posible) y puede probarse de forma independiente. Dependiendo de su lenguaje de implementación (no especificado), es posible que todavía tenga que tener todo en el mismo archivo, o puede dividir las cosas en archivos de origen más pequeños.


Estoy usando java
Victor Stafusa

Buena sugerencia. De esta manera, cada implementación tiene un enfoque claro que puede probarse individualmente sin tener una clase de junit de 2.000 líneas que pruebe todos los estados simultáneamente.
OliverS

3

No conozco su código, pero tomando el ejemplo del método de "suspensión", asumiré que es algo similar al siguiente 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");
}

Creo que hay que marcar la diferencia entre las pruebas de integración y las pruebas unitarias . Escribir una prueba que se ejecute en todo el estado de su máquina es ciertamente una gran tarea. Escribir pruebas unitarias más pequeñas que prueben si su método de sueño funciona correctamente es más fácil. En este punto, no tiene que saber si el estado de la máquina se actualizó correctamente o si el "automóvil" respondió correctamente al hecho de que el estado de la máquina fue actualizado por el "robot" ... etc.

Dado el código anterior, me burlaría del objeto "machineState" y mi primera prueba sería:

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());
 }
}

Mi opinión personal es que escribir pruebas unitarias tan pequeñas debería ser lo primero que debe hacer. Tu escribiste eso:

Las configuraciones de los casos de prueba son muy complejas, ya que necesitan crear un mundo significativamente complejo para ejercitarse.

La ejecución de estas pequeñas pruebas debería ser muy rápida y no debería tener nada que inicializar de antemano como su "mundo complejo". Por ejemplo, si se trata de una aplicación basada en un contenedor IOC (por ejemplo, Spring), no debería necesitar inicializar el contexto durante las pruebas unitarias.

Después de haber cubierto un buen porcentaje de su código complejo con pruebas unitarias, puede comenzar a construir pruebas de integración más complejas y que requieren más tiempo.

Finalmente, esto se puede hacer si su código es complejo (como usted dijo que es ahora) o después de haber refactorizado.


Creo que no estaba claro acerca de las máquinas de estado. el robot es en sí mismo una máquina de estados, con estados "durmiendo", "despierto", "recargando", "muerto", etc. El automóvil es otra máquina de estados.
Victor Stafusa

@Victor OK, siéntase libre de corregir mi código de ejemplo si lo desea. A menos que me diga lo contrario, creo que mi punto en las pruebas unitarias sigue siendo válido, al menos eso espero.
Jalayn

Corrija el ejemplo. No tengo privilegios para hacerlo fácilmente visible, por lo que primero debe ser revisado por pares. Tu comentario es útil.
Victor Stafusa

2

Estaba leyendo la sección "Origen" del artículo de Wikipedia sobre el Principio de segregación de interfaz , y me recordó esta pregunta.

Citaré el artículo. El problema: "... una clase de trabajo principal ... una clase gorda con multitud de métodos específicos para una variedad de clientes diferentes". La solución: "... una capa de interfaces entre la clase Job y todos sus clientes ..."

Su problema parece una permutación de la que tenía Xerox. En lugar de una clase de grasa, tienes dos, y estas dos clases de grasa están hablando entre sí en lugar de muchos clientes.

¿Puede agrupar los métodos por tipo de interacción y luego crear una clase de interfaz para cada tipo? Por ejemplo: clases RobotPowerInterface, RobotNavigationInterface, RobotAlarmInterface?

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.