Ecco una classe che non puoi testare senza un database live e un server SMTP funzionante:
| |
OrderService costruisce da sé SqlOrderRepository e SmtpEmailSender con new, connection string compresa. Ogni test che tocca PlaceOrder richiede un vero database Orders e un vero server di posta, altrimenti fallisce prima ancora che la tua assertion venga eseguita. La logica che vuoi testare sta in due righe: salva l’ordine, invia la conferma. Ma è saldata a due classi concrete che non puoi sostituire.
Cosa significa dependency injection in pratica
La dependency injection consiste nel passare a una classe gli oggetti di cui ha bisogno, invece di lasciare che se li costruisca da sola. La classe dichiara da cosa dipende. Il codice esterno alla classe decide quale implementazione concreta riceve. Container e lifetime sono gli strumenti costruiti attorno a questa singola idea.
Riscrivi OrderService in modo che riceva le sue dipendenze tramite il costruttore, come interfacce invece che come classi concrete:
| |
OrderService non sa più se il repository scrive su SQL Server o su una lista in memoria, né se l’email parte via SMTP o non parte affatto. Conosce due interfacce e ne chiama i metodi. Chi crea l’OrderService decide cosa collegare: un test passa dei fake, l’applicazione web passa l’implementazione reale.
Questa si chiama constructor injection, ed è la forma che userai per quasi tutto. La property injection (impostare una proprietà pubblica dopo la costruzione) e la method injection (passare una dipendenza al singolo metodo che ne ha bisogno) esistono per le dipendenze opzionali e i valori una tantum. Il container integrato di ASP.NET Core non supporta affatto la property injection. La regola è semplice: se una classe non può fare il suo lavoro senza qualcosa, quel qualcosa va nel costruttore.
Come la dependency injection rende una classe facile da testare
Con le dipendenze che arrivano come parametri a interfaccia, un test può passare a OrderService quello che vuole:
| |
Il test gira in millisecondi e non tocca né database né rete. Si colloca all’estremità unitaria dello spettro descritto in Test Unitari vs Test di Integrazione vs Test End-to-End: una classe, i collaboratori sostituiti, un comportamento verificato. Queste classi scritte a mano sono fake, piccole implementazioni funzionanti con un proprio stato. Una libreria di mocking come Moq o NSubstitute genera lo stesso tipo di sostituto per te, così non devi scrivere una classe per ogni interfaccia.
Cosa fa un container DI, e perché non ne hai sempre bisogno
Niente di quanto visto finora richiedeva un framework. Puoi collegare a mano le dipendenze reali nell’entry point della tua applicazione:
| |
Questo si chiama “pure DI” (o “DI del poveraccio”), e per un’app piccola basta e avanza. Lo schema è passare le dipendenze dall’esterno. Un container automatizza il collegamento, e inizia a ripagarsi quando hai decine di classi con dipendenze che si sovrappongono, e cambiare un costruttore significa modificare il codice di composizione in cinque punti diversi.
ASP.NET Core ha un container integrato. Registri ogni interfaccia contro un’implementazione una sola volta, all’avvio, e il framework risolve il grafo ogni volta che costruisce un controller, un handler di una minimal API o qualsiasi altro servizio registrato:
| |
SqlOrderRepository e SmtpEmailSender richiedono una connection string e un host name, non un costruttore senza parametri, quindi ogni registrazione è una factory: una lambda che riceve il container (sp) e restituisce l’istanza. Quando nessuna delle due implementazioni ha bisogno di un valore del genere, la forma con due argomenti di tipo è più breve: AddScoped<IOrderRepository, SqlOrderRepository>().
Registra OrderService stesso allo stesso modo (builder.Services.AddScoped<OrderService>()) e potrai richiederlo nel costruttore di un controller. Il container lo costruisce, risolve IOrderRepository e IEmailSender a partire da queste registrazioni, e tu non chiami mai new.
I lifetime dei servizi in ASP.NET Core: singleton, scoped, transient
AddScoped, AddTransient e AddSingleton si registrano allo stesso modo; il nome del metodo è il lifetime, e scegliere quello sbagliato causa la maggior parte dei bug di DI in ASP.NET Core.
| Lifetime | Nuova istanza creata | Uso tipico |
|---|---|---|
Transient | Ogni volta che viene richiesta | Servizi stateless ed economici (un formatter, un validator) |
Scoped | Una volta per ogni richiesta HTTP | Tutto ciò che è legato alla richiesta corrente: un DbContext, una unit of work |
Singleton | Una sola volta, per tutta la vita dell’app | Stato condiviso e thread-safe: configurazione, un connection pool, una cache |
La registrazione AddScoped qui sopra dà a ogni richiesta il proprio SqlOrderRepository. Se il repository incapsula un DbContext, due richieste concorrenti non potranno mai condividere una transazione a metà. La registrazione AddTransient crea un nuovo SmtpEmailSender a ogni punto di injection. Va bene così, perché il sender non mantiene alcuno stato legato alla singola richiesta.
Captive dependency: un servizio scoped intrappolato in un singleton
Questa è la regola sui lifetime che morde. Un servizio può dipendere in sicurezza solo da cose che vivono quanto lui, o più a lungo. Un singleton può ricevere altri singleton. Un servizio scoped può ricevere scoped o singleton. Un transient può ricevere qualsiasi cosa. Il fallimento si ha quando un singleton riceve nel costruttore una dipendenza scoped:
| |
Il container costruisce ReportCache una sola volta, e l’AppDbContext che ha ricevuto resta in quel campo per tutta la vita dell’applicazione. Questa è una captive dependency. Ogni richiesta successiva condivide un unico DbContext, una classe che non è thread-safe né pensata per vivere a lungo. Lo vedi sotto forma di letture obsolete, oppure di InvalidOperationException che si lamentano perché una seconda operazione è partita su questo context prima che la precedente fosse completata.
ASP.NET Core intercetta il problema in sviluppo. Con la scope validation attiva, che è il comportamento predefinito nell’ambiente Development, risolvere un singleton che ha bisogno di un servizio scoped lancia una InvalidOperationException all’avvio, invece di corrompere i dati in produzione. Se un singleton ha davvero bisogno ogni tanto di un servizio scoped, inietta IServiceScopeFactory, crea uno scope dove ti serve, e risolvi il servizio da quello scope invece di tenerlo in un campo.
Anti-pattern della dependency injection: service locator e costruttori sovraccarichi
Service locator travestito. Iniettare il container stesso ed estrarne le dipendenze dentro un metodo sembra DI, ma butta via esattamente quello che la DI ti dà:
| |
Il costruttore non dice più di cosa ha bisogno OrderService. Devi leggere ogni metodo per scoprirlo, e una registrazione mancante emerge come un null nel mezzo di PlaceOrder, invece che come un errore nel momento in cui la classe viene costruita. Inietta le interfacce specifiche che usi, non l’oggetto che le distribuisce.
Il costruttore con dodici parametri. Una classe che ha bisogno di IOrderRepository, IEmailSender, IInventoryService, IPaymentGateway, IAuditLogger e altri sette è una classe che fa troppe cose. Non è stata la DI a causarlo; lo ha solo reso visibile. È lo stesso sintomo descritto in Cos’è lo Spaghetti Code e Come Rifattorizzarlo. Dividi la classe per responsabilità e il costruttore si riduce da solo. Raggruppare le dodici dipendenze in un unico oggetto “services” nasconde il problema senza risolverlo.
Entrambi passano facilmente la code review. Smettono di passare inosservati la prima volta che qualcuno prova a scrivere un unit test per quella classe.
Da dove iniziare con la dependency injection
Scegli una classe nel tuo codebase che chiama new su un client di database, un client HTTP o un mail sender. Cambia il suo costruttore in modo che riceva un’interfaccia, collega a mano l’implementazione reale in Program.cs, e scrivi un test che passi un fake. In un’app ASP.NET Core, salta il collegamento manuale e registrala in builder.Services, dato che il container è già lì. Scegli il lifetime in modo deliberato per tutto ciò che mantiene uno stato, e lascia attiva la scope validation, così il framework intercetta una captive dependency prima dei tuoi utenti.