Test Private Logic Through Public APIs
Let's say we have a module that imports a warehouse's products and fetches their prices. Each price fetch is limited to a batch of 50 items per request1, and the module returns a map of each item's price2:
export async function importProducts(
products: Product[],
warehouseId: string,
): Promise<Record<string, number>> {
const priceResults: PriceEntry[] = [];
const batches = chunkByFifty(products);
for (const batch of batches) {
const res = await fetchPrices(batch, warehouseId);
const data = await res.json();
priceResults.push(...data.prices);
}
return buildSkuPriceMap(priceResults);
}We have three unexported helpers (fetchPrices, chunkByFifty, and buildSkuPriceMap) that do a large portion of that work.
At the bottom of the file we have:
export const internal = {
fetchPrices,
chunkByFifty,
buildSkuPriceMap,
}This set of exports is rather unnecessary, but is not uncommon. We have a public function importProducts and then a sort-of backdoor export of the helpers so they can be unit-tested.
So should we test these helper functions directly? It's understandable to want to test the edge cases of these functions, and that means meeting them where they are now.
An idea here is that we can keep our direct tests and avoid clunky export bounderies by extracting the helpers instead of exporting them from this module. We can move chunkByFifty and buildSkuPriceMap to a pricing.ts and make them public. fetchPrices will stay private here.
Let's see how that would look:
pricing.ts
export type PriceEntry = {
sku: string;
price: number;
};
export function chunkByFifty<T>(items: T[]): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += 50) {
batches.push(items.slice(i, i + 50));
}
return batches;
}
export function buildSkuPriceMap(entries: PriceEntry[]): Record<string, number> {
const map: Record<string, number> = {};
for (const entry of entries) {
if (entry.price == null || Number.isNaN(entry.price)) {
continue;
}
map[entry.sku] = entry.price;
}
return map;
}Now pricing.ts doesn't import anything and doesn't have to know the existence of Product! Our chunkByFifty is generic and buildSkuPriceMap only handles entries.
product-import.ts
import { buildSkuPriceMap, chunkByFifty, type PriceEntry } from "./pricing";
export type Product = {
sku: string;
name: string;
};
async function fetchPrices(batch: Product[], warehouseId: string): Promise<Response> {
const skus = batch.map((product) => product.sku).join(",");
const res = await fetch(`/api/warehouses/${warehouseId}/prices?skus=${skus}`);
if (!res.ok) {
throw new Error(`price service returned ${res.status}`);
}
return res;
}
export async function importProducts(
products: Product[],
warehouseId: string,
): Promise<Record<string, number>> {
const priceResults: PriceEntry[] = [];
const batches = chunkByFifty(products);
for (const batch of batches) {
const res = await fetchPrices(batch, warehouseId);
const data = await res.json();
priceResults.push(...data.prices);
}
return buildSkuPriceMap(priceResults);
}Where importProducts is just like the initial snippet from the beginning. This pattern lets us import the helpers and avoid exposing them where they don't quite belong. Our fetchPrices stays private as we had originally intended!
pricing.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { buildSkuPriceMap, chunkByFifty } from "./pricing";
test("chunkByFifty keeps a short tail in its own batch", () => {
const batches = chunkByFifty(Array.from({ length: 51 }, (_, i) => i));
assert.equal(batches.length, 2);
assert.equal(batches[1].length, 1);
});
test("buildSkuPriceMap lets the last entry win on duplicate skus", () => {
const map = buildSkuPriceMap([
{ sku: "A1", price: 1099 },
{ sku: "A1", price: 899 },
]);
assert.deepEqual(map, { A1: 899 });
});Previously, we needed that internal export to have tests like this. Now our testing strategy can remain the same while maintaining more stable public/private definitions. The idea is that we test logic through the public API that owns it. If a private function needs more direct tests, then maybe that means it should be moved to a better-fitted domain module.
Footnotes