Um dos fatores que limitam a testabilidade é a dependência de recursos externos, principalmente por motivos de performance e consistência. Imagine que um determinado use case, como por exemplo um checkout, precise processar um pagamento em seu fluxo de execução.
export default class Checkout {
async execute (input: Input): Promise {
...
const paymentGateway = new PayPalGateway();
const transaction = await paymentGateway.createTransaction(input.payment, amount);
...
}
type Input = {
email: string,
items: { itemId: string, quantity: number }[],
payment: {
creditCardNumber: string,
holderName: string,
expDate: string,
cvv: string
}
}
}
Em produção, a transação será processada no gateway de pagamento, nesse caso o PayPal, e dependendo do resultado o status do pedido será determinado como pago ou cancelado. No entanto, durante o desenvolvimento, não é possível testar essa transação em um ambiente real, caso contrário seria necessário criar dezenas ou até mesmo centenas de débitos em um cartão de crédito para depois estorná-lo, o que poderia inclusive ser classificado pela operadora do cartão de crédito como uma tentativa de fraude, possivelmente ocasionando o bloqueio do cartão.
Esse tipo de teste, em um amibente real, será necessário na homologação da funcionalidade mas não durante o desenvolvimento.
Com isso temos algumas opções, entre elas:
A primeira opção é mais simples, basta utilizar qualquer biblioteca de testes para sobreescrever o comportamento e tornar os testes isentos desse tipo de dependência. No entanto, para ter a liberdade de variar a implementação de forma polimórfica é necessário segregar uma interface, criando uma porta que pode receber diferentes adaptadores no use case de Checkout.
Ter esse contrato definido nos dá controle sobre a dependência, ainda que em um primeiro momento um simples Stub ou Mock pudesse resolver o problema.
export default class Checkout {
constructor (readonly paymentGateway: PaymentGateway) {
}
async execute (input: Input): Promise {
...
const transaction = await this.paymentGateway.createTransaction(input.payment, amount);
...
}
type Input = {
email: string,
items: { itemId: string, quantity: number }[],
payment: {
creditCardNumber: string,
holderName: string,
expDate: string,
cvv: string
}
}
}
Este é o princípio de design é conhecido como Dependency Inversion, ou DIP, do SOLID. Na essência ele diz que: "High level modules should not depend on low level modules", ou seja, módulos que lidam com regras de negócio, que são considerados de alto nível, não devem depender de conceitos de infraestrutura como o banco de dados ou a rede, que são considerados de baixo nível.
Dessa forma, é possível tornar um use case livre de características específicas de uma implementação externa, não corrompendo conceitos de domínio e agindo como uma espécie de camada anti-corrupção (padrão de integração do Domain-Driven Design). Em termos práticos, existe um risco de ao integrar com o PayPal seu domínio absorver conceitos específicos desse fornecedor como nomes de status para pagamentos ou mesmo tipos de identificador de transações. Ao criar esse contrato, é possível ser neutro, adotar uma terminologia alinhada com o seu negócio e fazer a tradução seja pro PayPal, Stripe ou qualquer outro gateway de pagamento.
Bom, nesse ponto temos a inversão da dependência conforme o diagrama abaixo:
Repare que o Checkout (HL1) conhece a interface PaymentGateway (I) mas em tempo de execução não sabe exatamente quem vai implementá-la e isso faz com que Checkout (HL1) e PayPalGateway (ML1) sejam desacopladas.
Nesse exemplo, passamos a dependência por meio do construtor, no entanto, conforme a quantidade de dependências aumenta, podemos ter um Code Smell conhecido como longa lista de parâmetros, dificultando a instanciação e impactando o código existente sempre que uma nova dependência é criada.
Na prática, passar dependências para uma classe, seja por um construtor, um método ou de forma automática é conhecido como Dependency Injection e esse padrão muito utilizado na comunidae, principalmente de forma automática, onde é possível registrar a dependência de forma global e centralizada, fazendo a injeção onde for necessário.
export default class Checkout {
@inject("paymentGateway")
paymentGateway?: PaymentGateway;
async execute (input: Input): Promise {
...
const transaction = await this.paymentGateway.createTransaction(input.payment, amount);
...
}
type Input = {
email: string,
items: { itemId: string, quantity: number }[],
payment: {
creditCardNumber: string,
holderName: string,
expDate: string,
cvv: string
}
}
}
Nesse caso, em tempo de execução, temos um algoritmo que localiza as dependências registradas e injeta nos lugares determinados por uma anotação (no TypeScript esse recurso se chama Decorator).
Muitos frameworks já vem com esse recurso e também é possível utilizar bibliotecas específicas para este propósito. Abaixo, segue uma forma simples de implementação:
export class Registry {
dependencies: { [name: string]: any };
static instance: Registry;
private constructor () {
this.dependencies = {};
}
provide (name: string, dependency: any) {
this.dependencies[name] = dependency;
}
inject (name: string) {
return this.dependencies[name];
}
static getInstance () {
if (!Registry.instance) {
Registry.instance = new Registry();
}
return Registry.instance;
}
}
export function inject (name: string) {
return (target: any, propertyKey: string) => {
target[propertyKey] = new Proxy({}, {
get (target: any, propertyKey: string, receiver: any) {
const dependency = Registry.getInstance().inject(name);
return dependency[propertyKey];
}
});
}
}
Dessa forma, vamos registrar a dependência durante a inicialização da aplicação, no entrypoint, também conhecido como Composition Root.
const paymentGateway = new PayPalGateway();
Registry.getInstance().provide("paymentGateway", paymentGateway);
const checkout = new Checkout();
Não vejo necessidade em recorrer a este tipo de recurso apenas para desacoplar uma biblioteca específica como por exemplo um gerador de token ou manipulação de date. Sempre existirá algum acoplamento com framework ou biblioteca e o ponto aqui não é esse, é desacoplar os recursos externos que impedem a testabilidade e afetam o design.
Utilize princpalmente para desacoplar o banco de dados, uma API externa, uma Fila ou até mesmo um sistema de arquivos, evitando que os testes sejam prejudicados por este tipo de dependência. Se você quiser criar um Adapter em torno de uma biblioteca que pode mudar no futuro, sem problemas, mas são conceitos completamente diferentes.
Lembre-se que uma coisa é o princípio de design e outra é a implementação deste princípio.