Scelta di una strategia di test

Come illustrato in Panoramica, una decisione di base da prendere è se i test coinvolgeranno il sistema di database di produzione, proprio come fa l'applicazione, o se i test verranno eseguiti su un doppio test, che sostituisce il sistema di database di produzione.

Il test su una risorsa esterna reale, anziché sostituirlo con un test double, può comportare le difficoltà seguenti:

  1. In molti casi, non è possibile o pratico testare la risorsa esterna effettiva. Ad esempio, l'applicazione può interagire con un servizio che non può essere facilmente testato su (a causa della limitazione della frequenza o della mancanza di un ambiente di test).
  2. Anche quando è possibile coinvolgere la risorsa esterna reale, questo potrebbe essere estremamente lento: l'esecuzione di una grande quantità di test su un servizio cloud può causare tempi troppo lunghi per i test. I test devono far parte del flusso di lavoro quotidiano dello sviluppatore, quindi è importante che i test vengano eseguiti rapidamente.
  3. L'esecuzione di test su una risorsa esterna può comportare problemi di isolamento, in cui i test interferiscono tra loro. Ad esempio, più test in esecuzione in parallelo su un database possono modificare i dati e causare l'esito negativo in diversi modi. L'uso di un test double evita questo problema, perché ogni test viene eseguito su una propria risorsa in memoria ed è quindi naturalmente isolato da altri test.

Tuttavia, i test che superano un test double non garantiscono che il programma funzioni durante l'esecuzione sulla risorsa esterna reale. Ad esempio, un test di database double può eseguire confronti tra stringhe con distinzione tra maiuscole e minuscole, mentre il sistema di database di produzione esegue confronti senza distinzione tra maiuscole e minuscole. Questi problemi vengono rilevati solo quando i test vengono eseguiti sul database di produzione reale, rendendo questi test una parte importante di qualsiasi strategia di test.

Il test sul database può essere più semplice di quanto sembri

A causa delle difficoltà precedenti con i test su un database reale, gli sviluppatori sono spesso invitati a usare i doppi test per primi e hanno un gruppo di test affidabile che possono essere eseguiti frequentemente nei propri computer; i test che coinvolgono il database, al contrario, dovrebbero essere eseguiti molto meno frequentemente, e in molti casi forniscono anche molto meno copertura. È consigliabile dare più pensiero a quest'ultimo e suggerire che i database potrebbero effettivamente essere molto meno interessati dai problemi precedenti rispetto a quelli che le persone tendono a pensare:

  1. La maggior parte dei database può essere attualmente installata facilmente nel computer dello sviluppatore. Le tecnologie basate su contenitori, ad esempio Docker, possono semplificare questa operazione e tecnologie come Github Workspaces e Dev Container configurano l'intero ambiente di sviluppo (incluso il database). Quando si usa SQL Server, è anche possibile eseguire test su LocalDB in Windows o configurare facilmente un'immagine Docker in Linux.
  2. Il test su un database locale, con un set di dati di test ragionevole, è in genere estremamente veloce: la comunicazione è completamente locale e i dati di test vengono in genere memorizzati nel buffer in memoria sul lato database. EF Core contiene oltre 30.000 test solo su SQL Server; questi vengono eseguiti in modo affidabile in pochi minuti, eseguiti in CI per ogni singolo commit e vengono eseguiti molto frequentemente dagli sviluppatori in locale. Alcuni sviluppatori si rivolgono a un database in memoria (un "falso") nella convinzione che questo sia necessario per la velocità - questo è quasi mai in realtà il caso.
  3. L'isolamento è in effetti un ostacolo durante l'esecuzione di test su un database reale, in quanto i test possono modificare i dati e interferire tra loro. Esistono tuttavia varie tecniche per fornire l'isolamento negli scenari di test del database; ci concentriamo su questi elementi in Test sul sistema di database di produzione.

Il precedente non è destinato a disparere i doppi test o a discutere contro l'uso di essi. Per un aspetto, i test double sono necessari per alcuni scenari che non possono essere testati in altro modo, ad esempio simulando un errore del database. Tuttavia, nell'esperienza, gli utenti spesso si allontanano dal test sul database per i motivi precedenti, credendo che sia lento, difficile o inaffidabile, quando questo non è necessariamente il caso. Il test sul sistema di database di produzione mira a risolvere questo problema, fornendo linee guida ed esempi per la scrittura di test rapidi e isolati sul database.

Tipi diversi di test double

Test doubles è un termine ampio che comprende approcci molto diversi. Questa sezione illustra alcune tecniche comuni che coinvolgono i doppi test per il test delle applicazioni EF Core:

  1. Usare SQLite (in modalità in memoria) come database falso, sostituendo il sistema di database di produzione.
  2. Usare il provider in memoria di EF Core come database falso, sostituendo il sistema di database di produzione.
  3. Simulare o stub e DbContext DbSet.
  4. Introdurre un livello di repository tra EF Core e il codice dell'applicazione e simulare o stub tale livello.

Di seguito si esaminerà il significato di ogni metodo e lo si confronterà con gli altri. È consigliabile leggere i diversi metodi per ottenere una comprensione completa di ognuno di essi. Se si è deciso di scrivere test che non coinvolgono il sistema di database di produzione, un livello del repository è l'unico approccio che consente lo stubing completo e affidabile del livello dati. Tuttavia, questo approccio ha un costo significativo in termini di implementazione e manutenzione.

SQLite come database falso

Un possibile approccio di test consiste nel scambiare il database di produzione (e.g. SQL Server) con SQLite, usandolo in modo efficace come test "falso". Oltre alla facilità di configurazione, SQLite ha una funzionalità di database in memoria particolarmente utile per i test: ogni test è naturalmente isolato nel proprio database in memoria e non è necessario gestire file effettivi.

Tuttavia, prima di eseguire questa operazione, è importante comprendere che in EF Core i diversi provider di database si comportano in modo diverso: EF Core non tenta di astrarre ogni aspetto del sistema di database sottostante. Fondamentalmente, questo significa che il test su SQLite non garantisce gli stessi risultati di SQL Server o di qualsiasi altro database. Ecco alcuni esempi di possibili differenze comportamentali:

  • La stessa query LINQ può restituire risultati diversi in provider diversi. Ad esempio, SQL Server esegue il confronto tra stringhe senza distinzione tra maiuscole e minuscole per impostazione predefinita, mentre SQLite fa distinzione tra maiuscole e minuscole. Questo può fare in modo che i test vengano superati rispetto a SQLite in cui potrebbero non riuscire rispetto a SQL Server (o viceversa).
  • Alcune query che funzionano su SQL Server semplicemente non sono supportate in SQLite, perché il supporto SQL esatto in questi due database è diverso.
  • Se la query usa un metodo specifico del EF.Functions.DateDiffDayprovider, ad esempio SQL Server, la query avrà esito negativo in SQLite e non può essere testata.
  • SQL non elaborato può funzionare oppure potrebbe non riuscire o restituire risultati diversi, a seconda esattamente di ciò che viene eseguito. I dialetti SQL sono diversi in molti modi tra i database.

Rispetto all'esecuzione di test sul sistema di database di produzione, è relativamente facile iniziare a usare SQLite e molti utenti lo fanno. Sfortunatamente, le limitazioni precedenti tendono a diventare problematiche durante il test delle applicazioni EF Core, anche se non sembrano essere all'inizio. Di conseguenza, è consigliabile scrivere i test sul database reale oppure se si usa un test double è una necessità assoluta, eseguire l'onboarding del costo di un modello di repository come illustrato di seguito.

Per informazioni su come usare SQLite per i test, vedere questa sezione.

In memoria come database falso

In alternativa a SQLite, EF Core include anche un provider in memoria. Anche se questo provider è stato originariamente progettato per supportare test interni di EF Core stesso, alcuni sviluppatori lo usano come database falso durante il test di applicazioni EF Core. Questa operazione è estremamente sconsigliata: poiché un database falso, in memoria presenta gli stessi problemi di SQLite (vedere sopra), ma presenta anche le limitazioni aggiuntive seguenti:

  • Il provider in memoria supporta in genere meno tipi di query rispetto al provider SQLite, poiché non è un database relazionale. Più query avranno esito negativo o si comportano in modo diverso rispetto al database di produzione.
  • Le transazioni non sono supportate.
  • SQL non elaborato non è completamente supportato. Confrontarlo con SQLite, dove è possibile usare SQL non elaborato, purché SQL funzioni nello stesso modo in SQLite e nel database di produzione.
  • Il provider in memoria non è stato ottimizzato per le prestazioni e in genere funzionerà più lentamente di SQLite in modalità in memoria (o anche il sistema di database di produzione).

In sintesi, in memoria ha tutti gli svantaggi di SQLite, insieme ad alcuni altri, e non offre alcun vantaggio in cambio. Se si sta cercando un semplice database in memoria falso, usare SQLite invece del provider in memoria; è tuttavia consigliabile usare il modello di repository come descritto di seguito.

Per informazioni su come usare in memoria per i test, vedere questa sezione.

Simulazione o stubing di DbContext e DbSet

Questo approccio usa in genere un framework fittizio per creare un doppio di test di DbContext e DbSete e i test su tali valori double. La simulazione DbContext può essere un buon approccio per testare varie funzionalità non di query , ad esempio le chiamate a Add o SaveChanges(), consentendo di verificare che il codice li chiami in scenari di scrittura.

Tuttavia, non è possibile simulare DbSet correttamente la funzionalità di query , poiché le query vengono espresse tramite operatori LINQ, che sono chiamate al metodo di estensione statico su IQueryable. Di conseguenza, quando alcune persone parlano di "simulazione DbSet", ciò che significa realmente è che creano un DbSet oggetto supportato da una raccolta in memoria e quindi valutano gli operatori di query rispetto a quella raccolta in memoria, proprio come un semplice IEnumerable. Invece di una simulazione, si tratta in realtà di una sorta di falso, in cui la raccolta in memoria sostituisce il database reale.

Poiché solo l'oggetto DbSet stesso è falso e la query viene valutata in memoria, questo approccio finisce per essere molto simile all'uso del provider in memoria di EF Core: entrambe le tecniche eseguono operatori di query in .NET su una raccolta in memoria. Di conseguenza, questa tecnica subisce gli stessi svantaggi: le query si comportano in modo diverso (ad esempio, distinzione tra maiuscole e minuscole) o semplicemente avranno esito negativo (ad esempio a causa di metodi specifici del provider), SQL non elaborato non funzionerà e le transazioni verranno ignorate al meglio. Di conseguenza, questa tecnica dovrebbe essere in genere evitata per testare qualsiasi codice di query.

Schema Repository

Gli approcci precedenti hanno tentato di scambiare il provider di database di produzione di EF Core con un provider di test fittizio o di creare un DbSet oggetto supportato da una raccolta in memoria. Queste tecniche sono simili in quanto valutano ancora le query LINQ del programma, in SQLite o in memoria, ed è in definitiva l'origine delle difficoltà descritte in precedenza: una query progettata per l'esecuzione su un database di produzione specifico non può essere eseguita in modo affidabile altrove senza problemi.

Per un test corretto e affidabile, è consigliabile introdurre un livello di repository che media tra il codice dell'applicazione e EF Core. L'implementazione di produzione del repository contiene le query LINQ effettive e le esegue tramite EF Core. Durante il test, l'astrazione del repository viene direttamente stubata o fittizia senza dover eseguire query LINQ effettive, rimuovendo ef Core dallo stack di test completamente e consentendo ai test di concentrarsi solo sul codice dell'applicazione.

Il diagramma seguente confronta l'approccio falso del database (SQLite/in memoria) con il modello di repository:

Confronto tra provider falsi con il modello di repository

Poiché le query LINQ non fanno più parte del test, è possibile fornire direttamente i risultati delle query all'applicazione. In un altro modo, gli approcci precedenti consentono approssimativamente lo stub degli input di query (ad esempio sostituendo le tabelle di SQL Server con quelle in memoria), ma quindi eseguono comunque gli operatori di query effettivi in memoria. Il modello di repository, al contrario, consente di stubre direttamente gli output delle query, consentendo di eseguire unit test molto più potenti e mirati. Si noti che per il funzionamento del repository, il repository non può esporre metodi di restituzione IQueryable, perché ancora una volta non possono essere stub. IEnumerable deve invece essere restituito.

Tuttavia, poiché il modello di repository richiede l'incapsulamento di ogni query LINQ (testable) in un metodo di restituzione IEnumerable, impone un livello architetturale aggiuntivo per l'applicazione e può comportare costi significativi per implementare e gestire. Questo costo non deve essere scontato quando si fa una scelta su come testare un'applicazione, in particolare dato che è probabile che i test sul database reale siano ancora necessari per le query esposte dal repository.

Vale la pena notare che i repository presentano vantaggi al di fuori del semplice test. Garantiscono che tutto il codice di accesso ai dati sia concentrato in un'unica posizione anziché essere distribuito nell'applicazione e, se l'applicazione deve supportare più di un database, l'astrazione del repository può essere molto utile per modificare le query tra provider.

Per un esempio che mostra il test con un repository, vedere questa sezione.

Confronto complessivo

La tabella seguente fornisce una visualizzazione rapida e comparativa delle diverse tecniche di test e mostra le funzionalità che è possibile testare con quale approccio:

Funzionalità In-memory SQLite in memoria Mock DbContext Schema Repository Test sul database
Tipo double di test Falsificare Falsificare Falsificare Mock/stub Reale, nessun doppio
SQL non elaborato? No Dipende da No
Transazioni? No (ignorato)
Traduzioni specifiche del provider? No No No
Comportamento esatto delle query? Dipende da Dipende da Dipende da
È possibile usare LINQ ovunque nell'applicazione? No*

* Tutte le query LINQ di database testabili devono essere incapsulate nei metodi del repository IEnumerable-returning, in modo da essere stub/fittizi.

Riepilogo

  • È consigliabile che gli sviluppatori abbiano una buona copertura di test dell'applicazione in esecuzione nel sistema di database di produzione effettivo. Ciò garantisce che l'applicazione funzioni effettivamente nell'ambiente di produzione e con una progettazione corretta, i test possono essere eseguiti in modo affidabile e rapido. Poiché in ogni caso questi test sono necessari, è consigliabile iniziare e, se necessario, aggiungere test usando i test raddoppia in un secondo momento, in base alle esigenze.
  • Se si è deciso di usare un test double, è consigliabile implementare il modello di repository, che consente di eseguire lo stub o di simulare il livello di accesso ai dati sopra EF Core, anziché usare un provider EF Core fittizio (Sqlite/in memoria) o simulando DbSet.
  • Se il modello di repository non è un'opzione valida per qualche motivo, prendere in considerazione l'uso di database SQLite in memoria.
  • Evitare il provider in memoria a scopo di test: questo è sconsigliato e supportato solo per le applicazioni legacy.
  • Evitare di simulare DbSet per scopi di query.