Una introducción práctica a la inyección de dependencia
Publicado: 2022-03-10El concepto de inyección de dependencia es, en esencia, una noción fundamentalmente simple. Sin embargo, comúnmente se presenta junto con los conceptos más teóricos de inversión de control, inversión de dependencia, los principios SOLID, etc. Para que sea lo más fácil posible para usted comenzar a usar la Inyección de dependencia y comenzar a cosechar sus beneficios, este artículo se mantendrá en el lado práctico de la historia, mostrando ejemplos que muestran precisamente los beneficios de su uso, de una manera principalmente divorciado de la teoría asociada.
Pasaremos muy poco tiempo discutiendo los conceptos académicos que rodean la inyección de dependencia aquí, ya que la mayor parte de esa explicación se reservará para el segundo artículo de esta serie. De hecho, se pueden escribir y se han escrito libros completos que brindan un tratamiento más profundo y riguroso de los conceptos.
Aquí, comenzaremos con una explicación simple, pasaremos a algunos ejemplos más del mundo real y luego analizaremos algunos antecedentes. Otro artículo (después de este) discutirá cómo la inyección de dependencia encaja en el ecosistema general de aplicación de patrones arquitectónicos de mejores prácticas.
Una explicación sencilla
“Inyección de dependencia” es un término demasiado complejo para un concepto extremadamente simple. En este punto, algunas preguntas sabias y razonables serían "¿cómo defines 'dependencia'?", "¿qué significa que una dependencia sea 'inyectada'?", "¿puedes inyectar dependencias de diferentes maneras?" y "¿por qué es esto útil?" Es posible que no crea que un término como "Inyección de dependencia" se puede explicar en dos fragmentos de código y un par de palabras, pero, por desgracia, se puede.
La forma más sencilla de explicar el concepto es mostrándote.
Esto, por ejemplo, no es una inyección de dependencia:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
Pero esto es inyección de dependencia:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
Hecho. Eso es todo. Frio. El fin.
¿Qué cambió? En lugar de permitir que la clase Car
cree una instancia de Engine
(como lo hizo en el primer ejemplo), en el segundo ejemplo, Car
tenía una instancia de Engine
pasada, o inyectada , desde un nivel superior de control a su constructor. Eso es todo. En esencia, esto es todo lo que es la inyección de dependencia: el acto de inyectar (pasar) una dependencia a otra clase o función. Cualquier otra cosa que involucre la noción de inyección de dependencia es simplemente una variación de este concepto fundamental y simple. Dicho de manera trivial, la inyección de dependencia es una técnica mediante la cual un objeto recibe otros objetos de los que depende, llamados dependencias, en lugar de crearlos él mismo.
En general, para definir qué es una “dependencia”, si alguna clase A
usa la funcionalidad de una clase B
, entonces B
es una dependencia para A
, o, en otras palabras, A
tiene una dependencia de B
Por supuesto, esto no se limita a clases y también se mantiene para funciones. En este caso, la clase Car
tiene una dependencia de la clase Engine
, o Engine
es una dependencia de Car
. Las dependencias son simplemente variables, como la mayoría de las cosas en la programación.
La inyección de dependencia se usa ampliamente para admitir muchos casos de uso, pero quizás el uso más evidente es permitir pruebas más sencillas. En el primer ejemplo, no podemos simular fácilmente el engine
porque la clase Car
lo instancia. El motor real siempre se está utilizando. Pero, en el último caso, tenemos control sobre el Engine
que se utiliza, lo que significa que, en una prueba, podemos subclasificar el Engine
y anular sus métodos.
Por ejemplo, si quisiéramos ver qué hace Car.startEngine()
si engine.fireCylinders()
arroja un error, simplemente podríamos crear una clase FakeEngine
, hacer que amplíe la clase Engine
y luego anular fireCylinders
para que arroje un error. . En la prueba, podemos inyectar ese objeto FakeEngine
en el constructor de Car
. Dado que FakeEngine
es un Engine
por implicación de la herencia, se cumple el sistema de tipos de TypeScript. El uso de la herencia y la anulación de métodos no sería necesariamente la mejor manera de hacer esto, como veremos más adelante, pero ciertamente es una opción.
Quiero dejar muy, muy claro que lo que ves arriba es la noción central de inyección de dependencia. Un Car
, por sí solo, no es lo suficientemente inteligente como para saber qué motor necesita. Solo los ingenieros que construyen el automóvil comprenden los requisitos de sus motores y ruedas. Por lo tanto, tiene sentido que las personas que construyen el automóvil proporcionen el motor específico requerido, en lugar de dejar que el propio Car
elija el motor que desea usar.
Uso la palabra "construir" específicamente porque construyes el automóvil llamando al constructor, que es el lugar donde se inyectan las dependencias. Si el automóvil también creó sus propias llantas además del motor, ¿cómo sabemos que las llantas que se usan son seguras para girar a las RPM máximas que el motor puede generar? Por todas estas razones y más, debería tener sentido, tal vez intuitivamente, que Car
no tenga nada que ver con decidir qué Engine
y qué Wheels
usa. Deben ser proporcionados desde algún nivel superior de control.
En el último ejemplo que muestra la inyección de dependencia en acción, si imagina que Engine
es una clase abstracta en lugar de una concreta, esto debería tener aún más sentido: el automóvil sabe que necesita un motor y sabe que el motor debe tener alguna funcionalidad básica. , pero cómo se maneja ese motor y cuál es su implementación específica está reservado para ser decidido y proporcionado por la pieza de código que crea (construye) el automóvil.
Un ejemplo del mundo real
Vamos a ver algunos ejemplos prácticos más que, con suerte, ayudarán a explicar, de nuevo de manera intuitiva, por qué es útil la inyección de dependencia. Con suerte, al no insistir en lo teórico y pasar directamente a los conceptos aplicables, puede ver más completamente los beneficios que proporciona la inyección de dependencia y las dificultades de la vida sin ella. Volveremos a un tratamiento un poco más "académico" del tema más adelante.
Comenzaremos construyendo nuestra aplicación normalmente, de una manera altamente acoplada, sin utilizar inyección de dependencia o abstracciones, para que veamos las desventajas de este enfoque y la dificultad que agrega a las pruebas. En el camino, refactorizaremos gradualmente hasta que rectifiquemos todos los problemas.
Para comenzar, suponga que se le ha asignado la tarea de crear dos clases: un proveedor de correo electrónico y una clase para una capa de acceso a datos que debe usar algún UserService
. Comenzaremos con el acceso a datos, pero ambos se definen fácilmente:
// UserRepository.ts import { dbDriver } from 'pg-driver'; export class UserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
Nota: El nombre "Repositorio" aquí proviene del "Patrón de repositorio", un método para desacoplar su base de datos de su lógica comercial. Puede obtener más información sobre el patrón de repositorio, pero para los fines de este artículo, simplemente puede considerarlo como una clase que encapsula su base de datos para que, según la lógica comercial, su sistema de almacenamiento de datos se trate simplemente como un sistema en memoria. colección. Explicar completamente el patrón de repositorio está fuera del alcance de este artículo.
Así es como normalmente esperamos que funcionen las cosas, y dbDriver
está codificado dentro del archivo.
En su UserService
, importaría la clase, la instanciaría y comenzaría a usarla:
import { UserRepository } from './UserRepository.ts'; class UserService { private readonly userRepository: UserRepository; public constructor () { // Not dependency injection. this.userRepository = new UserRepository(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send a welcome email // ... } public async findUserById(id: string): Promise<User> { // No need for await here, the promise will be unwrapped by the caller. return this.userRepository.findUserById(id); } }
Una vez más, todo sigue normal.
Un breve aparte: un DTO es un objeto de transferencia de datos: es un objeto que actúa como una bolsa de propiedades para definir una forma de datos estandarizada a medida que se mueve entre dos sistemas externos o dos capas de una aplicación. Puede obtener más información sobre las DTO en el artículo de Martin Fowler sobre el tema, aquí. En este caso, IRegisterUserDto
define un contrato sobre cuál debe ser la forma de los datos a medida que provienen del cliente. Solo tengo dos propiedades: id
y email
. Puede pensar que es peculiar que el DTO que esperamos del cliente para crear un nuevo usuario contenga la ID del usuario aunque aún no hayamos creado un usuario. El ID es un UUID y permito que el cliente lo genere por una variedad de razones, que están fuera del alcance de este artículo. Además, la función findUserById
debería asignar el objeto User
a un DTO de respuesta, pero lo descuidé por brevedad. Finalmente, en el mundo real, no tendría un modelo de dominio User
que contuviera un método fromDto
. Eso no es bueno para la pureza del dominio. Una vez más, su propósito es la brevedad aquí.
A continuación, desea manejar el envío de correos electrónicos. Una vez más, como de costumbre, simplemente puede crear una clase de proveedor de correo electrónico e importarla a su UserService
.
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
Dentro UserService
:
import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor () { // Still not doing dependency injection. this.userRepository = new UserRepository(); this.sendGridEmailProvider = new SendGridEmailProvider(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Ahora tenemos una clase completamente trabajadora, y en un mundo en el que no nos importa la capacidad de prueba o escribir código limpio de ninguna manera en la definición, y en un mundo donde la deuda técnica es inexistente y los molestos administradores de programas no. No establezca plazos, esto está perfectamente bien. Desafortunadamente, ese no es un mundo en el que tengamos el beneficio de vivir.
¿Qué sucede cuando decidimos que debemos migrar fuera de SendGrid para correos electrónicos y usar MailChimp en su lugar? Del mismo modo, ¿qué sucede cuando queremos realizar pruebas unitarias de nuestros métodos? ¿Vamos a utilizar la base de datos real en las pruebas? Peor aún, ¿enviaremos correos electrónicos reales a direcciones de correo electrónico potencialmente reales y también pagaremos por ello?
En el ecosistema de JavaScript tradicional, los métodos de las clases de pruebas unitarias bajo esta configuración están llenos de complejidad y exceso de ingeniería. Las personas traen bibliotecas completas simplemente para proporcionar la funcionalidad de creación de apéndices, lo que agrega todo tipo de capas de direccionamiento indirecto y, lo que es peor, puede acoplar directamente las pruebas a la implementación del sistema bajo prueba, cuando, en realidad, las pruebas nunca deberían saber cómo. el sistema real funciona (esto se conoce como prueba de caja negra). Trabajaremos para mitigar estos problemas mientras discutimos cuál es la responsabilidad real de UserService
y aplicamos nuevas técnicas de inyección de dependencia.
Considere, por un momento, lo que hace un UserService
. El objetivo de la existencia de UserService
es ejecutar casos de uso específicos que involucran a los usuarios: registrarlos, leerlos, actualizarlos, etc. Es una buena práctica que las clases y funciones tengan una sola responsabilidad (SRP: el principio de responsabilidad única), y la responsabilidad de UserService
es manejar las operaciones relacionadas con el usuario. ¿Por qué, entonces, UserService
es responsable de controlar la vida útil de UserRepository
y SendGridEmailProvider
en este ejemplo?
Imagínese si tuviéramos alguna otra clase utilizada por UserService
que abriera una conexión de larga duración. ¿Debería UserService
ser responsable de deshacerse de esa conexión también? Por supuesto que no. Todas estas dependencias tienen una vida útil asociada con ellas: podrían ser singletons, podrían ser transitorias y estar en el ámbito de una solicitud HTTP específica, etc. El control de estas vidas está fuera del alcance de UserService
. Entonces, para resolver estos problemas, inyectaremos todas las dependencias, tal como vimos antes.
import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor ( userRepository: UserRepository, sendGridEmailProvider: SendGridEmailProvider ) { // Yay! Dependencies are injected. this.userRepository = userRepository; this.sendGridEmailProvider = sendGridEmailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
¡Genial! Ahora UserService
recibe objetos previamente instanciados, y cualquier pieza de código que llame y cree un nuevo UserService
es la pieza de código a cargo de controlar la vida útil de las dependencias. Hemos invertido el control de UserService
y lo hemos subido a un nivel superior. Si solo quisiera mostrar cómo podemos inyectar dependencias a través del constructor para explicar el inquilino básico de la inyección de dependencia, podría detenerme aquí. Todavía hay algunos problemas desde la perspectiva del diseño, sin embargo, que cuando se rectifiquen, servirán para que nuestro uso de la inyección de dependencia sea aún más poderoso.
En primer lugar, ¿por qué UserService
sabe que estamos usando SendGrid para correos electrónicos? En segundo lugar, ambas dependencias están en clases concretas: el UserRepository
concreto y el SendGridEmailProvider
concreto. Esta relación es demasiado rígida: estamos obligados a pasar algún objeto que sea UserRepository
y SendGridEmailProvider
.
Esto no es bueno porque queremos que UserService
sea completamente independiente de la implementación de sus dependencias. Al hacer que UserService
sea ciego de esa manera, podemos intercambiar las implementaciones sin afectar el servicio en absoluto; esto significa que, si decidimos migrar fuera de SendGrid y usar MailChimp en su lugar, podemos hacerlo. También significa que si queremos falsificar el proveedor de correo electrónico para las pruebas, también podemos hacerlo.
Lo que sería útil es si pudiéramos definir alguna interfaz pública y obligar a que las dependencias entrantes cumplan con esa interfaz, mientras que UserService
sigue siendo independiente de los detalles de implementación. Dicho de otra manera, necesitamos obligar a UserService
a depender solo de una abstracción de sus dependencias, y no de sus dependencias concretas reales. Podemos hacer eso a través de, bueno, interfaces.
Comience definiendo una interfaz para UserRepository
e impleméntela:
// UserRepository.ts import { dbDriver } from 'pg-driver'; export interface IUserRepository { addUser(user: User): Promise<void>; findUserById(id: string): Promise<User>; existsByEmail(email: string): Promise<boolean>; } export class UserRepository implements IUserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
Y defina uno para el proveedor de correo electrónico, también implementándolo:
// IEmailProvider.ts export interface IEmailProvider { sendWelcomeEmail(to: string): Promise<void>; } // SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; import { IEmailProvider } from './IEmailProvider'; export class SendGridEmailProvider implements IEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
Nota: Este es el patrón de adaptador de los patrones de diseño Gang of Four.
Ahora, nuestro UserService
puede depender de las interfaces en lugar de las implementaciones concretas de las dependencias:
import { IUserRepository } from './UserRepository.ts'; import { IEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: IUserRepository; private readonly emailProvider: IEmailProvider; public constructor ( userRepository: IUserRepository, emailProvider: IEmailProvider ) { // Double yay! Injecting dependencies and coding against interfaces. this.userRepository = userRepository; this.emailProvider = emailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.emailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Si las interfaces son nuevas para usted, esto puede parecer muy, muy complejo. De hecho, el concepto de crear software débilmente acoplado también puede ser nuevo para usted. Piense en los receptáculos de pared. Puede enchufar cualquier dispositivo en cualquier receptáculo siempre que el enchufe encaje en el tomacorriente. Eso es acoplamiento flojo en acción. Su tostadora no está conectada a la pared, porque si lo estuviera y decide actualizar su tostadora, no tendrá suerte. En su lugar, se utilizan puntos de venta, y el punto de venta define la interfaz. De manera similar, cuando conecta un dispositivo electrónico a su tomacorriente de pared, no le preocupa el potencial de voltaje, el consumo máximo de corriente, la frecuencia de CA, etc., solo le importa si el enchufe encaja en el tomacorriente. Puede hacer que un electricista venga y cambie todos los cables detrás de ese tomacorriente, y no tendrá ningún problema para enchufar su tostadora, siempre que ese tomacorriente no cambie. Además, su fuente de electricidad podría cambiarse para que provenga de la ciudad o de sus propios paneles solares y, una vez más, no le importa, siempre y cuando pueda conectarse a ese tomacorriente.
La interfaz es la toma de corriente, que proporciona la funcionalidad "plug-and-play". En este ejemplo, el cableado en la pared y la fuente de electricidad son similares a las dependencias y su tostadora es similar a UserService
(depende de la electricidad): la fuente de electricidad puede cambiar y la tostadora aún funciona bien y no necesita ser tocado, porque la salida, actuando como interfaz, define los medios estándar para que ambos se comuniquen. De hecho, se podría decir que el tomacorriente actúa como una “abstracción” del cableado de la pared, los disyuntores, la fuente eléctrica, etc.
Es un principio común y bien considerado del diseño de software, por las razones anteriores, codificar contra interfaces (abstracciones) y no implementaciones, que es lo que hemos hecho aquí. Al hacerlo, tenemos la libertad de intercambiar implementaciones como queramos, ya que esas implementaciones están ocultas detrás de la interfaz (al igual que el cableado de la pared está oculto detrás de la toma de corriente), por lo que la lógica comercial que usa la dependencia nunca tiene que cambiar siempre y cuando la interfaz nunca cambie. Recuerde, UserService
solo necesita saber qué funcionalidad ofrecen sus dependencias , no cómo se admite esa funcionalidad detrás de escena . Es por eso que el uso de interfaces funciona.
Estos dos cambios simples de utilizar interfaces e inyectar dependencias marcan la diferencia en el mundo cuando se trata de crear software débilmente acoplado y resuelve todos los problemas con los que nos encontramos anteriormente.
Si decidimos mañana que queremos confiar en Mailchimp para los correos electrónicos, simplemente creamos una nueva clase de Mailchimp que respeta la interfaz IEmailProvider
y la inyectamos en lugar de SendGrid. La clase UserService
real nunca tiene que cambiar a pesar de que acabamos de hacer un cambio enorme en nuestro sistema al cambiar a un nuevo proveedor de correo electrónico. La belleza de estos patrones es que UserService
permanece felizmente inconsciente de cómo funcionan detrás de escena las dependencias que utiliza. La interfaz sirve como límite arquitectónico entre ambos componentes, manteniéndolos adecuadamente desacoplados.
Además, cuando se trata de pruebas, podemos crear falsificaciones que cumplan con las interfaces e inyectarlas en su lugar. Aquí puede ver un repositorio falso y un proveedor de correo electrónico falso.
// Both fakes: class FakeUserRepository implements IUserRepository { private readonly users: User[] = []; public async addUser(user: User): Promise<void> { this.users.push(user); } public async findUserById(id: string): Promise<User> { const userOrNone = this.users.find(u => u.id === id); return userOrNone ? Promise.resolve(userOrNone) : Promise.reject(new NotFoundError()); } public async existsByEmail(email: string): Promise<boolean> { return Boolean(this.users.find(u => u.email === email)); } public getPersistedUserCount = () => this.users.length; } class FakeEmailProvider implements IEmailProvider { private readonly emailRecipients: string[] = []; public async sendWelcomeEmail(to: string): Promise<void> { this.emailRecipients.push(to); } public wasEmailSentToRecipient = (recipient: string) => Boolean(this.emailRecipients.find(r => r === recipient)); }
Tenga en cuenta que ambas falsificaciones implementan las mismas interfaces que UserService
espera que cumplan sus dependencias. Ahora, podemos pasar estas falsificaciones a UserService
en lugar de las clases reales y UserService
no se dará cuenta; los usará como si fueran reales. La razón por la que puede hacer eso es porque sabe que todos los métodos y propiedades que quiere usar en sus dependencias existen y son accesibles (porque implementan las interfaces), que es todo lo que UserService
necesita saber (es decir, no cómo funcionan las dependencias).
Inyectaremos estos dos durante las pruebas, y hará que el proceso de prueba sea mucho más fácil y mucho más directo de lo que podría estar acostumbrado cuando se trata de bibliotecas de simulación y creación de apéndices exageradas, trabajando con el propio interno de Jest. herramientas, o tratando de parche de mono.
Aquí hay pruebas reales usando las falsificaciones:
// Fakes let fakeUserRepository: FakeUserRepository; let fakeEmailProvider: FakeEmailProvider; // SUT let userService: UserService; // We want to clean out the internal arrays of both fakes // before each test. beforeEach(() => { fakeUserRepository = new FakeUserRepository(); fakeEmailProvider = new FakeEmailProvider(); userService = new UserService(fakeUserRepository, fakeEmailProvider); }); // A factory to easily create DTOs. // Here, we have the optional choice of overriding the defaults // thanks to the built in `Partial` utility type of TypeScript. function createSeedRegisterUserDto(opts?: Partial<IRegisterUserDto>): IRegisterUserDto { return { id: 'someId', email: '[email protected]', ...opts }; } test('should correctly persist a user and send an email', async () => { // Arrange const dto = createSeedRegisterUserDto(); // Act await userService.registerUser(dto); // Assert const expectedUser = User.fromDto(dto); const persistedUser = await fakeUserRepository.findUserById(dto.id); const wasEmailSent = fakeEmailProvider.wasEmailSentToRecipient(dto.email); expect(persistedUser).toEqual(expectedUser); expect(wasEmailSent).toBe(true); }); test('should reject with a DuplicateEmailError if an email already exists', async () => { // Arrange const existingEmail = '[email protected]'; const dto = createSeedRegisterUserDto({ email: existingEmail }); const existingUser = User.fromDto(dto); await fakeUserRepository.addUser(existingUser); // Act, Assert await expect(userService.registerUser(dto)) .rejects.toBeInstanceOf(DuplicateEmailError); expect(fakeUserRepository.getPersistedUserCount()).toBe(1); }); test('should correctly return a user', async () => { // Arrange const user = User.fromDto(createSeedRegisterUserDto()); await fakeUserRepository.addUser(user); // Act const receivedUser = await userService.findUserById(user.id); // Assert expect(receivedUser).toEqual(user); });
Notarás algunas cosas aquí: Las falsificaciones escritas a mano son muy simples. No hay complejidad en los marcos burlones que solo sirven para ofuscar. Todo está hecho a mano y eso significa que no hay magia en el código base. El comportamiento asíncrono se falsifica para que coincida con las interfaces. Utilizo async/await en las pruebas, aunque todo el comportamiento es sincrónico porque creo que coincide más con la forma en que esperaría que funcionaran las operaciones en el mundo real y porque al agregar async/await, puedo ejecutar este mismo conjunto de pruebas. contra las implementaciones reales también, además de las falsificaciones, por lo que se requiere una gestión asincrónica adecuada. De hecho, en la vida real, probablemente ni siquiera me preocuparía por simular la base de datos y, en su lugar, usaría una base de datos local en un contenedor Docker hasta que hubiera tantas pruebas que tuviera que simular el rendimiento. Luego podría ejecutar las pruebas de base de datos en memoria después de cada cambio y reservar las pruebas de base de datos locales reales justo antes de realizar los cambios y en el servidor de compilación en la canalización de CI/CD.
En la primera prueba, en la sección "arreglar", simplemente creamos el DTO. En la sección "actuar", llamamos al sistema bajo prueba y ejecutamos su comportamiento. Las cosas se vuelven un poco más complejas cuando se hacen afirmaciones. Recuerde, en este punto de la prueba, ni siquiera sabemos si el usuario se guardó correctamente. Entonces, definimos cómo esperamos que se vea un usuario persistente, y luego llamamos al Repositorio falso y le pedimos un usuario con la ID que esperamos. Si UserService
no persistió al usuario correctamente, arrojará un NotFoundError
y la prueba fallará; de lo contrario, nos devolverá el usuario. A continuación, llamamos al proveedor de correo electrónico falso y le preguntamos si registró el envío de un correo electrónico a ese usuario. Finalmente, hacemos las afirmaciones con Jest y eso concluye la prueba. Es expresivo y se lee como si el sistema estuviera funcionando realmente. No hay indirección de bibliotecas simuladas y no hay acoplamiento a la implementación de UserService
.
En la segunda prueba, creamos un usuario existente y lo agregamos al repositorio, luego intentamos llamar al servicio nuevamente usando un DTO que ya se usó para crear y conservar un usuario, y esperamos que falle. También afirmamos que no se agregaron nuevos datos al repositorio.
Para la tercera prueba, la sección "arreglar" ahora consiste en crear un usuario y persistirlo en el Repositorio falso. Luego, llamamos al SUT y, finalmente, verificamos si el usuario que regresa es el que guardamos en el repositorio anterior.
Estos ejemplos son relativamente simples, pero cuando las cosas se vuelven más complejas, poder confiar en la inyección de dependencias y las interfaces de esta manera mantiene su código limpio y hace que escribir pruebas sea un placer.
Un breve aparte sobre las pruebas: en general, no necesita simular todas las dependencias que usa el código. Muchas personas afirman erróneamente que una "unidad" en una "prueba unitaria" es una función o una clase. Eso no podría ser más incorrecto. La “unidad” se define como la “unidad de funcionalidad” o la “unidad de comportamiento”, no una función o clase. Entonces, si una unidad de comportamiento usa 5 clases diferentes, no necesita simular todas esas clases a menos que lleguen fuera del límite del módulo. En este caso, me burlé de la base de datos y del proveedor de correo electrónico porque no tengo otra opción. Si no quiero usar una base de datos real y no quiero enviar un correo electrónico, tengo que simularlos. Pero si tuviera un montón de clases más que no hicieran nada en la red, no me burlaría de ellas porque son detalles de implementación de la unidad de comportamiento. También podría decidir no burlarme de la base de datos y los correos electrónicos y crear una base de datos local real y un servidor SMTP real, ambos en contenedores Docker. En el primer punto, no tengo ningún problema en usar una base de datos real y seguir llamándola prueba unitaria siempre que no sea demasiado lenta. En general, primero usaba la base de datos real hasta que se volvía demasiado lenta y tenía que simular, como se discutió anteriormente. Pero, sin importar lo que haga, debe ser pragmático: enviar correos electrónicos de bienvenida no es una operación de misión crítica, por lo que no necesitamos ir tan lejos en términos de servidores SMTP en contenedores Docker. Cada vez que me burlo, es muy poco probable que use un marco de burla o intente afirmar la cantidad de veces que se llama o los parámetros que se pasan, excepto en casos muy raros, porque eso acoplaría pruebas a la implementación del sistema bajo prueba, y ellos debe ser agnóstico a esos detalles.
Realización de inyección de dependencia sin clases ni constructores
Hasta ahora, a lo largo del artículo, hemos trabajado exclusivamente con clases e inyectado las dependencias a través del constructor. Si está adoptando un enfoque funcional para el desarrollo y no desea usar clases, aún puede obtener los beneficios de la inyección de dependencia usando argumentos de función. Por ejemplo, nuestra clase UserService
anterior podría refactorizarse en:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
Es una fábrica que recibe las dependencias y construye el objeto de servicio. También podemos inyectar dependencias en funciones de orden superior. Un ejemplo típico sería crear una función Express Middleware que obtiene un UserRepository
y un ILogger
inyectado:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
En el primer ejemplo, no definí el tipo de dto
e id
porque si definimos una interfaz llamada IUserService
que contiene las firmas del método para el servicio, el compilador de TS deducirá los tipos automáticamente. De manera similar, si hubiera definido una firma de función para Express Middleware como el tipo de devolución de authProvider
, tampoco habría tenido que declarar los tipos de argumento allí.
Si consideramos que el proveedor de correo electrónico y el repositorio también son funcionales, y si también inyectamos sus dependencias específicas en lugar de codificarlas, la raíz de la aplicación podría verse así:
import { sendMail } from 'sendgrid'; async function main() { const app = express(); const dbConnection = await connectToDatabase(); // Change emailProvider to `makeMailChimpEmailProvider` whenever we want // with no changes made to dependent code. const userRepository = makeUserRepository(dbConnection); const emailProvider = makeSendGridEmailProvider(sendMail); const userService = makeUserService(userRepository, emailProvider); // Put this into another file. It's a controller action. app.post('/login', (req, res) => { await userService.registerUser(req.body as IRegisterUserDto); return res.send(); }); // Put this into another file. It's a controller action. app.delete( '/me', authProvider(userRepository, emailProvider), (req, res) => { ... } ); }
Notice that we fetch the dependencies that we need, like a database connection or third-party library functions, and then we utilize factories to make our first-party dependencies using the third-party ones. We then pass them into the dependent code. Since everything is coded against abstractions, I can swap out either userRepository
or emailProvider
to be any different function or class with any implementation I want (that still implements the interface correctly) and UserService
will just use it with no changes needed, which, once again, is because UserService
cares about nothing but the public interface of the dependencies, not how the dependencies work.
As a disclaimer, I want to point out a few things. As stated earlier, this demo was optimized for showing how dependency injection makes life easier, and thus it wasn't optimized in terms of system design best practices insofar as the patterns surrounding how Repositories and DTOs should technically be used. In real life, one has to deal with managing transactions across repositories and the DTO should generally not be passed into service methods, but rather mapped in the controller to allow the presentation layer to evolve separately from the application layer. The userSerivce.findById
method here also neglects to map the User domain object to a DTO, which it should do in real life. None of this affects the DI implementation though, I simply wanted to keep the focus on the benefits of DI itself, not Repository design, Unit of Work management, or DTOs. Finally, although this may look a little like the NestJS framework in terms of the manner of doing things, it's not, and I actively discourage people from using NestJS for reasons outside the scope of this article.
A Brief Theoretical Overview
All applications are made up of collaborating components, and the manner in which those collaborators collaborate and are managed will decide how much the application will resist refactoring, resist change, and resist testing. Dependency injection mixed with coding against interfaces is a primary method (among others) of reducing the coupling of collaborators within systems, and making them easily swappable. This is the hallmark of a highly cohesive and loosely coupled design.
The individual components that make up applications in non-trivial systems must be decoupled if we want the system to be maintainable, and the way we achieve that level of decoupling, as stated above, is by depending upon abstractions, in this case, interfaces, rather than concrete implementations, and utilizing dependency injection. Doing so provides loose coupling and gives us the freedom of swapping out implementations without needing to make any changes on the side of the dependent component/collaborator and solves the problem that dependent code has no business managing the lifetime of its dependencies and shouldn't know how to create them or dispose of them. This doesn't mean that everything should be injected and no collaborators should ever be directly coupled to each other. There are certainly many cases where having that direct coupling is no problem at all, such as with utilities, mappers, models, and more.
Despite the simplicity of what we've seen thus far, there's a lot more complexity that surrounds dependency injection.
Injection of dependencies can come in many forms. Constructor Injection is what we have been using here since dependencies are injected into a constructor. There also exists Setter Injection and Interface Injection. In the case of the former, the dependent component will expose a setter method which will be used to inject the dependency — that is, it could expose a method like setUserRepository(userRepository: UserRepository)
. In the last case, we can define interfaces through which to perform the injection, but I'll omit the explanation of the last technique here for brevity since we'll spend more time discussing it and more in the second article of this series.
Because wiring up dependencies manually can be difficult, various IoC Frameworks and Containers exist. These containers store your dependencies and resolve the correct ones at runtime, often through Reflection in languages like C# or Java, exposing various configuration options for dependency lifetime. Despite the benefits that IoC Containers provide, there are cases to be made for moving away from them, and only resolving dependencies manually. To hear more about this, see Greg Young's 8 Lines of Code talk.
Additionally, DI Frameworks and IoC Containers can provide too many options, and many rely on decorators or attributes to perform techniques such as setter or field injection. I look down on this kind of approach because, if you think about it intuitively, the point of dependency injection is to achieve loose coupling, but if you begin to sprinkle IoC Container-specific decorators all over your business logic, while you may have achieved decoupling from the dependency, you've inadvertently coupled yourself to the IoC Container. IoC Containers like Awilix by Jeff Hansen solve this problem since they remain divorced from your application's business logic.
Conclusión
This article served to depict only a very practical example of dependency injection in use and mostly neglected the theoretical attributes. I did it this way in order to make it easier to understand what dependency injection is at its core in a manner divorced from the rest of the complexity that people usually associate with the concept.
In the second article of this series, we'll take a much, much more in-depth look, including at:
- The difference between Dependency Injection and Dependency Inversion and Inversion of Control;
- Dependency Injection anti-patterns;
- IoC Container anti-patterns;
- The role of IoC Containers;
- The different types of dependency lifetimes;
- How IoC Containers are designed;
- Dependency Injection with React;
- Advanced testing scenarios;
- Y más.
¡Manténganse al tanto!