Melhorando seu código: Padrão por ação! Action Pattern - limpo, óbvio e testável! - Parte 2
Adicionando etapas adicionais como funções
Agora que temos nossa função de manipulador configurada, bem como a nossa validateOptions, podemos começar a transferir a funcionalidade central para nossa ação.
/* eslint-disable consistent-return */
import mongodb from '/path/to/mongodb';
import settings from '/path/to/settings';
const connectToMongoDB = () => {
try {
return new Promise((resolve, reject) => {
mongodb.connect(
settings.mongodb.url,
(error, client) => {
if (error) {
reject(error);
} else {
const db = client.db('production');
resolve({
db,
users: db.collection('users'),
customers: db.collection('customers'),
});
}
},
);
});
} catch (exception) {
throw new Error(`[signup.connectToMongoDB] ${exception.message}`);
}
};
const validateOptions = (options) => [...];
export default async (options) => {
try {
validateOptions(options);
const db = await connectToMongoDB();
} catch (exception) {
throw new Error(`[signup] ${exception.message}`);
}
};
Primeiro, precisamos estabelecer uma conexão com nosso banco de dados. Lembre-se, precisamos acessar a coleção users e customers do MongoDB. Sabendo disso, podemos simplificar nosso código criando um método de ação connectToMongoDB, cujo único trabalho é nos conectar ao MongoDB, dando-nos acesso aos bancos de dados de que precisaremos para fazer nosso trabalho.
Para fazer isso, encerramos nossa chamada para mongodb.connect usar o padrão de método de ação. Envolvendo este código com uma Promise, podemos garantir que nossa conexão seja concluída antes de tentarmos usá-la. Isso é necessário porque não estamos mais executando nosso código subsequente acessando o banco de dados dentro do mongodb.connect callback. Em vez disso, o resolve da Promise passa a conexão 'db'. junto com as duas bases de dados que vamos precisar: userse e customers.
Por que isso é importante? Considere o seguinte: nossa conexão com o MongoDB pode falhar. Em caso afirmativo, não queremos apenas saber o porquê, mas também queremos que nosso código seja facilmente depurado. Com o código espaguete aninhado, isso é possível, mas acrescenta peso mental.
Encapsulando nossa chamada - e quaisquer falhas - dentro de uma única função, eliminamos a necessidade de rastrear erros. Isso é especialmente útil quando os próprios erros são inúteis ou ambíguos (RIP para almas que recebem um ECONNRESET). A diferença entre ERR ECONNRESET e [signup.connectToMongoDB] é noite e dia. O erro pode não estar claro, mas dissemos a nós mesmos exatamente quem é o responsável.
De volta à nossa função de manipulador, utilizamos async/await para garantir que recebamos uma resposta do MongoDB antes de continuarmos com o resto de nossa ação (ou seja, alcançamos o que nosso retorno de chamada nos deu sem abrir um restaurante italiano).
/* eslint-disable consistent-return */
import mongodb from '/path/to/mongodb';
import settings from '/path/to/settings';
const createUser = (users, userToCreate) => {
try {
return new Promise((resolve, reject) => {
users.insert(userToCreate, (error, insertedUser) => {
if (error) {
reject(error);
} else {
const [user] = insertedUser;
resolve(user._id);
}
});
});
} catch (exception) {
throw new Error(`[signup.createUser] ${exception.message}`);
}
};
const connectToMongoDB = () => [...];
const validateOptions = (options) => [...];
export default async (options) => {
try {
validateOptions(options);
const db = await connectToMongoDB();
const userId = await createUser(db.users, options.body);
} catch (exception) {
throw new Error(`[signup] ${exception.message}`);
}
};
O próximo passo é criar nosso usuário. É aqui que a magia das ações começa a aparecer. Abaixo em nossa função de manipulador, adicionamos nossa próxima etapa createUser abaixo de nossa primeira etapa connectToMongoDB. Observe que quando precisamos fazer referência ao valor retornado por uma etapa anterior nas etapas futuras, damos a ele um nome de variável que representa exatamente o que está sendo retornado.
Aqui, const db sugere que tenhamos acesso ao nosso banco de dados nessa variável e const userId que esperamos a _id de um usuário de createUser. Para chegar lá, sabemos que precisamos nos conectar à coleção users no MongoDB e precisamos das informações do usuário passadas no request.body para criar esse usuário. Para fazer isso, apenas passamos esses valores como argumentos para createUser. Limpo e arrumado.
const createUser = (users, userToCreate) => {
try {
return new Promise((resolve, reject) => {
users.insert(userToCreate, (error, insertedUser) => {
if (error) {
reject(error);
} else {
const [user] = insertedUser;
resolve(user._id);
}
});
});
} catch (exception) {
throw new Error(`[signup.createUser] ${exception.message}`);
}
};
Focando apenas na definição de createUser , podemos ver que mudamos db.users argumento para users e options.body para userToCreate(lembre-se, esta deve ser uma Object com email, password,e profile como propriedades).
Usando a abordagem de Promise, chamamos para users.insert e contamos com nosso resolve e reject para lidar com os respectivos estados de erro e sucesso de nossa chamada para users.insert. Se nossa inserção for bem-sucedida, obtemos o _id do insertedUser e chamamos resolve().
Preste muita atenção. Como estamos chamando resolve(user._id), isso significa que de volta em nossa handler função, nosso const userId = createUser() agora é "verdadeiro" porque, uma vez que isso seja resolvido, obteremos o userId de volta, atribuído a essa variável. "Doce"!
Completando nossa ação
Neste ponto, estamos familiarizados com os conceitos básicos de uma ação. Assim que a conversão completa for concluída, aqui está o que obtemos:
import mongodb from '/path/to/mongodb';
import settings from '/path/to/settings';
import stripe from '/path/to/stripe/api';
import imaginaryEmailService from '/path/to/imaginaryEmailService';
import slackLog from '/path/to/slackLog';
const logCustomerOnSlack = (emailAddress) => {
try {
slackLog.success({
message: 'New Customer',
metadata: {
emailAddress,
},
});
} catch (exception) {
throw new Error(`[signup.logCustomerOnSlack] ${exception.message}`);
}
};
const sendWelcomeEmail = (to) => {
try {
return imaginaryEmailService.send({ to, template: 'welcome' });
} catch (exception) {
throw new Error(`[signup.sendWelcomeEmail] ${exception.message}`);
}
};
const createCustomer = (customers, userId, stripeCustomerId) => {
try {
return new Promise((resolve, reject) => {
customers.insert({ userId, stripeCustomerId }, (error, insertedCustomer) => {
if (error) {
reject(error);
} else {
const [customer] = insertedCustomer;
resolve(customer._id);
}
});
});
} catch (exception) {
throw new Error(`[signup.createCustomer] ${exception.message}`);
}
};
const createCustomerOnStripe = (email) => {
try {
return stripe.customer.create({ email });
} catch (exception) {
throw new Error(`[signup.createCustomerOnStripe] ${exception.message}`);
}
};
const createUser = (users, userToCreate) => {
try {
return new Promise((resolve, reject) => {
users.insert(userToCreate, (error, insertedUser) => {
if (error) {
reject(error);
} else {
const [user] = insertedUser;
resolve(user._id);
}
});
});
} catch (exception) {
throw new Error(`[signup.createUser] ${exception.message}`);
}
};
const connectToMongoDB = () => {
try {
return new Promise((resolve, reject) => {
mongodb.connect(
settings.mongodb.url,
(error, client) => {
if (error) {
reject(error);
} else {
const db = client.db('production');
resolve({
db,
users: db.collection('users'),
customers: db.collection('customers'),
});
}
},
);
});
} catch (exception) {
throw new Error(`[signup.connectToMongoDB] ${exception.message}`);
}
};
const validateOptions = (options) => {
try {
if (!options) throw new Error('options object is required.');
if (!options.body) throw new Error('options.body is required.');
if (!options.body.email) throw new Error('options.body.email is required.');
if (!options.body.password) throw new Error('options.body.password is required.');
if (!options.body.profile) throw new Error('options.body.profile is required.');
if (!options.response) throw new Error('options.response is required.');
} catch (exception) {
throw new Error(`[signup.validateOptions] ${exception.message}`);
}
};
export default async (options) => {
try {
validateOptions(options);
const db = await connectToMongoDB();
const userId = await createUser(db.users, options.body);
const customerOnStripe = await createCustomerOnStripe(options.body.email);
await createCustomer(db.customers, userId, customerOnStripe.id);
sendWelcomeEmail(options.body.email);
logCustomerOnSlack(options.body.email);
} catch (exception) {
throw new Error(`[signup] ${exception.message}`);
}
};
Algumas coisas a serem destacadas. Primeiro, todos os nossos métodos de ação adicionais foram adicionados ao nosso manipulador, chamados em sequência.
Observe que, depois de criarmos um cliente no Stripe (e devolvê-lo como const customerOnStripe), nenhuma das etapas após isso precisa de um valor das etapas anteriores. Por sua vez, apenas chamamos essas etapas de forma independente, sem armazenar seu valor de returno em uma variável.
Observe também que nossos passos sendWelcomeEmail e logCustomerOnSlack removem o uso de um await porque não há nada para esperarmos.
É isso! Neste ponto, temos uma ação completa.
##Espere, mas por quê?
Você provavelmente está se perguntando "não adicionamos uma tonelada de código extra para fazer a mesma coisa?" Sim fizemos. Mas algo importante a considerar é quanto contexto e clareza adicionando esse código extra (quantidade insignificante) nos proporcionou.
Este é o objetivo das ações: nos dar um padrão consistente e previsível para organizar processos complexos. Isso é complicado, então outra maneira de pensar sobre isso é reduzindo o custo de manutenção. Ninguém gosta de manter código. Freqüentemente, também, quando temos a tarefa de manter uma base de código “legada”, ela tende a se parecer mais com o código com o qual começamos.
Isso se traduz em custo. Custo em tempo, dinheiro e para as pessoas que fazem o trabalho: paz de espírito. Quando o código é um emaranhado de fios, há um custo para entendê-lo. Quanto menos estrutura e consistência, maior será o custo.
Com ações, podemos reduzir significativamente a quantidade de pensamento que envolve a manutenção de nosso código. Não apenas isso, mas também tornamos incrivelmente fácil estender nosso código. Por exemplo, se formos solicitados a adicionar a capacidade de registrar o novo usuário em nosso sistema analítico, haverá pouco ou nenhum pensamento envolvido.
[...]
import analytics from '/path/to/analytics';
const trackEventInAnalytics = (userId) => {
try {
return analytics.send(userId);
} catch (exception) {
throw new Error(`[signup.trackEventInAnalytics] ${exception.message}`);
}
};
const logCustomerOnSlack = (emailAddress) => [...];
const sendWelcomeEmail = (to) => [...];
const createCustomer = (customers, userId, stripeCustomerId) => [...];
const createCustomerOnStripe = (email) => [...];
const createUser = (users, userToCreate) => [...];
const connectToMongoDB = () => [...];
const validateOptions = (options) => [...];
export default async (options) => {
try {
validateOptions(options);
const db = await connectToMongoDB();
const userId = await createUser(db.users, options.body);
const customerOnStripe = await createCustomerOnStripe(options.body.email);
await createCustomer(db.customers, userId, customerOnStripe.id);
sendWelcomeEmail(options.body.email);
logCustomerOnSlack(options.body.email);
trackEventInAnalytics(userId);
} catch (exception) {
throw new Error(`[signup] ${exception.message}`);
}
};
Isso significa que, em vez de desperdiçar seu próprio tempo e energia, você pode implementar recursos e corrigir bugs com muito pouco esforço. O resultado final é você e as partes interessadas mais felizes. Bom negócio, certo?
Embora seja um pequeno detalhe, apenas para ficar claro, vamos ver como realmente usamos nossa ação em nossa API:
import signup from '/path/to/signup/action';
export default {
v1: {
'/users/signup': (request, response) => {
return signup({ body: request.body, response });
},
},
};
Este seria um momento apropriado para um GIF de “cara de pudim” de Bill Cosby, mas, bem ... você sabe.
Testando nossa ação
O "uau" final das ações é a facilidade de testá-las. Como o código já está em etapas, uma ação nos diz o que precisamos testar. Supondo que simulamos as funções em uso dentro de nossa ação (por exemplo, stripe.customers.create) um teste de integração para nossa ação pode ter a seguinte aparência:
import signup from '/path/to/signup/action';
import stripe from '/path/to/stripe';
import slackLog from '/path/to/slackLog';
const testUser = {
email: '[email protected]',
password: 'password',
profile: { name: 'Test User' },
};
describe('signup.js', () => {
beforeEach(() => {
stripe.customers.create.mockReset();
stripe.customers.create.mockImplementation(() => 'user123');
slackLog.success.mockReset();
slackLog.success.mockImplementation();
});
test('creates a customer on stripe', () => {
signup({ body: testUser });
expect(stripe.customers.create).toHaveBeenCalledTimes(1);
expect(stripe.customers.create).toHaveBeenCalledWith({ email: testUser.email });
});
test('logs the new customer on slack', () => {
signup({ body: testUser });
expect(slackLog.success).toHaveBeenCalledTimes(1);
expect(slackLog.success).toHaveBeenCalledWith({
message: 'New Customer',
metadata: {
emailAddress: testUser.email,
},
});
});
});
Aqui, cada teste representa uma verificação de que a etapa de nossa ação foi concluída conforme o esperado. Porque só nos importamos que nossa ação execute as etapas, nosso conjunto de testes é muito simples. Tudo o que precisamos fazer é chamar nossa ação com alguma entrada (neste caso, passamos um testUserobjeto como o options.body em nossa ação).
A seguir, verificamos se nossas etapas foram concluídas. Aqui, verificamos que, dado um usuário com um e-mail [email protected], nossa ação pede para stripe.customers.create passe esse mesmo e-mail. Da mesma forma, testamos para ver se nosso método slackLog.success foi chamado, passando a mensagem que gostaríamos de ver em nossos logs.
Há muitas nuances no teste, é claro, mas espero que o ponto aqui seja claro: temos um pedaço de código muito organizado que é incrivelmente fácil de testar. Sem confusão. Nenhum tempo perdido "descobrindo". O único custo verdadeiro seria o tempo de mockar o código chamado por nossa ação, se ainda não tivéssemos feito isso.
Empacotando
Então aí está! Ações são uma ótima maneira de limpar sua base de código, tornar as coisas mais previsíveis e economizar muito tempo no processo.
Como as ações são apenas um padrão JavaScript, o custo para testá-las em seu próprio aplicativo é zero. Experimente, veja se você gosta. Mais importante: veja se eles melhoram a qualidade do seu código. Se você está lutando para escrever um código com desempenho previsível, experimente este padrão. Você não vai se arrepender.
Isto é uma tradução não muito bem feita deste artigo > https://ponyfoo.com/articles/action-pattern-clean-obvious-testable-code