Writing Testable Code & Its Importance
There are a few core principles to writing great software. Those educated in it formally have probably heard it so many times since their first day of school. For those self taught, it's important to learn them yourself.
In today’s era of “vibe coding,” it’s crucial to understand these principles—both to guide the agents generating our code and to refactor that code into something more efficient, maintainable, and readable. I believe that Separation of Concerns is the most important principle, while writing testable code brings its own set of valuable benefits. These are the ideas we’ll be diving into today.
I think that these two concepts are important for 2 main reasons:
- It breaks out data-transforming logic into reusable functions, reducing duplication and making the overall code easier to understand. This is especially valuable because agents can more easily follow your patterns when reading your code and extend them when writing new functionality.
- It allows you to actually write effective tests, minimizing the chance of introducing bugs or unintended behaviour changes as a result of new additions or modifications. As we enter an era where iteration happens so quickly, it's important to ensure we don't break what used to work.
What is Separation of Concerns?
A software design strategy that divides a complex application into distinct modules, each responsible for a single, well-defined "concern" or function.
Simply put, it means writing functions that serve 1 purpose, and 1 purpose only. There will be exact examples of what this means later in this piece. The benefits of following this principle are:
- Modularity & Reusability—Everything is small, concise, and well organized. No function does too much, but they do just enough. They serve a purpose in isolation, and can be reused in various different places. If organized properly, this can also reduce the risk of circular dependencies which can cause big problems.
- Readability and Maintainability—Great software is "self-documenting". It's clear what is going on at every point, and functions are named to explain their purpose well. This allows for future contributors to quickly understand what is going on in the codebase.
- Testability—Since the code is modular, we can test functions for the specific thing they're supposed to do.
While this principle extends to the project's architecture as a whole, we're just focusing on function-level Separation of Concerns in this piece, and will also explore some frontend specific examples.
What Are Tests?
Testing frameworks allow you to write code that can simply call other functions and compare it's outputs with what's expected. Testing blocks can be run in isolation, as files, or for the entire codebase. It's pretty straight forward to integrate into CI/CD pipelines once you've gotten there, and could be very useful to run before deploying changes to production to ensure that important parts of the code are still behaving as expected.
For React, frameworks like Jest or Vitest are commonly used. I recommend skimming the documentation before diving into writing tests, and asking ChatGPT for some best practices!
Writing & Refactoring Code Into Testable Units
Here is an example of code for a People search that fetches raw directory data, does tokenization + scoring + ranking + highlighting, and returns a shaped response for a UI, and how it can be refactored. This is AI generated code, but it serves the purpose for demonstration
// BEFORE
export async function getOrdersReport(
apiBase: string,
params: {
from: string;
to: string;
status?: string;
currency: 'USD' | 'EUR' | 'GBP';
sort?: 'date' | 'total';
page?: number;
pageSize?: number;
callerRole: 'admin' | 'analyst' | 'support';
},
) {
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 50;
// Fetch orders (server paginated)
const res = await fetch(
`${apiBase}/orders?from=${params.from}&to=${params.to}&status=${params.status ?? ''}&page=${page}&pageSize=${pageSize}`,
);
if (!res.ok) throw new Error('orders fetch failed');
const { items: orders, total } = await res.json(); // each order has { id, customerId, lines[], discountCode, vatRate, createdAt }
// Fetch customers individually (N calls)
const customers = await Promise.all(
orders.map(async (o: any) => {
const r = await fetch(`${apiBase}/customers/${o.customerId}`);
if (!r.ok) throw new Error('customer fetch failed');
return r.json(); // { id, email, name, country }
}),
);
// Fetch rates
const fxRes = await fetch(`${apiBase}/fx?currency=${params.currency}`);
if (!fxRes.ok) throw new Error('fx fetch failed');
const { rate } = await fxRes.json();
// Business logic + PII + totals all mixed in
const rows = orders.map((o: any) => {
const customer = customers.find((c: any) => c.id === o.customerId);
let subtotal = o.lines.reduce(
(sum: number, l: any) => sum + l.priceCents * l.qty,
0,
);
if (o.discountCode === 'SAVE10') subtotal = Math.round(subtotal * 0.9);
const vat = Math.round(subtotal * (o.vatRate ?? 0.2));
const totalCents = Math.round((subtotal + vat) * rate);
const maskedEmail =
params.callerRole === 'admin'
? customer?.email
: customer?.email?.replace(/(.).+(@.+)/, '$1***$2');
return {
orderId: o.id,
customer: {
id: customer?.id,
name: customer?.name,
email: maskedEmail,
country: customer?.country,
},
createdAt: o.createdAt,
currency: params.currency,
amounts: { subtotalCents: subtotal, vatCents: vat, totalCents },
lineCount: o.lines.length,
status: o.status,
};
});
rows.sort((a: any, b: any) => {
if ((params.sort ?? 'date') === 'total')
return b.amounts.totalCents - a.amounts.totalCents;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
return {
meta: { page, pageSize, total },
rows,
};
}
// AFTER
export async function getOrdersReport(
apiBase: string,
params: {
from: string;
to: string;
status?: string;
currency: 'USD' | 'EUR' | 'GBP';
sort?: 'date' | 'total';
page?: number;
pageSize?: number;
callerRole: 'admin' | 'analyst' | 'support';
},
) {
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 50;
const [{ items: orders, total }, rate] = await Promise.all([
fetchOrders(apiBase, {
from: params.from,
to: params.to,
status: params.status,
page,
pageSize,
}),
fetchFxRate(apiBase, params.currency),
]);
const customerIds = Array.from(new Set(orders.map((o: any) => o.customerId)));
const customers = await fetchCustomersByIds(apiBase, customerIds);
const byId = indexById(customers);
const shaped = orders.map((o: any) => {
const amounts = computeAmounts(o.lines, o.discountCode, o.vatRate, rate);
const customer = byId.get(o.customerId);
const email = customer?.email ?? '';
const masked = email ? maskEmail(email, params.callerRole) : '';
return shapeRow(o, customer, params.currency, amounts, masked);
});
return {
meta: { page, pageSize, total },
rows: sortRows(shaped, params.sort ?? 'date'),
};
}
// TESTABLE UTILITIES
export async function fetchOrders(
apiBase: string,
q: {
from: string;
to: string;
status?: string;
page: number;
pageSize: number;
},
) {
const u = new URL(`${apiBase}/orders`);
Object.entries({ ...q }).forEach(
([k, v]) => v != null && u.searchParams.set(k, String(v)),
);
const res = await fetch(u);
if (!res.ok) throw new Error('orders fetch failed');
return res.json(); // { items, total }
}
export async function fetchCustomersByIds(apiBase: string, ids: string[]) {
// Batch endpoint; if you don’t have one, add a simple server proxy to avoid N+1
const res = await fetch(`${apiBase}/customers:batch`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!res.ok) throw new Error('customers batch fetch failed');
return res.json(); // [{ id, email, name, country }]
}
export async function fetchFxRate(apiBase: string, currency: string) {
const res = await fetch(`${apiBase}/fx?currency=${currency}`);
if (!res.ok) throw new Error('fx fetch failed');
const { rate } = await res.json();
return rate as number;
}
export function lineSubtotalCents(
lines: Array<{ priceCents: number; qty: number }>,
) {
return lines.reduce((sum, l) => sum + l.priceCents * l.qty, 0);
}
export function applyDiscounts(subtotalCents: number, discountCode?: string) {
if (discountCode === 'SAVE10') return Math.round(subtotalCents * 0.9);
return subtotalCents;
}
export function computeVatCents(subtotalCents: number, vatRate = 0.2) {
return Math.round(subtotalCents * vatRate);
}
export function convertCents(amountCents: number, rate: number) {
return Math.round(amountCents * rate);
}
export function computeAmounts(
lines: any[],
discountCode?: string,
vatRate?: number,
rate = 1,
) {
const subtotal = applyDiscounts(lineSubtotalCents(lines), discountCode);
const vat = computeVatCents(subtotal, vatRate ?? 0.2);
const totalCents = convertCents(subtotal + vat, rate);
return { subtotalCents: subtotal, vatCents: vat, totalCents };
}
export function maskEmail(
email: string,
role: 'admin' | 'analyst' | 'support',
) {
if (role === 'admin') return email;
return email.replace(/(.).+(@.+)/, '$1***$2');
}
export function indexById<T extends { id: string }>(arr: T[]) {
const map = new Map<string, T>();
for (const item of arr) map.set(item.id, item);
return map;
}
export function shapeRow(
order: any,
customer: any,
currency: string,
amounts: {
subtotalCents: number;
vatCents: number;
totalCents: number;
},
maskedEmail: string,
) {
return {
orderId: order.id,
customer: {
id: customer?.id,
name: customer?.name,
email: maskedEmail,
country: customer?.country,
},
createdAt: order.createdAt,
currency,
amounts,
lineCount: order.lines.length,
status: order.status,
};
}
// orders/sort.ts — pluggable sorters
export function sortRows(rows: any[], criterion: 'date' | 'total' = 'date') {
if (criterion === 'total') {
return [...rows].sort(
(a, b) => b.amounts.totalCents - a.amounts.totalCents,
);
}
return [...rows].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
Setting Up Factories For Models
For every object is your domain model (ie. Users, Organizations, Projects, etc.) you should set up a Factory which build an object with fake data that can be used for tests or mock data when you build your applications. I've develop my own method of creating these factories which has a few set of principles that will be shown below.
For the example of my User object, it has a factory as follows:
// SIMPLIFIED FOR DISPLAY. THE BASE MODEL SHOULD HAVE DEFAULTS, AND MORE PRECISE CONFIGURATIONS.
export const DrizzleBaseModel = {
id: text('id'),
created_at: timestamp('created_at'),
updated_at: timestamp('updated_at'),
is_active: boolean('is_active'),
deleted_at: timestamp('deleted_at'),
};
export const users = pgTable('users', {
...DrizzleBaseModel,
first_name: text('first_name').notNull(),
last_name: text('last_name'),
email: text('email').notNull().unique(),
role: UserRoleEnum('role').notNull().default(UserRole.USER),
organization_id: text('organization_id').references(() => organizations.id, {
onDelete: 'cascade',
}),
});
export function buildFakeUser(
options: {
baseOverride?: Partial<TBaseModel>;
override?: Partial<TWithoutBaseModel<TUser>>;
} = {},
): TUser {
const { baseOverride, override } = options;
const base = buildFakeBase(baseOverride);
const user: TUser = {
...base,
first_name: faker.person.firstName(),
last_name: faker.person.lastName(),
email: faker.internet.email(),
role: faker.helpers.enumValue(UserRole),
organization_id: faker.string.uuid(),
};
return { ...user, ...override };
}
const buildFakeBase = (override?: Partial<TBaseModel>): TBaseModel => {
const base = {
id: faker.string.uuid(),
created_at: faker.date.past(),
updated_at: faker.date.recent(),
is_active: faker.datatype.boolean(),
deleted_at: null,
};
return {
...base,
...override,
};
};
This is a basic model to look at, but some models have nested objects, in which case you'd want to adjust the logic to be similar to something as follows:
export function buildFakeFoo(
options: {
baseOverride?: Partial<TBaseModel>;
override?: Omit<Partial<TWithoutBaseModel<TFoo>>, 'nested_c'> & {
nested_c?: Partial<TWithoutBaseModel<TFoo>['nested_c']>;
};
} = {},
): TFoo {
const { baseOverride, override } = options;
const { nested_c, ...rest } = override ?? {};
const base = buildFakeBase(baseOverride);
const foo: TFoo = {
...base,
property_a: faker.lorem.words(),
property_b: faker.lorem.words(),
nested_c: {
nested_property_x: faker.lorem.words(),
nested_property_y: faker.lorem.words(),
...nested_c,
},
};
return { ...foo, ...rest };
}
You could also have a utility for your factories of something like this, to help you generate multiple objects to test certain UI components (ie. data tables, lists, etc.).
export function buildMany<T>(factory: (index: number) => T, count = 1): T[] {
if (count <= 0) return [];
return Array.from({ length: count }, (_, index) => factory(index));
}
To Test, or Not To Test?
For those of you who don't know, there's a few things that most developers hate:
- Writing tests
- Tests failing
- Having to fix failing tests
- Not having tests
This puts us in a tricky situation of having to decide: What is worth testing? To answer this question there are a few heuristics I use to judge whether or nothing something should be tested. However, these heuristics only work if the code you write is modular and 'testable' as previously explored.
Does this code deal with data transformation?
├─ No
│ - Example: making an API call and passing the response directly through,
│ rendering a button, or forwarding a request to another service.
│ - These may mean lots of mocks with little payoff.
│ - Decision: Testing optional, depends on your team’s culture & bandwidth.
│
└─ Yes
- Example: parsing an API response, converting currencies, validating inputs,
applying business rules like discounts or tax.
- These are logic-heavy and prone to subtle bugs.
↓
How bad would it be if this logic broke?
├─ Very destructive / critical
│ - Could corrupt data, cause financial mistakes, break key workflows,
│ or lead to downtime.
│ - Example: payment calculations, account balance updates, security checks.
│ - Decision: ✅ Write tests. This is worth the investment.
│
└─ Not very destructive
- Example: formatting a date string, sorting a small list, cosmetic
display changes.
- The system can still function even if this fails.
↓
How often does this code change?
├─ Changes often
│ - Example: marketing copy logic, fast-changing UI transformations,
│ experimental features under active iteration.
│ - Frequent test updates may slow you down more than they help.
│ - Decision: ⚠️ Skip or keep tests very light.
│
└─ Rarely changes (stable logic)
- Example: standardized parsing, business rules, utility functions,
domain rules that don’t shift often.
- Rarely breaks, but if it does, you’ll want confidence.
- Decision: ✅ Test it — low maintenance cost, high long-term value.