Strategies for Writing More Testable Code - An Imperative Approach

Felipe Leao - Sep 1 - - Dev Community

More testable code

Initial Observations

  • Improvement in test writing and code testability depends on the team's maturity with:

    • The development environment
    • Test development
    • System architecture
    • Requirements understanding and clarity
  • The points covered in this document are based on the theoretical study and knowledge acquired during the exhaustive practice of test development.

    • Such practices will be useful and relevant, however, standards must be created and maintained by the teams so that the workflow of developing code and writing tests becomes a common and easily adaptable flow for everyone.

What are tests for?

  • Tests serve as documentation for other developers.

    • Facilitates the understanding and maintenance of a company's products.
  • Testing provides confidence in the development and maintenance of product flows.

  • Tests indicate whether your code is well written.

    • If it's difficult to write tests for a piece of code, it may be badly written, complex, verbose, over-coupled, or more complicated than it could be.
  • Tests detect faults during the development stage.

    • They prevent these faults from being found by users.

Why having well-testable code is positive for the lifespan of software?

  • The flow of software is always changing to keep up with the real world and the needs of users.

    • Maintaining a team's testing culture depends on the time/benefit ratio of writing tests.
  • Having a well-testable code implies the adoption and maintenance of a codebase structure/architecture, generating:**

    • Easier to understand and develop
    • Reduced time to find faults**
    • Better understanding of complex flows
    • Less costly, tiring and disorganized workflow

Strategies to improve the testability of a code

  • a complete understanding of the requirements and design of a system

    • this includes the infrastructure of the code
    • how internal and external components interact
  • Definition of breakpoints.

  • Error standardization

    • Standardization of error messages
  • Data standardization

  • Separation of internal and external components well decoupled


Let's break down the above points:

Available Infrastructure

Understanding the infrastructure involves knowing what resources are available, such as servers, cloud services, databases, and how the code will run. This affects design decisions, development decisions and testing scenarios.

Interaction between Internal and External Components

Knowing how internal components (modules, services) and external components (APIs, databases, third-party systems) interact is crucial for decision-making, system design and test scenarios. For example, when integrating with a third-party API, it is important to clearly define how to deal with failures or latencies in order to maintain the system's robustness. Secondly, keeping internal components well decoupled from external components
helps us to create mocks, simulate possibilities and take control of all possible scenarios in the feature we are developing.
we are developing.


Standardization of errors and error messages

Why standardize errors and error messages?

  • Consistency: Guaranteed use of the same terminology throughout the system.
  • Ease of maintenance: Changes made in a single place, without having to search and replace throughout the code.
  • Internationalization: Facilitates translation by centralizing messages and instantiating them only once.
  • Testability: Predictable messages make it easier to validate exceptions in tests.
  • Reusability: Error messages can be used uniformly in different parts of the application.
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

Data normalization

What is data normalization?

  • Data normalization is the process of transforming unstructured data into a consistent, structured format before using it in the rest of the system. This helps ensure that the system works predictably, consistently and decoupled from external components. This applies to any external component, Cache source, data source, messaging, storage...

Why normalize data?

  • Separation of responsibilities: Decisions can only be made for external components, the application is treated as an independent entity.
  • Testability**: Generates types and interfaces that are fully linked to the application, facilitating predictability and mock results.
  • Documentation: Normalization creates an implicit documentation of the expected data format.
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”: “FRAGILE”
// },
// ...
// ]

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

// normalizing generalizing
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,
  }));
}

// normalizing by 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

Mat of instructions that don't carry too much complex logic.

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

dotenv.config()

const processOrder = async(data: {
  order,
  user_id
}, {
  orders: {
    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: Purchase made,
    body: `email body`,
  };

  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; // error message instantiated in a single source


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

  if (!user) {
    console.log(ErrorMessages.USER_NOT_FOUND) // standard error message
    return null
  }

  return user;
}

const validateOrder = (order: {
  quantity: number;
  item: string;
}) => {
  if (order.quantity <= 0) {
    console.log(ErrorMessages.INVALID_QUANTITY); // standard error message
    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: Purchase made,
    body: email body,
  }

  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); // db decoupling

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



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

  await sendOrderRequestedMail(user.email); // mail decoupling

  return { ok: true }
}

Enter fullscreen mode Exit fullscreen mode

Benefits of refactoring

  • Flexibility: With a modular architecture and standardized error messages, it is easier to add new features and make changes to the code without impacting other parts of the system.

  • Reusability: Functions can be used in different contexts(e.g. getUserById)

  • Testing: Dividing the code in this way makes it easier to create mocks, stubs and spies and complete test scenarios with a simple and clear treadmill, and allows testing on small scopes of the treadmill, which are basically the causes of breakpoints.

. . . . . . .