DEV Community 👩‍💻👨‍💻

Cover image for Padrão por ações! Action Pattern - limpo, óbvio e testável!
Uriel dos Santos Souza
Uriel dos Santos Souza

Posted on • Originally published at ponyfoo.com

Padrão por ações! Action Pattern - limpo, óbvio e testável!

Isto é uma tradução não muito bem feita deste artigo > https://ponyfoo.com/articles/action-pattern-clean-obvious-testable-code
Desenvolvido por: Ryan Glover

Em primeiro lugar quando li este pattern eu e identifiquei. Pois meio que casa com meu pensamento de dev iniciante :)

Se você conhecer este pattern por outro nome, por favor, coloque nos comentários suas fontes pois quero devora-las :)

A tradução pode não estar muito boa. Mas irei me esforçar. Você pode e deve sugerir melhorias!

Tradução do texto original:

Vamos converter um endpoint de uma API simulada que inscreve novos usuários para o padrão de ação.

Quando comecei a escrever software para web, meu código era uma confusão. Cada projeto foi carregado com arquivos desnecessariamente longos e código comentado, jogado para o lado da estrada como um veículo abandonado. O tema do dia foi: imprevisibilidade.

Sob condições ideais - o caminho feliz - consegui fazer meu código funcionar. Mas o que eu não consegui fazer foi fazer meu código funcionar de maneira consistente. Uma vez, meu código funcionava, depois, na próxima, um anônimo "500 Internal Server Error" me deixava em uma espiral por dias.

Consegui passar despercebido, mas pensar em continuar respondendo e-mails de clientes que diziam “isso não está funcionando ...” era uma vida que eu não queria levar.

Tirando meu chapéu de iniciante, comecei a ver o que outros programadores mais experientes estavam fazendo. Eu tinha ouvido falar de Bob “Tio Bob” Martin de passagem, eventualmente descobrindo sua série Código limpo.
Estava preso. Pela primeira vez, ele estava respondendo a perguntas que outras pessoas no meu caminho não tinham.

Minha pergunta principal? “Como organizo código complexo?” No que diz respeito às perguntas, isso foi um novelo de lã, mas ao longo de vários vídeos ele explicou as partes que eu estava perdendo:

  • Usar nomes explícitos que não podem ser confundidos.

  • Quebrar seu código em funções que fazem uma coisa.

  • Usar TDD (desenvolvimento orientado a testes) para orientar seu trabalho.

Eu ainda verde, parte disso fazia sentido e outra parte não.
O outro problema era que a linguagem de escolha de Bob era Java, não JavaScript. Isso significava que eu era capaz de entender o que ele estava dizendo em alto nível, mas na parte pratica ainda estava perplexo.

Várias iterações depois ...

Eventualmente, o que Bob ensinou começou a ser absorvido. Conforme ganhei experiência, comecei lentamente a organizar meu código em um padrão (apoiado por uma pequena lista de regras):

  1. Qualquer código que envolva várias etapas deve ser movido para seu próprio arquivo / módulo.

  2. Esse arquivo / módulo deve receber um nome que descreva a que essas etapas conduzem.

  3. Cada etapa desse código deve ser uma única função com um nome que descreve exatamente o que ela faz (mesmo que seja mais longo do que preferimos).

  4. Se o código falhar, deve ser fácil ver exatamente onde ele falhou, sem muitos passos para trás.

O que começou como um conjunto informal de regras para mim, acabou evoluindo para um padrão concreto.
Depois de anos de iteração e colocando-o à prova em projetos de clientes e pessoais, em 2017 o padrão de ação foi batizado.

Como funcionam as ações...

Para o restante deste tutorial, Vamos converter um endpoint de uma API simulada que inscreve novos usuários para o padrão de ação.

Nossos objetivos:

  1. Compreender a estrutura de uma ação.
  2. Aprender a usar JavaScript Promises com ações.
  3. Encontrar um “porquê” maior para o uso de ações.
  4. Entender como a escrita de testes é simplificada pelo uso de ações.

Convertendo Nosso Endpoint

Nosso aplicativo, Doodler (uma rede social paga para artistas), lida com suas inscrições por meio de uma API existente baseada no Express. Quando um novo usuário se inscreve no aplicativo, uma solicitação é feita para sua API em https://doodler.fake/api/v1/users/signup.

Nesse ponto de extremidade, ocorrem as seguintes etapas:

  • Um novo usuário é criado na coleção de usuários.
  • Um novo cliente é criado no Stripe(sistema de pagamentos).
  • Um cliente é criado na coleção de clientes.
  • Um e-mail de boas-vindas é gerado.
  • Uma mensagem de “novo usuário” é enviada ao Slack da empresa.

Juntas, essas cinco etapas representam a ação de inscrever um novo usuário. Como algumas das etapas dependem das etapas anteriores, queremos ter uma maneira de “parar” nosso código se as etapas anteriores falharem. Antes de entrarmos nas ervas daninhas, vamos dar uma olhada no código que temos agora:

/* eslint-disable */

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';

export default {
  v1: {
    '/users/signup': (request, response) => {
      mongodb.connect(settings.mongodb.url, function (error, client) {
        const db = client.db('production');
        const users = db.collection('users');
        const customers = db.collection('customers');

        users.insert({ email: request.body.email, password: request.body.password, profile: request.body.profile }, async function (error, insertedUser) {
          if (error) {
            throw new Error(error);
          } else {
            const [user] = insertedUser;
            const userId = user._id;

            const customerOnStripe = await stripe.customers.create({
              email: request.body.email,
            });

            customers.insert({ userId, stripeCustomerId: customerOnStripe.id }, async function (error, insertedCustomer) {
              if (error) {
                throw new Error(error);
              } else {
                imaginaryEmailService.send({ to: request.body.email, template: 'welcome' });
                slackLog.success({
                  message: 'New Customer',
                  metadata: {
                    emailAddress: request.body.email,
                  },
                });

                response.end();
              }
            });
          }
        });
      });
    },  
  },
};

Enter fullscreen mode Exit fullscreen mode

Olhando para esse código, supondo que todas as partes funcionem por conta própria, é plausível que esse código funcione. O que é diferente sobre esse código, no entanto, é que ele não é terrivelmente organizado. Ele contém muitas chamadas aninhadas e não muito controle de fluxo (ou seja, se algo falhar, todo o castelo de cartas cai).

É aqui que começamos a andar na ponta dos pés até o abismo do "funciona" vs. "funciona bem". Infelizmente, é um código como esse que leva à perda de muito tempo perseguindo e corrigindo bugs. Não é que o código não funcione, é que ele funciona de forma imprevisível.

Você provavelmente está dizendo "bem, sim, todo código é imprevisível". Você não está errado. Mas, se formos inteligentes, podemos reduzir significativamente a quantidade de imprevisibilidade, dando-nos mais tempo para nos concentrarmos nas coisas divertidas - não em consertar erros do passado (cometidos por nós mesmos ou por alguém de nossa equipe).

Apresentando o padrão de ação

Em primeiro lugar, é importante entender que o padrão de ação é o JavaScript vanilla. É um padrão a seguir, não uma biblioteca ou estrutura a ser implementada. Isso significa que o uso de ações requer um certo nível de disciplina (a maioria dos quais pode ser automatizada por meio de fragmentos em seu IDE).

Para começar a nossa conversão, vamos olhar para uma versão do esqueleto de uma ação e, em seguida, construí-la para lidar com a nossa inscrição de novo usuário.

/* eslint-disable consistent-return */

const actionMethod = (someOption) => {
  try {
    console.log('Do something with someOption', someOption);
    // Perform a single step in your action here.
  } catch (exception) {
    throw new Error(`[actionName.actionMethod] ${exception.message}`);
  }
};

const validateOptions = (options) => {
  try {
    if (!options) throw new Error('options object is required.');
    if (!options.someOption) throw new Error('options.someOption is required.');
  } catch (exception) {
    throw new Error(`[actionName.validateOptions] ${exception.message}`);
  }
};

export default (options) => {
  try {
    validateOptions(options);
    actionMethod(options.someOption);
    // Call action methods in sequence here.
  } catch (exception) {
    throw new Error(`[actionName] ${exception.message}`);
  }
};

Enter fullscreen mode Exit fullscreen mode

As ações são projetadas para serem lidas de baixo para cima. Na parte inferior de nosso arquivo, exportamos uma função conhecida como nosso manipulador. Esta função é responsável por chamar todas as outras etapas de nossa ação. Isso nos ajuda a realizar algumas coisas:

  1. Centralize todas as nossas chamadas para outro código em um só lugar.
  2. Compartilhe os valores de resposta de cada etapa com outras etapas.
  3. Delineie claramente a ordem das etapas em nosso código.
  4. Torne nosso código mais sustentável e extensível, evitando código espaguete aninhado.

Dentro dessa função, a primeira coisa que fazemos é chamar a validateOptions passando do options como argumento, assumido passado para a função de tratamento (ou, o que exportamos de nosso arquivo como nossa ação).

Com validateOptions começamos a ver alguns outros subpadrões de ações aparecerem. Especificamente, o nome da função validateOptions é exatamente o que ela faz

. Não é nem vldOpts nem validateOps, nada que deixa espaço para confusão. Se eu colocasse outro desenvolvedor neste código e perguntasse "o que essa função faz?" ele provavelmente responderia sarcasticamente com "uhh, valida as opções?"

A próxima coisa que você notará é a estrutura de validateOptions. Imediatamente dentro do corpo da função, uma try/catch instrução é adicionada, com a catch pegando exception e throw usando o Error construtor JavaScript.
Note, também, que quando este erro é lançado, dizemos a nós mesmos exatamente onde o erro está acontecendo com [actionName.validateOptions]seguido pela mensagem de erro específica.

No try, fazemos o que nosso código diz: validar nosso options! A lógica aqui é mantida simples de propósito. Se nossa ação requer que options seja passado e requer que propriedades específicas sejam definidas no options, lançamos um erro se elas não existirem. Para deixar isso claro, se chamássemos essa ação agora desta forma:

actionName()// sem passar nada;

Obteríamos o seguinte erro em resposta:

[actionName.validateOptions] options object is required.

Esta é uma grande vantagem para o desenvolvimento. Estamos dizendo a nós mesmos exatamente o que precisamos desde o início, para que possamos pular a roleta do "o que eu esqueci de passar agora?".

Se voltarmos para nossa função de manipulador, veremos que, depois que nossas opções foram validadas com validateOptions, nosso próximo passo é chamar actionMethod, passando options.someOptions.

É aqui que entramos nas etapas reais ou na funcionalidade de nossa ação. Aqui, actionMethod pega options.someOption. Observe que, por ser a segunda etapa chamada em nosso manipulador, ela está definida acima de validateOptions (nossa primeira etapa).

Se olharmos para funcção actionMethod, deveria - propositalmente - parecer muito familiar. Aqui, repetimos o mesmo padrão: dar um nome claro para nossa função, executar nosso código em um bloco try/catch e, se nosso código falhar, throw error dizendo a nós mesmos de que veio [actionName.actionMethod].

Refatorando nossa inscrição

Sentindo-se indeciso? Excelente! É isso que procuramos. Escrever código limpo não deve ser difícil ou excessivamente esotérico.
Agora, vamos começar a refatorar nosso endpoint de inscrição em uma ação. Vamos limpar nosso esqueleto, adicionando algumas verificações legítimas para validateOptions:

const actionMethod = (someOption) => {
  try {
    console.log('Do something with someOption', someOption);
    // Perform a single step in your action here.
  } catch (exception) {
    throw new Error(`[signup.actionMethod] ${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 (options) => {
  try {
    validateOptions(options);
    // Call action methods in sequence here.
    options.response.end();
  } catch (exception) {
    throw new Error(`[signup] ${exception.message}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

Algumas coisas mudaram. Observe que, em vez de actionName, nossa ação tem um nome: signup.

No interior validateOptions, também definimos algumas expectativas reais. Lembre-se de que em nosso código original, reutilizamos o request.body várias vezes. Aqui, pensamos no futuro e presumimos que apenas passaremos o body da solicitação (a única parte que utilizamos). Também nos certificamos de validar se cada uma das propriedades do body está presente.

Por fim, também queremos validar se o objeto
response de nosso terminal é passado para que possamos responder à solicitação em nossa ação.

Os detalhes disso são em sua maioria arbitrários; o ponto aqui é que estamos garantindo que temos o que precisamos antes de colocá-lo em uso. Isso ajuda a eliminar o inevitável "já passei nisso?", bem como o tempo subsequente desperdiçado na depuração para descobri-la.
OBS do tradutor: usando console.log em N cantos.

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}`);
  }
};

Enter fullscreen mode Exit fullscreen mode

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}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
};

Enter fullscreen mode Exit fullscreen mode

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}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
};

Enter fullscreen mode Exit fullscreen mode

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 });
    },  
  },
};
Enter fullscreen mode Exit fullscreen mode

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: 'test@test.com',
  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,
      },
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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 test@test.com, 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.

Top comments (0)

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.