Test unitari in un mondo "senza setter"


23

Non mi considero un esperto di DDD ma, come architetto di soluzioni, cerco di applicare le migliori pratiche ogni volta che è possibile. So che ci sono molte discussioni sui pro e contro dello "stile" di setter (pubblico) in DDD e posso vedere entrambi i lati dell'argomento. Il mio problema è che lavoro in un team con una grande diversità di competenze, conoscenze ed esperienze, il che significa che non posso fidarmi che ogni sviluppatore farà le cose nel modo "giusto". Ad esempio, se i nostri oggetti di dominio sono progettati in modo tale che le modifiche allo stato interno dell'oggetto vengano eseguite da un metodo ma forniscano setter di proprietà pubbliche, qualcuno inevitabilmente imposterà la proprietà invece di chiamare il metodo. Usa questo esempio:

public class MyClass
{
    public Boolean IsPublished
    {
        get { return PublishDate != null; }
    }

    public DateTime? PublishDate { get; set; }

    public void Publish()
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        PublishDate = DateTime.Today;

        Raise(new PublishedEvent());
    }
}

La mia soluzione è stata quella di rendere privati ​​i setter di proprietà, il che è possibile perché l'ORM che stiamo usando per idratare gli oggetti usa la riflessione in modo da poter accedere ai setter privati. Tuttavia, ciò presenta un problema quando si tenta di scrivere unit test. Ad esempio, quando voglio scrivere un unit test che verifica il requisito che non possiamo ripubblicare, devo indicare che l'oggetto è già stato pubblicato. Posso certamente farlo chiamando Pubblica due volte, ma poi il mio test presuppone che Pubblica sia implementato correttamente per la prima chiamata. Sembra un po 'puzzolente.

Rendiamo lo scenario un po 'più reale con il seguente codice:

public class Document
{
    public Document(String title)
    {
        if (String.IsNullOrWhiteSpace(title))
            throw new ArgumentException("title");

        Title = title;
    }

    public String ApprovedBy { get; private set; }
    public DateTime? ApprovedOn { get; private set; }
    public Boolean IsApproved { get; private set; }
    public Boolean IsPublished { get; private set; }
    public String PublishedBy { get; private set; }
    public DateTime? PublishedOn { get; private set; }
    public String Title { get; private set; }

    public void Approve(String by)
    {
        if (IsApproved)
            throw new InvalidOperationException("Already approved.");

        ApprovedBy = by;
        ApprovedOn = DateTime.Today;
        IsApproved = true;

        Raise(new ApprovedEvent(Title));
    }

    public void Publish(String by)
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        if (!IsApproved)
            throw new InvalidOperationException("Cannot publish until approved.");

        PublishedBy = by;
        PublishedOn = DateTime.Today;
        IsPublished = true;

        Raise(new PublishedEvent(Title));
    }
}

Voglio scrivere unit test che verificano:

  • Non posso pubblicare se il documento non è stato approvato
  • Non riesco a ripubblicare un documento
  • Quando pubblicati, i valori PublishedBy e PublishedOn sono impostati correttamente
  • Quando viene pubblicato, il PublishedEvent viene generato

Senza accesso ai setter, non posso mettere l'oggetto nello stato necessario per eseguire i test. L'apertura dell'accesso ai setter vanifica lo scopo di impedire l'accesso.

In che modo (hai) risolvi (d) questo problema?


Più ci penso, più penso che il tuo intero problema abbia metodi con effetti collaterali. O meglio, un oggetto immutabile mutevole. In un mondo DDD, non dovresti restituire un nuovo oggetto Document da Approve e Publish, piuttosto che aggiornare lo stato interno di questo oggetto?
pdr

1
Domanda rapida, quale O / RM stai usando. Sono un grande fan di EF, ma dichiarare i setter come protetti mi fa un po 'male.
Michael Brown,

Abbiamo un mix in questo momento a causa dello sviluppo a distanza che mi è stato accusato di litigare. Alcuni ADO.NET utilizzano AutoMapper per idratare da un DataReader, un paio di modelli Linq-SQL (che sarà il prossimo a sostituire ) e alcuni nuovi modelli EF.
SonOfPirate

Chiamare Pubblica due volte non è affatto maleodorante ed è il modo per farlo.
Piotr Perak,

Risposte:


27

Non riesco a mettere l'oggetto nello stato necessario per eseguire i test.

Se non è possibile inserire l'oggetto nello stato necessario per effettuare un test, quindi non è possibile inserire l'oggetto nello stato nel codice di produzione, quindi non c'è alcun bisogno di prova che lo stato. Ovviamente, questo non è vero nel tuo caso, puoi mettere il tuo oggetto nello stato necessario, basta chiamare Approva.

  • Non posso pubblicare se il Documento non è stato approvato: scrivere un test che chiama la pubblicazione prima di chiamare approva provoca l'errore giusto senza cambiare lo stato dell'oggetto.

    void testPublishBeforeApprove() {
        doc = new Document("Doc");
        AssertRaises(doc.publish, ..., NotApprovedException);
    }
    
  • Non riesco a ripubblicare un documento: scrivere un test che approva un oggetto, quindi chiamare una volta riesci, ma la seconda volta provoca l'errore giusto senza cambiare lo stato dell'oggetto.

    void testRePublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        AssertRaises(doc.publish, ..., RepublishException);
    }
    
  • Quando vengono pubblicati, i valori PublishedBy e PublishedOn sono impostati correttamente: scrivere un test che chiama approva quindi chiamare la pubblicazione, affermare che lo stato dell'oggetto cambia correttamente

    void testPublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        Assert(doc.PublishedBy, ...);
        ...
    }
    
  • Quando viene pubblicato, il PublishedEvent viene generato: aggancia al sistema di eventi e imposta un flag per assicurarti che venga chiamato

È inoltre necessario scrivere test per approvare.

In altre parole, non testare la relazione tra i campi interni e IsPublished e IsApproved, il test sarebbe piuttosto fragile se lo facessi poiché cambiare il campo significherebbe cambiare il codice dei test, quindi il test sarebbe abbastanza inutile. Dovresti invece testare la relazione tra chiamate di metodi pubblici, in questo modo, anche se modifichi i campi non dovrai modificare il test.


Quando Approve si interrompe, vengono interrotti diversi test. Non stai più testando un'unità di codice, stai testando l'implementazione completa.
pdr

Condivido la preoccupazione di pdr ed è per questo che ho esitato ad andare in questa direzione. Sì, sembra più pulito, ma non mi piace avere più ragioni per cui un singolo test può fallire.
SonOfPirate

4
Devo ancora vedere un test unitario che potrebbe fallire solo per un singolo motivo possibile. Inoltre, è possibile inserire in un setup()metodo le parti di "manipolazione dello stato" del test , non il test stesso.
Peter K.

12
Perché dipende in approve()qualche modo fragile, eppure in setApproved(true)qualche modo non lo è? approve()è una dipendenza legittima nei test perché è una dipendenza nei requisiti. Se la dipendenza esistesse solo nei test, questo sarebbe un altro problema.
Karl Bielefeldt,

2
@pdr, come testeresti una classe di stack? Proveresti a testare i metodi push()e in pop()modo indipendente?
Winston Ewert,

2

Ancora un altro approccio è quello di creare un costruttore della classe che consenta di impostare le proprietà interne sull'istanza:

 public Document(
  String approvedBy,
  DateTime? approvedOn,
  Boolean isApproved,
  Boolean isPublished,
  String publishedBy,
  DateTime? publishedOn,
  String title)
{
  ApprovedBy = approvedBy;
  ApprovedOn = approvedOn;
  IsApproved = isApproved;
  IsApproved = isApproved;
  PublishedBy = publishedBy;
  PublishedOn = publishedOn;
}

2
Questo non si adatta affatto bene. Il mio oggetto potrebbe avere molte più proprietà con un numero qualsiasi di loro che hanno o non hanno valori in un dato punto del ciclo di vita dell'oggetto. Seguo il principio secondo cui i costruttori contengono parametri per le proprietà richieste affinché l'oggetto sia in uno stato iniziale valido o dipendenze che un oggetto richiede per funzionare. Lo scopo delle proprietà nell'esempio è acquisire lo stato corrente mentre l'oggetto viene manipolato. Avere un costruttore con ogni proprietà o sovraccarichi con combinazioni diverse è un odore enorme e, come ho detto, non si ridimensiona.
SonOfPirate

Inteso. Il tuo esempio non ha menzionato molte altre proprietà e il numero nell'esempio è "sulla cuspide" di avere questo come approccio valido. Sembra che questo ti stia dicendo qualcosa sul tuo design: non puoi mettere il tuo oggetto in uno stato valido all'istanza. Ciò significa che devi metterlo in uno stato iniziale valido e loro lo manipolano nello stato giusto per il test. Ciò implica che la risposta di Lie Ryan è la strada da percorrere .
Peter K.

Anche se l'oggetto ha una proprietà e non cambierà mai questa soluzione è male. Cosa impedisce a chiunque di utilizzare questo costruttore nella produzione? Come segnerai questo costruttore [TestOnly]?
Piotr Perak,

Perché è male in produzione? (Davvero, mi piacerebbe saperlo). A volte è necessario ricreare lo stato preciso di un oggetto al momento della creazione ... non solo un singolo oggetto iniziale valido.
Peter K.

1
Quindi, mentre ciò aiuta a mettere l'oggetto in uno stato iniziale valido, testare il comportamento dell'oggetto durante il suo ciclo di vita richiede che l'oggetto sia cambiato dal suo stato iniziale. Il mio OP ha a che fare con il test di questi stati aggiuntivi quando non è possibile semplicemente impostare le proprietà per modificare lo stato dell'oggetto.
SonOfPirate il

1

Una strategia è quella di ereditare la classe (in questo caso Documento) e scrivere test contro la classe ereditata. La classe ereditata consente in qualche modo di impostare lo stato dell'oggetto nei test.

In C # una strategia potrebbe essere quella di rendere interni i setter, quindi esporre gli interni per testare il progetto.

Potresti anche usare l'API di classe come hai descritto ("Posso certamente farlo chiamando due volte Pubblica"). Questo significherebbe impostare lo stato dell'oggetto usando i servizi pubblici dell'oggetto, non mi sembra troppo maleodorante. Nel caso del tuo esempio, questo sarebbe probabilmente il modo in cui lo farei.


Ho pensato a questa come una possibile soluzione, ma ho esitato a rendere le mie proprietà sostituibili o esporre i setter come protetti perché mi sembrava di aprire l'oggetto e rompere l'incapsulamento. Penso che rendere le proprietà protette sia sicuramente meglio di pubblico o addirittura interno / amico. Darò sicuramente più considerazione a questo approccio. È semplice ed efficace. A volte è l'approccio migliore. Se qualcuno non è d'accordo, si prega di aggiungere commenti con specifiche.
SonOfPirate

1

Per testare in assoluto isolamento i comandi e le query che ricevono gli oggetti di dominio, sono abituato a fornire ad ogni test una serializzazione dell'oggetto nello stato previsto. Nella sezione organizza del test, carica l'oggetto da testare da un file che ho preparato in precedenza. All'inizio ho iniziato con le serializzazioni binarie, ma json ha dimostrato di essere molto più facile da mantenere. Ciò ha dimostrato di funzionare bene ogni volta che l'isolamento assoluto nei test fornisce un valore reale.

modifica solo una nota, a volte la serializzazione JSON fallisce (come nel caso dei grafici di oggetti ciclici, che sono un odore, a proposito). In tali situazioni, salvo la serializzazione binaria. È un po 'pragmatico, ma funziona. :-)


E come si prepara l'oggetto nello stato previsto se non c'è setter e non si desidera chiamare i suoi metodi pubblici per configurarlo?
Piotr Perak,

Ho scritto un piccolo strumento per quello. Carica una classe per riflessione che crea una nuova entità usando il suo costruttore pubblico (in genere prendendo solo l'identificatore) e invoca una matrice di Azione <TEntità> su di essa, salvando un'istantanea dopo ogni operazione (con un nome convenzionale basato sull'indice dell'azione e il nome dell'entità). Lo strumento viene eseguito manualmente ad ogni refactoring del codice entità e le istantanee vengono monitorate dal DCVS. Ovviamente ogni azione chiama un comando pubblico dell'entità, ma questo viene fatto dalle prove che in questo modo sono veramente unit test.
Giacomo Tesio,

Non capisco come questo cambi qualcosa. Se chiama ancora metodi pubblici su sut (sistema sotto test), allora non è diverso, quindi chiama semplicemente quei metodi nel test.
Piotr Perak,

Dopo aver prodotto le istantanee, vengono archiviate in file. Ciascun test non dipende dalla sequenza delle operazioni richieste per ottenere lo stato iniziale dell'entità, ma dallo stato stesso (caricato dall'istantanea). Il metodo in esame stesso viene quindi isolato dalle modifiche agli altri metodi.
Giacomo Tesio,

Cosa succede quando qualcuno modifica il metodo pubblico utilizzato per preparare lo stato serializzato per i test ma si dimentica di eseguire lo strumento per rigenerare l'oggetto serializzato? I test sono ancora verdi anche se c'è un errore nel codice. Comunque dico che questo non cambia nulla. Esegui ancora metodi pubblici, quindi imposta gli oggetti testati. Ma li esegui molto prima che vengano eseguiti i test.
Piotr Perak,

-7

Tu dici

cerca di applicare le migliori pratiche quando possibile

e

l'ORM che stiamo usando per idratare gli oggetti usa la riflessione in modo da poter accedere ai setter privati

e devo pensare che usare la riflessione per bypassare i controlli di accesso sulle tue classi non è ciò che definirei "best practice". Sarà anche terribilmente lento.


Personalmente, eliminerei il tuo framework di unit test e andrei con qualcosa nella classe - sembra che tu stia scrivendo test dal punto di vista dell'intera classe comunque, il che è buono. In passato, per alcuni componenti complicati che necessitavano di test, ho incorporato gli asserti e il codice di configurazione nella classe stessa (era un modello di progettazione comune per avere un metodo test () in ogni classe), quindi si creava un client che crea semplicemente un'istanza di un oggetto e chiama il metodo di prova che può essere impostato a piacere senza cattiverie come gli hack di riflessione.

Se sei preoccupato per il bloat del codice, basta avvolgere i metodi di test in #ifdefs per renderli disponibili solo nel codice di debug (probabilmente una buona pratica stessa)


4
-1: Eliminare il framework di test e tornare ai metodi di test all'interno della classe tornerebbe all'età oscura dei test unitari.
Robert Johnson,

9
No -1 da parte mia, ma includere il codice di prova in produzione è generalmente una cosa negativa (TM) .
Peter K.

cos'altro fa l'OP? Hai intenzione di fregare con setter privati ​​?! È come scegliere quale veleno vuoi bere. Il mio suggerimento all'OP era di mettere il test unitario nel codice di debug, non in produzione. Nella mia esperienza, mettere i test unitari in un progetto diverso significa solo che il progetto è comunque strettamente legato all'originale, quindi da un sviluppatore PoV, c'è poca distinzione.
gbjbaanb,
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.