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
}
};
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,
};
}
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}
}
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 }
}
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.