Well, I have to admit something – I used to hate writing tests too. For years, my “testing strategy” was basically running the app, clicking the button that broke last time, and praying nothing else caught on fire. And it wasn’t because I didn’t care about quality—I just hated the friction. Setting up the harness, fighting with config files, getting those cryptic ts-node errors… it was a nightmare.
But things have changed. And if you’re still skipping unit tests in TypeScript because “it’s too hard to set up,” you’re out of excuses. The tooling has gotten shockingly good. I spent last Tuesday migrating a legacy backend service from a tangled mess of untyped JS to strict TypeScript, and honestly? The tests saved my bacon more times than I’d like to admit.
I’m writing this running Node 22.12.0 and TypeScript 5.7.3. And if you’re on anything older, you should probably update your environment. Seriously. The performance gains in the test runner alone are worth it.
The “It Just Works” Setup
Forget Jest. I know, I know—it was the standard for a decade. But the config overhead for TypeScript is just too heavy. I switched to Vitest about two years ago and haven’t looked back. It handles TypeScript out of the box. No ts-jest, no babel config hell. It just runs.
And you know, the thing about unit testing in TS is that strict types are actually your first test. I mean, before you even write an expect(), the compiler is yelling at you. And that’s a good thing.
1. Testing Pure Functions (The Easy Stuff)
Let’s start small. Pure functions are the easiest things to test because they don’t depend on the outside world. Input in, output out. No database connections, no API calls, no drama.
Here is a classic example: a cart calculator. I wrote this helper last week for an e-commerce gig.
// src/cart.ts
export interface CartItem {
id: string;
price: number;
quantity: number;
discount?: number; // 0.1 means 10% off
}
export const calculateTotal = (items: CartItem[]): number => {
return items.reduce((total, item) => {
const discount = item.discount ?? 0;
const itemTotal = item.price * item.quantity * (1 - discount);
return total + itemTotal;
}, 0);
};
// src/cart.test.ts
import { describe, it, expect } from 'vitest';
import { calculateTotal } from './cart';
describe('Cart Calculator', () => {
it('calculates total with mixed discounts', () => {
const items = [
{ id: '1', price: 100, quantity: 2, discount: 0.5 }, // 100 * 2 * 0.5 = 100
{ id: '2', price: 50, quantity: 1 }, // 50 * 1 = 50
];
const result = calculateTotal(items);
// Floating point math is weird, so use closeTo
expect(result).toBeCloseTo(150, 2);
});
it('handles empty carts without crashing', () => {
expect(calculateTotal([])).toBe(0);
});
});
2. The Async Nightmare (and How to Tame It)
Real apps aren’t just math. They wait for things. Databases, file systems, network requests. And testing async code used to be flaky as hell, but async/await makes it readable.
The trick is knowing what to test. Don’t test the database driver; test your logic around the data. Here’s a user service method I refactored recently:
// src/userService.ts
interface User {
id: string;
username: string;
isActive: boolean;
}
// Simulating a DB call
const findUserInDb = async (id: string): Promise<User | null> => {
// Imagine a DB query here
return new Promise((resolve) => setTimeout(() => resolve(null), 50));
};
export class UserService {
async activateUser(userId: string): Promise<User> {
const user = await findUserInDb(userId);
if (!user) {
throw new Error(User ${userId} not found);
}
if (user.isActive) {
return user; // Already active, do nothing
}
user.isActive = true;
// await saveUser(user);
return user;
}
}
3. Mocking: The Necessary Evil
This is where everyone gets stuck. You have a function that calls an external API (like Stripe or a weather service). And you cannot—I repeat, cannot—let your unit tests make real network requests. It’s slow, it’s flaky, and if the API goes down, your build fails. Plus, you might get rate-limited.
Dependency Injection (DI) is your friend here. It sounds like fancy architecture talk, but it just means “passing the dependencies in instead of importing them.”
Here is how I structure my API clients now. It makes testing trivial.
// src/weatherClient.ts
export interface IHttpClient {
get(url: string): Promise<any>;
}
export class WeatherService {
constructor(private http: IHttpClient) {}
async getTemperature(city: string): Promise<number> {
const data = await this.http.get(/weather/${city});
return data.temp;
}
}
// src/weatherClient.test.ts
import { describe, it, expect, vi } from 'vitest';
import { WeatherService } from './weatherClient';
describe('WeatherService', () => {
it('returns temperature from API response', async () => {
// Create a fake HTTP client
const mockHttp = {
get: vi.fn().mockResolvedValue({ temp: 72 })
};
const service = new WeatherService(mockHttp);
const temp = await service.getTemperature('London');
expect(temp).toBe(72);
expect(mockHttp.get).toHaveBeenCalledWith('/weather/London');
});
});
4. DOM Testing (When You Must)
Sometimes you have to touch the DOM. Maybe you’re writing a utility that manipulates classes or parses HTML. And you don’t need a browser for this. Environments like jsdom or happy-dom simulate the browser in Node.
I recently had to fix a bug in a tooltip positioning library we use. The logic relied on document.createElement. Here is how you test that without launching Chrome.
// src/domUtils.ts
export const createBadge = (text: string): HTMLElement => {
const div = document.createElement('div');
div.className = 'badge';
div.textContent = text;
document.body.appendChild(div);
return div;
};
// src/domUtils.test.ts
// @vitest-environment happy-dom
import { describe, it, expect, afterEach } from 'vitest';
import { createBadge } from './domUtils';
describe('DOM Utils', () => {
afterEach(() => {
document.body.innerHTML = ''; // Cleanup!
});
it('appends a badge to the body', () => {
createBadge('New');
const badge = document.body.querySelector('.badge');
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe('New');
});
});
Real Talk: A Gotcha That Burned Me
And here is something the documentation usually glosses over: Date mocking. It is the silent killer of test suites.
import { beforeEach, afterEach, vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-02-20T10:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
Just Write the Tests
Look, you don’t need 100% coverage. That’s a vanity metric managers love but devs know is meaningless. I usually aim for about 70-80%, focusing heavily on the complex logic and ignoring the boilerplate getters and setters.
