DEV Community

Felipe Leao
Felipe Leao

Posted on • Updated on

Estratégias para escrever código com maior testabilidade - uma análise imperativa

Código com maior testabilidade

Observações Iniciais

  • A melhora na escrita de testes e na testabilidade do código depende da maturidade da equipe com:

    • O ambiente de desenvolvimento
    • O desenvolvimento de testes
    • A arquitetura do sistema
    • A compreensão e clareza de requisitos
  • Os pontos tratados nesse documento possuem base no estudo teórico e no conhecimento adquirido ao longo da prática exaustiva do desenvolvimento de testes:

    • Tais práticas serão úteis e relevantes, no entanto, padrões devem ser criados e mantidos pelas equipes para que o workflow de desenvolver código e escrever testes vire um fluxo comum e de fácil adaptação para todos.

Para que servem os testes?

  • Testes servem como documentação para outros desenvolvedores:

    • Facilita o entendimento e a manutenção dos produtos de uma empresa.
  • Testes transmitem confiança para desenvolvimento e manutenção de fluxos de produto.

  • Testes indicam se o seu código está bem escrito:

    • Se é difícil escrever testes para um código, ele pode estar mal escrito, complexo, verboso, acoplado demais, ou mais complicado do que poderia.
  • Testes detectam falhas durante a etapa de desenvolvimento:

    • Evitam que essas falhas sejam encontradas pelos usuários.

Por que ter um código bem testável é positivo para a vida útil de um software?

  • O fluxo de um software sempre muda para acompanhar o mundo real e as necessidades dos usuários:

    • Manter a cultura de testes de uma equipe depende da relação tempo/benefício em escrever testes.
  • Ter um código bem testável implica na adoção e manutenção de uma estrutura/arquitetura de codebase, gerando:

    • Fácil entendimento e desenvolvimento
    • Redução do tempo para encontrar falhas
    • Melhor entendimento de fluxos complexos
    • Fluxo de trabalho menos custoso, cansativo e desorganizado

Estratégias para melhorar a testabilidade de um código

  • entendimento completo de requisitos e do design de um sistema

    • isso inclui a infra da qual dispõe o código
    • a forma como os componentes internos e externos interagem
  • Definição de 'breakpoints'.

  • Padronização de erros ( Molde )

    • padronização de mensagens de erro
  • Normalização de dados

  • Separação de componentes internos e externos bem desacoplada


Destrinchemos os pontos acima:

Infraestrutura Disponível

Entender a infraestrutura envolve conhecer os recursos disponíveis, como servidores, serviços em nuvem, bancos de dados, e como o código será executado. Isso afeta decisões de design, tomadas de decisão no desenvolvimento e cenários de testes.

Interação entre Componentes Internos e Externos

Conhecer como os componentes internos (módulos, serviços) e externos (APIs, bancos de dados, sistemas de terceiros) interagem é crucial para a tomada de decisão, o design do sistema e os cenários de testes. Por exemplo, ao integrar com uma API de terceiros, é importante definir claramente como lidar com falhas ou latências para manter a robustez do sistema. Em um segundo ponto, manter componentes internos bem desacoplados de componentes externos
nos ajuda a criar mocks, simular possibilidades e ter o controle de todos os cenários possiveís na feature
que estamos desenvolvendo.


Padronização de erros e mensagem de erros

Por que padronizar erros e mensagem de erros?

  • Consistência: Garantia de uso da mesma terminologia em todo o sistema.
  • Facilidade de Manutenção: Alterações feitas em um único lugar, sem precisar buscar e substituir em todo o código.
  • Internacionalização: Facilita a tradução ao centralizar as mensagens e instancia-las apenas uma vez.
  • Testabilidade: Mensagens previsíveis facilitam a validação de exceções em testes.
  • Reutilização: Mensagens de erro podem ser usadas de forma uniforme em diferentes partes da aplicação.
export const createErrors = (error_message: string, status_code: number) => {
  const error = new Error(error_message);

  return {
    error: error,
    status_code,
  };
};

export const ErrorMessages = {
  INVALID_PASSWORD: "Invalid password",
  USER_ALREADY_EXISTS: "User already exists",
} as const;

export const createUser = async ({
  email,
  password,
}: {
  email: string;
  password: string;
}) => {
  const validPassword = validatePassword(password);

  if (!validPassword) {
    return createErrors(ErrorMessages.INVALID_PASSWORD, 422); // breakpoint
  }

  const userExists = await findUserByEmail(email);

  if (userExists) {
    return createErrors(ErrorMessages.USER_ALREADY_EXISTS, 412); //breakpoint
  }
};
Enter fullscreen mode Exit fullscreen mode

Normalização de dados

O que é normalização de dados?

  • Normalização de dados é o processo de transformar dados não estruturados para um formato consistente e estruturado antes de usá-los no restante do sistema. Isso ajuda a garantir que o sistema funcione de maneira previsível, consistente e desacoplado de componentes externos. Isso serve para qualquer componente externo, Fonte de cache, fonte de dados, mensageria, storage...

Por que normalizar dados?

  • Separação de responsabilidades: Decisões podem ser tomadas somente para componentes externos, a aplicação é tratada como uma entidade independente.
  • Testabilidade: Gera tipos e interfaces totalmente ligadas a aplicação, facilitando a previsibilidade e mock de resultados.
  • Documentação: A normalização cria uma documentação implícita do formato esperado de dados.
const orders = await db.order.findMany();

// [
//   {
//     "id": 1,
//     "customer_id": 101,
//     "product_id": 202,
//     "quantity": 2,
//     "total_price": 59.99,
//     "created_at": "...",
//     "status": "shipped",
//     "delivery_date": "...",
//     "notes": "FRÁGIL"
//   },
//   ...
// ]

type NormalizedOrder = {
  orderId: number;
  customerId: number;
  productId: number;
  quantity: number;
  totalPrice: number;
  status: string;
  deliveryDate: string | null;
  notes?: string;
};

// normalizando generalizando
function normalizeOrders(orders: any[]): NormalizedOrder[] {
  return orders.map((order) => ({
    orderId: order.id,
    customerId: order.customer_id,
    productId: order.product_id,
    quantity: order.quantity,
    totalPrice: Number(order.total_price),
    status: order.status,
    deliveryDate: order.delivery_date
      ? new Date(order.delivery_date).toISOString()
      : null,
    notes: order.notes,
  }));
}

// normalizando por adapter

import { Order as PrismaOrder } from "@prisma/client";
import { Order as MongoOrder } from "mongodb";

function normalizePrismaOrder(order: PrismaOrder[]): NormalizedOrder {
  return {
    orderId: order.id,
    customerId: order.customer_id,
    productId: order.product_id,
    quantity: order.quantity,
    totalPrice: Number(order.total_price),
    status: order.status,
    deliveryDate: order.delivery_date
      ? new Date(order.delivery_date).toISOString()
      : null,
    notes: order.notes,
  };
}

function normalizeOrmOrder(order: MongoOrder[]): NormalizedOrder {
  return {
    orderId: order._id,
    customerId: order.customerId,
    productId: order.productId,
    quantity: order.quantity,
    totalPrice: order.totalPrice,
    status: order.status,
    deliveryDate: order.deliveryDate ? order.deliveryDate.toISOString() : null,
    notes: order.notes,
  };
}
Enter fullscreen mode Exit fullscreen mode

Esteira de instruções que não carregam lógicas complexas demais.

import client as mailClient from "@sendgrid/mail";
import { db } from "./db";
import dotenv from 'dotenv'

dotenv.config()

const processOrder = async(data: {
  order,
  user_id
}, {
  order: {
    quantity: number;
    item: string
  }[],
  user_id: string
}) => {


  const user = await db.user.findUnique({
    where: {id: user_id}
  })
  if (order.quantity <= 0) {
    console.log("Invalid quantity");
    return;
  }

  const validItems = ["Laptop", "Smartphone", "Tablet"];
for (const order of orders) {
  if (!validItems.includes(order.item)) {
    console.log("Invalid item");
    return;
  }
}

  const message = {
    from: "store@gmail.com",
    to: user.email,
    subject: "Compra realizada",
    body: `corpo do email`,
  };

  const mailClient = mailClient.setApiKey(process.env.SENDGRID_KEY);

  const data = await client.send(message);

  return {ok: true}
}

Enter fullscreen mode Exit fullscreen mode

import client as mailClient from "@mail";
import { db } from "./db";
import dotenv from 'dotenv'

dotenv.config()


const ErrorMessages = {
  INVALID_QUANTITY: "Invalid quantity",
  INVALID_ITEM: "Invalid item",
  USER_NOT_FOUND: "User not found",
  MAIL_NOT_SENT: "Mail not sent",
} as const; // mensagem de erros instanciadas em uma única fonte


const getUserById = async(id: number) => {
  const user = await db.user.findUnique({
    where: { id }
  })

  if (!user) {
    console.log(ErrorMessages.USER_NOT_FOUND) // mensagem de erro padronizada
    return null
  }

  return user;
}

const validateOrder = (order: {
  quantity: number;
  item: string;
}) => {
  if (order.quantity <= 0) {
    console.log(ErrorMessages.INVALID_QUANTITY); // mensagem de erro padronizada
    return false;
  }

  const validItems = ["Laptop", "Smartphone", "Tablet"];
  if (!validItems.includes(order.item)) {
    console.log(ErrorMessages.INVALID_ITEM);
    return false;
  }

  return true;
}

const sendOrderRequestedMail = async (email_to: string) => {
  const message = {
    from: "store@gmail.com",
    to: email_to,
    subject: "Compra realizada",
    body: "corpo do email",
  }

  const mailClient = mailClient.setApiKey(process.env.MAIL_CLIENT_KEY);


  const mailSent = await client.send(message);

  if(!mailSent) {
    console.log(ErrorMessages.MAIL_NOT_SENT)
    return { ok: false }
  }
}

const processOrder = async({
  orders,
  user_id
  }: {
  orders: {
    quantity: number;
    item: string;
  }[],
  user_id: number;
  } ) => {

  const user = await getUserById(user_id); // desacoplamento do db

  if (!user) {
    return {ok: false} // breakpoint
   }



  for( const order of orders) {
    if (!validateOrder(order)) {
      return {ok: false} // breakpoint
    }
  }

  await sendOrderRequestedMail(user.email); // desacoplamento do mail

  return { ok: true }
}

Enter fullscreen mode Exit fullscreen mode

Beneficios da refatoração

  • Flexibilidade : Com uma arquitetura modular e mensagens de erro padronizadas, é mais fácil adicionar novas funcionalidades e fazer alterações no código sem impactar outras partes do sistema.

  • Reusabilidade: As funções podem ser usadas em diferentes contextos(ex: getUserById)

  • Testes: Dividir o codigo desta maneira desencadea uma facilidade em criar mocks, stubs e spies e cenários de testes completos com uma esteira simples e clara, ademais, permite o teste em pequenos escopos da esteira, que são basicamente as causas dos breakpoints.


Top comments (0)