Кажется, что нет такого проекта, в котором не нужно делать запросы к серверу. И всегда хорошей идеей написать отдельную абстракцию для описания подобной коммуникации. Это добавляет коду не только читаемости и переиспользуемости, но и дает возможность быстро и эффективно написать тест для подобной функциональности.

class NetworkService {
    constructor(private readonly fetch: typeof fetch) {}
    getUser: (): Promise<IUser> => this.fetch('/api/user').then(d => d.json())
}

function getUsername(api: Pick<NetworkService, 'getUser'>) {
    return api.getUser().then(user => user.name);
}

test('getUsername', async () => {
    const name = await getUsername({
        getUser: () => Promise.resolve({ name: 'test' } as IUser)
    });
    expect(name).toBe('test');
});

И для среднестатистического проекта такого подхода будет достаточно. Но иногда возникают требования, что нужно переопределить сам fetch. Например, у нас в проекте, мы разрабатываем extension для браузера и хотим делать запросы через serviceWorker. И возникает требования переимплементировать Fetch API. Как мы знаем fetch возвращает примитив Response, для создания которого нужно передать первым аргументом ReadableStream, а вторым характеристики ответа. Таким образом мы можем использовать следующую конструкцию.

function buildFetchEmulator(
  controller: (url: RequestInfo, init: RequestInit | undefined) =>
    Promise<[ResponseInit, (add: (msg: object) => void) => Promise<void>]>
) {
  const cFetch: typeof fetch = async (url, init) => {
    // извлекаем характеристики ответа и контроллер который будет генерировать данные
    const [responseInit, ctrl] = await controller(url, init);

    // описываем поведение ReadableStream
    const stream = new ReadableStream({
      async start(controller) {
        try {
          const enc = new TextEncoder();
          await ctrl((msg) => {
            // преобразуем обьект в строку
            const message = JSON.stringify(msg);
            // строку в Uint8Array
            const buf = enc.encode(message);
            // передаем данные в ReadableStream controller
            controller.enqueue(buf);
          });
          controller.close();
        } catch (err) {
          controller.error(err);
          controller.close();
        }
      },
    });

    return new Response(stream, responseInit);
  };

  return cFetch;
}

test('getUser', () => {
    const customFetch = buildFetchEmulator(async (url, init) => {
        if (url === '/api/user') {
            return [{
                headers: new Headers({ 'content-type': contentType }),
                status: 200,
                statusText: 'OK',
            }, async (add) => {
                // Здесь мы можем использовать любую логику для транспорта данных
                add({ name: 'test' })
            }];
        }
        throw new Error('not implemented');
    })
    const network = new NetworkService();

    const name = await getUsername(network);
    expect(name).toBe('test');
});

Здесь хочется еще раз отметить, что для случаев когда нет необходимости эмулировать fetch и писать кастомные адапторы, первый способ намного лаконичнее и удобнее, но при данном подходе мы можем тестировать даже внешние библиотеки которые используют fetch напрямую, например как @microsoft/fetch-event-source.