Injeção de Dependência em Javascript
Injeção de dependência (DI) é uma técnica extremamente poderosa, mas que por vezes assusta as pessoas, principalmente na comunidade JS, onde o assunto ainda não é tão difundido.
Apesar do nome assustar um pouco, vou mostrar que é uma técnica menos complicada do que parece, que você provavelmente já usa em alguma medida sem saber, e que usada sistematicamente vai te ajudar a escrever código mais desacolpado e testável.
Molhando os Pés
Imagine que na sua aplicação você precisa formatar datas de diferentes maneiras em diferentes lugares, então você cria uma função formatdate
que recebe um Date
, um formato (uma string que descreve o formato) e retorna a data formatada:
export const formatDate = (date, format) => {
//...
};
// Exemplos de uso
formatDate(new Date(), "YYYY/MM/DD"); // 2023/19/01
formatDate(new Date(), "DD-MM"); // 19-01
Conforme sua aplicação vai evoluindo, você percebe que em determinada parte da dela você está usando a mesma formatação, por exemplo, formatDate(date, "DD-MM-YYYY")
e então pra não ter que ficar repetindo esta mesma formatação o tempo todo e, você decide criar uma abstração:
// Supondo aqui hipoteticamente que esta parte
// da aplicação que usa a mesma formatação é a parte
// de contabilidade
export const formatDateForAccounting = (date) => formatDate(date, "DD-MM-YYYY");
Além do benefício de que agora você não precisa repetir o formato, esta abstração também acaba por esconder o detalhe da formatação específica que ela usa e qual é a string que a representa, de forma que:
- Quem usa esta função agora tem uma informação a menos pra se preocupar.
- Como a formatação virou detalhe de implementação, se a gente tiver que mudar a formatação ou mesmo a forma como a gente a representa, todos os lugares que usam
formatDateForAccounting
vão estar protegidos desta mudança, e o único local que teremos que mudar é na definição da função.
O que acabamos de fazer é injeção de dependência.
É isto pessoal e obrigado por virem ao meu TED talk.
Brincadeiras à parte, a abstração que criamos é um exemplo concreto de injeção de dependência.
Isto porque injeção de dependência consiste em criar "versões" de funções a partir da "fixação" (injeção) de alguns dos parâmetros (dependências) dessa função.
E é exatamente o que fizemos no nosso exemplo, onde a partir da função formatDate
que é flexível o bastante para que possamos escolher qual formatação específica nós queremos usar, nós criamos uma "versão" mais restrita dela chamada formatDateForAccouting
, na qual a formatação já está pré-fixada.
Dentro do jargão da injeção de dependência, nós dizemos que "formatDateForAccounting
é uma versão de formatDate
com a formatação injetada".
Entrando na Água
Até agora, pode parecer que injeção de dependência não é grande coisa, mas agora eu vou mostrar pra vocês o pulo do gato.
Imagine que na sua aplicação você está implementando uma função pay
que:
- Recebe os dados do cartão de crédito do usuário.
- Tokeniza o cartão de crédito.
- Chama a API de algum gateway de pagamento passando o cartão de crédito tokenizado.
- Se o pagamento der certo, a função só retorna normalmente, e se der errado, loga uma mensagem de erro.
- Se a tokenização falhar, a API do gateway de pagamento não é chamada.
Algo mais ou menos assim:
const pay = async (creditCard) => {
try {
const token = await tokenizeCreditCard(creditCard);
if (token !== undefined) {
throw new Error("Failed to tokenize credit card!");
}
await processPayment(token);
} catch (error) {
logger.error(error);
}
};
Como é que você faria pra testar esta função unitariamente, ou seja, sem ter que bater nas APIs de fato, ou fazer chamadas HTTP?
Esta função não retorna nada, quase tudo que ela faz é interagir com sistemas externos e produzir efeitos colaterais (side-effects), o que a torna bem difícil de testar unitariamente.
Toda vez que algo está difícil de testar, é porque existem coisas que estão fora do nosso controle e portanto é difícil (ou impossível) manipulá-las pra que elas se comportem da forma que precisamos nos nossos testes.
Neste caso, como tokenizeCreditCard
, processPayment
e logger
estão hardcoded dentro de pay
, é como se eles estivessem fora do nosso alcance nos testes.
E aqui que mora o pulo do gato, porque da mesma forma que nós podemos criar versões mais restritas de funções fixando seus argumentos, a gente também pode fazer o processo inverso, ou seja, criar versões mais flexíveis de funções, parametrizando coisas que outrora estavam hardcoded.
Portanto, podemos fazer:
const pay = async (tokenizeCreditCard, processPayement, logger, creditCard) => {
try {
const token = await tokenizeCreditCard(creditCard);
if (token !== undefined) {
throw new Error("Failed to tokenize credit card!");
}
await processPayment(token);
} catch (error) {
logger.error(error);
}
};
Perceba que a implementação da nossa função continua exatamente igual, porém agora, nós temos controle sobre tokenizeCreditCard
, processPayment
e logger
, uma vez que nós conseguimos passar essas funções para pay
.
Por conta disto, nós podemos passar versões mockadas destas funções para pay
, de forma que nós não só conseguimos fazer com que elas se comportem da forma que precisamos, como também conseguimos "espiá-las", para saber se elas foram chamadas, com quais argumentos, e etc.
E agora, no nosso teste:
describe("When credit card tokenization fails", () => {
const setup = () => {
// Forçando o erro na tokenização
const tokenizeCreditCard = () => {
return undefined;
};
// Para podermos "espiar" esta função
const processPayment = jest.fn();
// Provavelmente a implementação "real" deste logger
// possui mais métodos, mas como estamos num teste,
// nós podemos mockar só os métodos que nos interessam
const logger = {
error: jest.fn(),
};
const creditCard = {
number: "4242 4242 4242 4242",
expiration: "10/25",
cvc: "123",
};
pay(tokenizeCreditCard, processPayment, logger, creditCard);
return {
processPayment,
logger,
};
};
it("Logs error", () => {
const { logger } = setup();
expect(logger.error).toHaveBeenCalledTimes(1);
});
it("processPayment is NOT called", () => {
const { processPayment } = setup();
expect(processPayment).not.toHaveBeenCalled();
});
});
describe("When payment processing fails", () => {
const setup = () => {
const tokenizeCreditCard = () => "Some Token";
const processPayment = () => {
throw new Error("");
};
const logger = {
error: jest.fn(),
};
const creditCard = {
number: "4242 4242 4242 4242",
expiration: "10/25",
cvc: "123",
};
pay(tokenizeCreditCard, processPayment, logger, creditCard);
return {
logger,
};
};
it("Logs error", () => {
const { logger } = setup();
expect(logger.error).toHaveBeenCalledTimes(1);
});
});
describe("When payment succeeds", () => {
const setup = () => {
const tokenizeCreditCard = () => "Some Token";
const processPayment = () => {
// No op
};
const logger = {
error: jest.fn(),
};
const creditCard = {
number: "4242 4242 4242 4242",
expiration: "10/25",
cvc: "123",
};
pay(tokenizeCreditCard, processPayment, logger, creditCard);
return {
logger,
};
};
it("Does not log anything", () => {
const { logger } = setup();
expect(logger).not.toHaveBeenCalled();
});
});
Eu imagino que agora você provavelmente está pensando:
Mas a gente não poderia simplesmente mockar os módulos como um todo, usando por exemplo o
jest.mock
?
E a resposta é sim, poderíamos, mas eu acredito que a injeção de dependência é uma abordagem superior pelos seguintes motivos:
- Injeção de dependência não está atrelada a uma biblioteca específica, ela vai funcionar com qualquer framework de testes.
- O
jest.mock
vive dando dor de cabeça pelo fato dele ter que fazer monkey patching com orequire
/import
, e inclusive já tive projetos meus que TODOS os mocks quebraram por atualizar a versão de coisas como o NextJS. - Ao contrário do
jest.mock
, a injeção de dependência não se importa se você está usandorequire
ouimport
, se você está usando CommonJS ou ESM, it just works. - Com injeção de dependência, você consegue organizar seus testes de maneira que você nunca vai precisar lembrar de resetar/limpar seus mocks, uma vez que você consegue deixar eles escopados em cada teste/setup.
Além disto, injeção de dependência nos dá uma abordagem uniforme pra lidar com mocks/valores que a gente quer controlar nos nossos testes.
Por exemplo, digamos que nós temos uma função payWithRetry
que vai fazer o pagamento e, caso ele dê erro em algum lugar, ele vai retentar o pagamento um certo número de vezes controlado por uma variável de ambiente PAY_RETRY_TIMES
:
const payWithRetry = async (creditCard) => {
let times = 1;
while (times < process.env.PAY_RETRY_TIMES) {
try {
await pay(creditCard);
return;
} catch {
times++;
}
}
};
Se a gente quiser testar esta função com diferentes quantidades de retries, ao invés de termos que ficar mutando o process.env
nos nossos testes, e lembrando de ficar limpando/restaurando ele a cada teste, com injeção dependência, nossa abordagem é idêntica à anterior:
const payWithRetry = async (payRetryTimes, creditCard) => {
let times = 1;
while (true) {
try {
await pay(creditCard);
return;
} catch(error) {
if(times < payRetryTimes) {
times++;
continue;
}
throw error;
}
}
};
Basta parametrizarmos a quantidade de retries.
E se estivermos no frontend e a nossa função de pagamento redireciona o usuário para alguma página específica e quisermos checar que esse redirecionamento está sendo feito de fato?
const pay = async (creditCard) => {
//...
window.location.assign("https://example.com");
};
Novamente, ao invés de termos que mutar/mockar objetos globais, principalmente nos testes que rodam em Node e não no browser, basta recebermos a window como parâmetro:
const pay = async (window, creditCard) => {
//...
window.location.assign("https://example.com");
};
Desta forma, nos nossos testes podemos substituir a window
original por um spy.
A mesma coisa vai funcionar com outros globais como Math.random
, setTimeout
, setInterval
, Date
, entre outros.
Usando as utilities de bibliotecas de teste/mock, cada uma dessas situações que vimos, vai ter alguma técnica ou API específica para que possa ser mockada, porém com injeção de dependência, todos os casos são resolvidos da mesma forma.
Nadando
Tá, mas só tem um problema, antes, quando eu precisava fazer um pagamento, bastava chamar a função
pay
passando o cartão de crédito, só que agora eu tenho que ficar passando tambémtokenizeCreditCard
,processPayment
e ologger
em todos os lugares que eu for chamarpay
?
Calma jovem, que aqui vem o segundo pulo do gato:
Da mesma forma que lá trás, a gente tinha a função formatDate
, pra ser usada nos contextos onde a flexibilidade de escolhar a formatação é importante e, ao mesmo tempo, uma versão mais restrita, formatDateForAccounting
, onde essa flexibilidade não é importante, a gente vai criar duas versões da nossa função pay
.
// Importamos as implementações "reais"
import { tokenizeCreditCard } from "./tokenizeCreditCard";
import { processPayment } from "./processPayment";
import { logger } from "./logger";
// makePay é uma função que "cria" `pay`.
// Recebemos as dependências como parâmetro
// e usamos as implementações "reais" como default
// Este formato é equivalente ao primeiro onde
// tínhamos uma única função, porém desta forma
// além de ficar explícito quais são as dependências,
// também fica mais fácil de mockar apenas algumas
// dependências específicas, enquanto as outras
// vão usar as dependências "originais"
export const makePay =
({
tokenizeCreditCard = tokenizeCreditCard,
processPayment = processPayment,
logger = logger,
}) =>
(creditCard) => {
//...
};
// É esta a função que será importada
// pela nossa aplicação, de forma
// que ela não precise ficar passando
// as dependências de `pay` em todo lugar
// e, tão pouco saber que elas existem
export const pay = makePay();
E aqui fechamos o ciclo de injeção de dependência.
Primeiro, nós parametrizamos as dependências que estavam hardcoded na nossa função para que possamos usar implementações mockadas dessas dependências nos testes.
Depois, criamos uma versão mais restrita de pay
, com as dependências já injetadas, para usarmos na aplicação, uma vez que ela não precisa ter controle sobre quais implementações estão sendo usadas (em runtime).
Perceba que agora, pay
recebe apenas o cartão de crédito, enquanto makePay
é a função que nos permite criar uma "versão alternativa" de pay
substituindo suas dependências.
Tornar uma aplicação testável é provavelmente o caso de uso mais comum de injeção de dependência, mas em geral, ela vai nos ajudar sempre que precisarmos usar implementações diferentes para as dependências de uma determinada função.
Dois exemplos comuns disto, são:
Quanto estamos usando feature flags.
const foo = () => {
//...
if (process.env.ALTERNATE_FOO) {
//...
}
//...
if (process.env.ALTERNATE_FOO) {
//...
}
//...
};
Onde ao invés de lotarmos de "ifs", podemos fazer:
const originalFoo = () => {
// ...
};
const altFoo = () => {
//...
};
export const foo = process.env.FEATURE_FLAG_ALTERNATE_FOO ? altFoo : foo;
Ou quando estamos trabalhando no frontend com aplicações universais, que rodam tanto no servidor quanto no cliente (e.g. NextJS) e, por vezes, precisam de implementações diferentes para cada um desses ambientes:
const clientFoo = () => {
// ...
};
const serverFoo = () => {
// ...
};
const isServer = typeof window === "undefined";
export const foo = isServer ? serverFoo : clientFoo;
Fin
Injeção de dependência é um assunto extremamente extenso e que por isso não daria pra cobrir tudo aqui, porém com o que vimos é possível entender a essência da injeção de dependência e utilizar a maior parte dos seus benefícios.
Dito isto, ainda haveria muito a se falar, como por exemplo o uso de injeção de dependência com classes (que inclusive é como surgiu), framekworks de injeção de dependência, containers, service locators, e enfim, a lista é longa.
Assim, se você deseja fazer um mergulho mais profundo no assunto, vou deixar aqui um outro post bem mais extenso e aprofundado sobre o assunto que escrevi:
https://blog.codeminer42.com/dependency-injection-in-js-ts-part-1/
Até mais, e obrigado pelos peixes!