Fixing Flaky TypeScript Tests: Async, Time, and APIs

I was staring at my terminal at 11pm last Tuesday. The CI pipeline failed on a test that had just passed locally 40 times in a row. A classic flaky test. TypeScript is fantastic at catching typos and object shape mismatches before you even run your code. I rely on it heavily. But it does absolutely nothing to protect you from the runtime chaos of async race conditions, network latency, and the relentless march of time. If your test suite occasionally fails and your solution is to just hit the “Re-run jobs” button in GitHub Actions, your tests are broken. I’ve spent the last few years untangling these messes. Here is what actually works when you need to lock down functions, APIs, the DOM, and time-based events.

The Easy Stuff: Pure Functions

TypeScript code on monitor - Reduce Development Cost, Accelerate Code Delivery with TypeScript
TypeScript code on monitor – Reduce Development Cost, Accelerate Code Delivery with TypeScript
Pure functions are the dream. You put data in, you get data out. No side effects. Testing them is boring, which is exactly how testing should be. The only real trick with pure TypeScript functions is testing your edge cases and making sure your type guards actually work at runtime. TypeScript types disappear after compilation. If an API hands you a string where you expected an array, your type definitions won’t save you.
import { describe, it, expect } from 'vitest';

// The implementation
function processUserRoles<T extends string>(roles: unknown): T[] {
  if (!Array.isArray(roles)) {
    throw new Error('Roles must be an array');
  }
  return roles.filter((role): role is T => typeof role === 'string');
}

// The test
describe('processUserRoles', () => {
  it('filters out non-string garbage from the payload', () => {
    const dirtyData = ['admin', 42, null, 'editor', undefined];
    const result = processUserRoles<'admin' | 'editor'>(dirtyData);
    
    expect(result).toEqual(['admin', 'editor']);
  });

  it('throws when handed an object instead of an array', () => {
    expect(() => processUserRoles({ role: 'admin' })).toThrow(/must be an array/);
  });
});

Taming API Calls and Async Boundaries

This is where most test suites turn into a dumpster fire. People love to mock fetch manually. Don’t do this. I finally ripped out all our custom fetch mocks and switched our entire staging cluster to MSW (Mock Service Worker) last month. Running Node.js 22.13.0, the native fetch implementation is solid, but mocking it manually is a nightmare of unresolved promises and leaked state. MSW intercepts requests at the network level. Your code doesn’t know it’s being mocked.
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

// The async function we want to test
async function fetchUserProfile(userId: string): Promise<{ name: string }> {
  const response = await fetch(https://api.example.com/users/${userId});
  if (!response.ok) throw new Error('Network response was not ok');
  return response.json();
}

// Set up the network interceptor
const server = setupServer(
  http.get('https://api.example.com/users/:id', ({ params }) => {
    if (params.id === '999') {
      return new HttpResponse(null, { status: 404 });
    }
    return HttpResponse.json({ name: 'Jane Doe' });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('fetchUserProfile', () => {
  it('parses the user profile on a 200 OK', async () => {
    const data = await fetchUserProfile('123');
    expect(data.name).toBe('Jane Doe');
  });

  it('throws an error on a 404', async () => {
    await expect(fetchUserProfile('999')).rejects.toThrow('Network response was not ok');
  });
});
Notice how clean that is. No vi.spyOn(global, 'fetch'). No wrestling with Promise.resolve. It just behaves like a real network.

The DOM: Testing What the User Actually Sees

TypeScript code on monitor - Say No to JS Frameworks: Use TypeScript + Native Web for Fast ...
TypeScript code on monitor – Say No to JS Frameworks: Use TypeScript + Native Web for Fast …
Testing the DOM in TypeScript usually means wrestling with jsdom and React Testing Library. The trick here isn’t the TypeScript part. It’s knowing when the DOM has actually finished updating. Async state updates are the number one cause of flaky UI tests. You click a button, the test checks the DOM instantly, the state hasn’t updated yet, the test fails.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';

// A component with artificial delay
function StatusButton() {
  const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');

  const handleClick = async () => {
    setStatus('saving');
    // Simulate network delay
    await new Promise(resolve => setTimeout(resolve, 100));
    setStatus('saved');
  };

  return (
    <button onClick={handleClick} disabled={status === 'saving'}>
      {status === 'idle' ? 'Save Changes' : 
       status === 'saving' ? 'Saving...' : 'Saved!'}
    </button>
  );
}

describe('StatusButton', () => {
  it('transitions through saving states correctly', async () => {
    const user = userEvent.setup();
    render(<StatusButton />);

    const button = screen.getByRole('button', { name: 'Save Changes' });
    
    // Trigger the click
    await user.click(button);

    // Synchronous check for immediate state change
    expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled();

    // Async wait for the final state
    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Saved!' })).not.toBeDisabled();
    });
  });
});

The Final Boss: Time and Scheduling

Now for the absolute worst part of testing. Time. Got a cron job? A polling mechanism? Code that assumes 30 days have passed? Most people reach for vi.useFakeTimers() or Jest’s equivalent. I hate them. They mutate global state. Here’s a nasty gotcha: if you use global fake timers with async DOM rendering in React 19, you’ll randomly deadlock your test runner if an unhandled promise rejection gets swallowed by the fake clock. I burned three hours on that exact issue. The actual fix? Dependency injection. Stop calling Date.now() or setTimeout directly in your business logic. It makes your code untestable without global hacks. Instead, pass a clock interface into your functions. When you inject a deterministic clock during testing, you can verify the exact same code that runs in production. You just manually advance the time.
// 1. Define the interface
interface Clock {
  now(): number;
}

// 2. The production implementation
const systemClock: Clock = {
  now: () => Date.now()
};

// 3. The test implementation (A simple TestClock)
class TestClock implements Clock {
  private currentTime: number;

  constructor(startTime: number = 0) {
    this.currentTime = startTime;
  }

  now(): number {
    return this.currentTime;
  }

  advanceBy(ms: number): void {
    this.currentTime += ms;
  }
}

// 4. The business logic (depends on the abstraction)
class SubscriptionService {
  constructor(private clock: Clock) {}

  isExpired(expirationDateMs: number): boolean {
    return this.clock.now() > expirationDateMs;
  }
}

// 5. The test
describe('SubscriptionService', () => {
  it('correctly identifies expired subscriptions using time manipulation', () => {
    // Start our fake universe at exactly 1000ms
    const testClock = new TestClock(1000);
    const service = new SubscriptionService(testClock);

    // Expiration is set to 5000ms
    const expiration = 5000;

    // At 1000ms, it's not expired
    expect(service.isExpired(expiration)).toBe(false);

    // Fast forward time by 3000ms (Current time: 4000ms)
    testClock.advanceBy(3000);
    expect(service.isExpired(expiration)).toBe(false);

    // Fast forward past the expiration (Current time: 5001ms)
    testClock.advanceBy(1001);
    expect(service.isExpired(expiration)).toBe(true);
  });
});
This pattern saved my ass on a billing project. We had complex retry logic that backed off exponentially over 48 hours. Trying to test that with real timeouts or global fake timers was a flaky, leaking disaster. By injecting a TestClock, we cut our CI pipeline from 14m 20s to just under 3 minutes. The tests run instantly because no actual waiting occurs. We just tell the system what time it is. I expect we’ll see native dependency injection for side effects become standard in major TS frameworks by Q1 2027. We are already seeing functional programming patterns push heavily in this direction. Until then, stop relying on global mocks. Pass your dependencies explicitly, control your side effects, and your tests will actually stay green.

Zahra Al-Farsi

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *