Come semplificare le mie complesse classi stateful e i loro test?


9

Sono in un progetto di sistema distribuito scritto in Java dove abbiamo alcune classi che corrispondono a oggetti di business reali molto complessi. Questi oggetti hanno molti metodi corrispondenti alle azioni che l'utente (o qualche altro agente) può applicare a quegli oggetti. Di conseguenza, queste classi sono diventate molto complesse.

L'approccio generale di architettura del sistema ha portato a molti comportamenti concentrati su alcune classi e molti possibili scenari di interazione.

A titolo di esempio e per mantenere le cose facili e chiare, diciamo che Robot e Auto erano classi nel mio progetto.

Quindi, nella classe Robot avrei molti metodi nel seguente schema:

  • dormire(); isSleepAvaliable ();
  • sveglio(); isAwakeAvaliable ();
  • a piedi (direzione); isWalkAvaliable ();
  • shoot (direzione); isShootAvaliable ();
  • turnOnAlert (); isTurnOnAlertAvailable ();
  • turnOffAlert (); isTurnOffAlertAvailable ();
  • ricaricare(); isRechargeAvailable ();
  • spegni(); isPowerOffAvailable ();
  • stepInCar (Car); isStepInCarAvailable ();
  • stepOutCar (Car); isStepOutCarAvailable ();
  • auto distruzione(); isSelfDestructAvailable ();
  • morire(); isDieAvailable ();
  • è vivo(); è sveglio(); isAlertOn (); GetBatteryLevel (); getCurrentRidingCar (); getAmmo ();
  • ...

Nella classe Car, sarebbe simile:

  • accendere(); isTurnOnAvaliable ();
  • Spegni(); isTurnOffAvaliable ();
  • a piedi (direzione); isWalkAvaliable ();
  • Fare rifornimento(); isRefuelAvailable ();
  • auto distruzione(); isSelfDestructAvailable ();
  • in crash (); isCrashAvailable ();
  • isOperational (); Ison (); getFuelLevel (); getCurrentPassenger ();
  • ...

Ognuno di questi (robot e auto) è implementato come una macchina a stati, dove alcune azioni sono possibili in alcuni stati e altre no. Le azioni cambiano lo stato dell'oggetto. I metodi di azione vengono generati IllegalStateExceptionquando chiamati in uno stato non valido e i isXXXAvailable()metodi indicano se l'azione è possibile al momento. Sebbene alcuni siano facilmente deducibili dallo stato (ad esempio, nello stato di sonno, è disponibile il risveglio), altri non lo sono (per sparare, deve essere sveglio, vivo, munito di munizioni e non in macchina).

Inoltre, anche le interazioni tra gli oggetti sono complesse. Ad esempio, l'auto può contenere solo un passeggero Robot, quindi se un altro tenta di entrare, deve essere generata un'eccezione; Se l'auto si blocca, il passeggero dovrebbe morire; Se il robot è morto all'interno di un veicolo, non può uscire, anche se l'auto stessa è ok; Se il robot si trova all'interno di un'auto, non può entrarne un'altra prima di uscire; eccetera.

Il risultato di questo, è come ho già detto, queste classi sono diventate davvero complesse. A peggiorare le cose, ci sono centinaia di scenari possibili quando il robot e l'auto interagiscono. Inoltre, gran parte di tale logica deve accedere ai dati remoti in altri sistemi. Il risultato è che il test unitario è diventato molto difficile e abbiamo molti problemi di test, uno che causa l'altro in un circolo vizioso:

  • Le configurazioni dei test sono molto complesse, perché devono creare un mondo significativamente complesso da esercitare.
  • Il numero di test è enorme.
  • L'esecuzione della suite di test richiede alcune ore.
  • La nostra copertura di test è molto bassa.
  • Il codice di test tende a essere scritto settimane o mesi dopo il codice che testano, o mai del tutto.
  • Anche molti test vengono interrotti, principalmente perché i requisiti del codice testato sono cambiati.
  • Alcuni scenari sono così complessi, che falliscono al timeout durante l'installazione (abbiamo configurato un timeout in ogni test, nel peggiore dei casi 2 minuti e anche questa volta hanno timeout, ci siamo assicurati che non si trattasse di un loop infinito).
  • I bug si inseriscono regolarmente nell'ambiente di produzione.

Lo scenario di Robot e auto è una grossolana semplificazione di ciò che abbiamo nella realtà. Chiaramente, questa situazione non è gestibile. Quindi, sto chiedendo aiuto e suggerimenti per: 1, ridurre la complessità delle lezioni; 2. Semplifica gli scenari di interazione tra i miei oggetti; 3. Ridurre i tempi di test e la quantità di codice da testare.

EDIT:
penso di non essere stato chiaro sulle macchine statali. il Robot è esso stesso una macchina a stati, con stati "dormienti", "svegli", "in ricarica", "morti", ecc. La Macchina è un'altra macchina a stati.

EDIT 2: Nel caso in cui tu sia curioso di sapere cosa sia effettivamente il mio sistema, le classi che interagiscono sono cose come Server, indirizzo IP, disco, backup, utente, licenza software, ecc. Lo scenario Robot e auto è solo un caso che ho trovato sarebbe abbastanza semplice da spiegare il mio problema.


hai considerato di chiedere a Code Review.SE ? A parte questo, per un design come il tuo inizierei a pensare al refactoring del tipo di classe Extract
moscerino

Ho considerato Code Review, ma non è il posto giusto. Il problema principale non è nel codice stesso, il problema è nell'approccio dell'architettura generale del sistema che porta a molti comportamenti concentrati su poche classi e molti possibili scenari di interazione.
Victor Stafusa,

@gnat Puoi fornire un esempio di come implementerei la classe di estrazione nel dato scenario di robot e auto?
Victor Stafusa,

Estraerei le cose relative all'auto da Robot in una classe separata. Estrarrei anche tutti i metodi relativi a sleep + wake in una classe dedicata. Altri "candidati" che sembrano meritare l'estrazione sono i metodi di alimentazione + ricarica, cose relative al movimento. Ecc. Nota, dato che si tratta di refactoring, l'API esterna per robot dovrebbe probabilmente rimanere; nella prima fase modificherei solo gli interni. BTDTGTTS
moscerino

Questa non è una domanda di revisione del codice - l'architettura è fuori tema lì.
Michael K,

Risposte:


8

Il modello di progettazione dello stato potrebbe essere utile, se non lo si sta già utilizzando.

L'idea centrale è che si crea una classe interna per ogni stato distinto - in modo da continuare a tuo esempio, SleepingRobot, AwakeRobot, RechargingRobote DeadRobottutto sarebbe classi, l'attuazione di un'interfaccia comune.

I metodi sulla Robotclasse (like sleep()e isSleepAvaliable()) hanno implementazioni semplici che delegano alla classe interna corrente.

I cambiamenti di stato vengono implementati sostituendo l'attuale classe interna con una diversa.

Il vantaggio di questo approccio è che ciascuna delle classi di stato è notevolmente più semplice (in quanto rappresenta solo uno stato possibile) e può essere testata in modo indipendente. A seconda della lingua di implementazione (non specificata), potresti essere ancora costretto ad avere tutto nello stesso file o potresti essere in grado di dividere le cose in file di origine più piccoli.


Sto usando Java.
Victor Stafusa,

Buon consiglio In questo modo ogni implementazione ha un focus chiaro che può essere testato individualmente senza avere una classe junit di 2.000 linee che collauda tutti gli stati contemporaneamente.
OliverS,

3

Non conosco il tuo codice, ma prendendo l'esempio del metodo "sleep", suppongo che sia qualcosa di simile al seguente codice "semplicistico":

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

Penso che devi fare la differenza tra test di integrazione e test unitari . Scrivere un test che attraversi l'intero stato della macchina è sicuramente un grosso compito. Scrivere test di unità più piccoli che testano se il tuo metodo di sonno funziona correttamente è più facile. A questo punto non è necessario sapere se lo stato della macchina è stato aggiornato correttamente o se la "macchina" ha risposto correttamente al fatto che lo stato della macchina è stato aggiornato dal "robot" ... ecc., Lo si ottiene.

Dato il codice sopra, deriderei l'oggetto "machineState" e il mio primo test sarebbe:

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

La mia opinione personale è che scrivere test di unità così piccoli dovrebbe essere la prima cosa da fare. Hai scritto che:

Le configurazioni dei test sono molto complesse, perché devono creare un mondo significativamente complesso da esercitare.

Eseguire questi piccoli test dovrebbe essere molto veloce e non dovresti avere nulla da inizializzare in anticipo come il tuo "mondo complesso". Ad esempio, se si tratta di un'applicazione basata su un contenitore IOC (ad esempio Spring), non è necessario inizializzare il contesto durante i test unitari.

Dopo aver coperto una buona percentuale del codice complesso con unit test, è possibile iniziare a creare test di integrazione più complessi e che richiedono molto tempo.

Infine, questo può essere fatto se il tuo codice è complesso (come hai detto ora) o dopo aver eseguito il refactoring.


Penso di non essere stato chiaro sulle macchine statali. il Robot è esso stesso una macchina a stati, con stati "dormienti", "svegli", "in ricarica", "morti", ecc. La Macchina è un'altra macchina a stati.
Victor Stafusa,

@Victor OK, se vuoi puoi correggere il mio codice di esempio. A meno che non mi dica diversamente, penso che il mio punto sui test unitari sia ancora valido, almeno lo spero.
Jalayn,

Ho corretto l'esempio. Non ho il privilegio di renderlo facilmente visibile, quindi prima deve essere sottoposto a peer review. Il tuo commento è utile
Victor Stafusa,

2

Stavo leggendo la sezione "Origine" dell'articolo di Wikipedia sul principio di segregazione dell'interfaccia e mi è stata ricordata questa domanda.

Citerò l'articolo. Il problema: "... una classe professionale principale ... una classe grassa con una moltitudine di metodi specifici per una varietà di clienti diversi". La soluzione: "... uno strato di interfacce tra la classe Job e tutti i suoi client ..."

Il tuo problema sembra una permutazione di quello che Xerox aveva. Invece di una classe di grassi ne hai due, e queste due classi di grassi parlano tra loro invece di molti clienti.

Puoi raggruppare i metodi per tipo di interazione e quindi creare una classe di interfaccia per ciascun tipo? Ad esempio: classi RobotPowerInterface, RobotNavigationInterface, RobotAlarmInterface?

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.