惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

人人都是产品经理
人人都是产品经理
MyScale Blog
MyScale Blog
Y
Y Combinator Blog
罗磊的独立博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
P
Proofpoint News Feed
Google DeepMind News
Google DeepMind News
V
Vulnerabilities – Threatpost
T
The Blog of Author Tim Ferriss
云风的 BLOG
云风的 BLOG
Recorded Future
Recorded Future
N
News and Events Feed by Topic
B
Blog RSS Feed
阮一峰的网络日志
阮一峰的网络日志
博客园_首页
C
CXSECURITY Database RSS Feed - CXSecurity.com
博客园 - 【当耐特】
N
Netflix TechBlog - Medium
博客园 - 叶小钗
B
Blog
Vercel News
Vercel News
T
Tenable Blog
T
The Exploit Database - CXSecurity.com
Spread Privacy
Spread Privacy
T
Threat Research - Cisco Blogs
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Last Week in AI
Last Week in AI
F
Fortinet All Blogs
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Microsoft Security Blog
Microsoft Security Blog
S
Securelist
Microsoft Azure Blog
Microsoft Azure Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
P
Palo Alto Networks Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
D
DataBreaches.Net
Cyberwarzone
Cyberwarzone
Engineering at Meta
Engineering at Meta
Martin Fowler
Martin Fowler
G
GRAHAM CLULEY
Project Zero
Project Zero
Cisco Talos Blog
Cisco Talos Blog
A
Arctic Wolf
C
CERT Recently Published Vulnerability Notes
L
LangChain Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
C
Check Point Blog
A
About on SuperTechFans
W
WeLiveSecurity
The GitHub Blog
The GitHub Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Вы неправильно тестируете асинхронный код: тест проходит раньше, чем выполняется проверка
badcasedaily · 2026-05-19 · via Все публикации подряд на Хабре

Вы неправильно тестируете асинхронный код: тест проходит раньше, чем выполняется проверка

Уровень сложностиСредний

Время на прочтение9 мин

Охват и читатели233

Туториал

Привет, Хабр!

Самая распространённая ошибка в тестировании асинхронного кода — уверенность, что зелёный тест означает «проверка внутри отработала и прошла». Вы написали test(...), внутри есть expect, прогон зелёный — значит, код проверен. Кажется очевидным.

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

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

В статье разберём, как именно раннер решает, что тест прошёл, почему .then без return выполняется уже после теста, почему try/catch в async‑тесте — частый источник ложного зелёного, что не так с forEach и setTimeout внутри тестов и какие инструменты не дают тесту соврать. Примеры на Jest, но контракт у Mocha, vitest и прочих тот же.

Как тест‑раннер решает, что тест прошёл

Начнём с механики, из которой дальше следует всё остальное.

Когда вы пишете test('...', fn), раннер просто вызывает fn(). По умолчанию он ожидает синхронный тест: выполнил функцию, никто не бросил исключение — зелёный, бросили — красный. Вся «асинхронность» теста для раннера держится на одном — на том, что функция теста ему вернула.

Если fn вернула Promiseasync‑функция возвращает Promise всегда), раннер этот Promise дожидается: тест зелёный, если он завершился успешно, и красный, если он отклонился или внутри что‑то бросило. Если же fn не вернула ничего, раннер не имеет ни малейшего понятия, что где‑то ещё крутится асинхронная работа. Для него тест закончился в момент return из функции.

И вторая половина механики, без которой картина неполная: Jest по умолчанию считает тест пройденным, если в нём не выполнилось ни одной проверки. Ноль вызовов expect — это не ошибка, это зелёный тест.

Сложите эти два факта вместе: раннер дожидается только того Promise, который вы вернули, а тест без единой выполненной проверки считается пройденным. Всё, что разобрано дальше, — следствия этих двух правил.

then без return: проверка, которая выполняется в пустоту

Тест, который выглядит совершенно нормально:

test('getUser возвращает имя', () => {
  fetchUser(1).then(user => {
    expect(user.name).toBe('Анна');
  });
});

fetchUser(1) возвращает Promise. .then(...) регистрирует колбэк — он встанет в очередь микрозадач и выполнится позже. А прямо сейчас функция теста доходит до конца и возвращает undefined. Раннер видит синхронный тест, который завершился без исключений, и отмечает его зелёным.

Колбэк из .then выполнится через несколько микрозадач — когда тест уже давно пройден. Внутри сработает expect(user.name).toBe('Анна'). Допустим, на самом деле user.name — это 'Борис'. expect честно бросит исключение. Вот только бросит он его в микрозадаче, которую никто не слушает: функция теста вернула управление, раннер ушёл к следующему тесту.

Проверьте сами — этот тест зелёный и при 'Анна', и при 'Борис'. Он не проверяет ничего. Он запускает fetchUser, регистрирует колбэк и заканчивается, не дождавшись ни ответа, ни проверки.

Чинится одним словом — return:

test('getUser возвращает имя', () => {
  return fetchUser(1).then(user => {
    expect(user.name).toBe('Анна');
  });
});

Теперь функция теста возвращает раннеру Promise. Раннер его дожидается, а вместе с ним дожидается и колбэка с проверкой. Тот же эффект даёт async/await, к нему перейдём в следующем блоке.

async без await: тот же баг под новой обёрткой

Естественная реакция — «сделаю тест async, и всё станет хорошо». Не станет:

test('getUser возвращает имя', async () => {
  fetchUser(1).then(user => {
    expect(user.name).toBe('Анна');
  });
});

async перед функцией не делает асинхронным то, что внутри. Он лишь означает, что функция вернёт Promise. Раннер дождётся этого Promise — но тот завершится в ту же секунду, когда тело функции дойдёт до конца. А тело снова не дождалось внутреннего .then: между fetchUser(1) и концом функции нет ни одного await. Promise теста разрешается, раннер доволен, проверка опять уходит в пустоту.

async помогает, только когда внутри есть await:

test('getUser возвращает имя', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Анна');
});

await приостанавливает функцию теста до того, как fetchUser отдаст результат. Тело продолжится только после, expect выполнится внутри той же функции, и его исключение попадёт в Promise, которого ждёт раннер.

Сформулирую правило, к которому всё сводится: раннер ждёт тот Promise, который вы ему отдали, а не всю асинхронную работу, которую вы запустили. Запустить и уйти — это не протестировать.

try/catch в async‑тесте: самый частый ложный зелёный

Предыдущие примеры ломались грубо, проверка вообще не доезжала. Этот случай потоньше: проверка написана правильно, корректно дождана, и тест всё равно врёт.

Задача — проверить, что на плохих данных функция бросает ошибку:

test('бросает ошибку на несуществующем id', async () => {
  try {
    await fetchUser(999);
  } catch (e) {
    expect(e.message).toBe('not found');
  }
});

Выглядит супер: await на месте, expect стоит внутри catch. Тест зелёный.

Теперь представьте, что в fetchUser заехал баг и на несуществующем id функция больше не бросает ошибку, а возвращает undefined. Что произойдёт с тестом? await fetchUser(999) отработает без исключения. Блок catch не выполнится — бросать нечего. expect внутри catch не вызовется ни разу. try спокойно дойдёт до конца, функция теста завершится, Promise разрешится.

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

Лечится двумя способами. Первый — заявить раннеру, сколько проверок обязано выполниться:

test('бросает ошибку на несуществующем id', async () => {
  expect.assertions(1);
  try {
    await fetchUser(999);
  } catch (e) {
    expect(e.message).toBe('not found');
  }
});

expect.assertions(1) говорит Jest: за этот тест должна выполниться ровно одна проверка. Если catch не отработал и expect не вызвался, Jest валит тест с понятным сообщением — ждал одну проверку, получил ноль.

Второй способ, который я считаю лучше, — не писать try/catch руками, а взять матчер .rejects:

test('бросает ошибку на несуществующем id', async () => {
  await expect(fetchUser(999)).rejects.toThrow('not found');
});

.rejects сам провалит тест, если Promise вместо отклонения вдруг разрешится. При этом нельзя забыть await (или return): матчеры .rejects и .resolves тоже возвращают Promise, и без await вы получаете ровно ту же дыру, что и в первом блоке статьи.

forEach и setTimeout: проверки в коде, до которого тест не дойдёт

Та же дыра принимает разные формы. Две самые частые.

forEach с асинхронным колбэком:

test('все пользователи активны', async () => {
  users.forEach(async (user) => {
    const full = await fetchUser(user.id);
    expect(full.active).toBe(true);
  });
});

forEach не умеет ждать. Каждый async‑колбэк возвращает Promise, и forEach молча эти Promise выбрасывает. Функция теста доходит до конца, не дождавшись ни одной итерации; Promise теста разрешается; раннер ставит зелёный. Все expect выполняются потом, в пустоте. Тест проходит, даже если половина пользователей неактивна.

Здесь нужен for...of, который умеет приостанавливаться на await:

test('все пользователи активны', async () => {
  for (const user of users) {
    const full = await fetchUser(user.id);
    expect(full.active).toBe(true);
  }
});

Второй случай — setTimeout и колбэк‑API:

test('колбэк получает данные', () => {
  loadData((err, data) => {
    expect(data).toBe('ok');
  });
});

Тест запускает loadData, регистрирует колбэк и заканчивается. Колбэк сработает позже, в пустоте. Для колбэк‑API Jest даёт аргумент done:

test('колбэк получает данные', (done) => {
  loadData((err, data) => {
    try {
      expect(data).toBe('ok');
      done();
    } catch (e) {
      done(e);
    }
  });
});

Получив параметр done, раннер не считает тест законченным, пока done() не будет вызван. У done свои острые углы. Если loadData по какой‑то причине не вызовет колбэк, done не вызовется никогда — тест провисит до таймаута (по умолчанию 5 секунд в Jest) и упадёт по нему. Это хотя бы провал. Хуже, если expect внутри колбэка бросит исключение: без try/catch оно не дойдёт до раннера понятным образом, done() после него не выполнится, и вместо внятного «ожидал ok, получил X» вы снова получите малопонятный таймаут. Поэтому проверку в колбэке заворачивают в try/catch и передают ошибку в done(e). И помните: повторный вызов done() Jest считает ошибкой теста.

Колбэк‑стиль вообще стоит по возможности оборачивать в Promise и тестировать через async/await — меньше церемоний, меньше способов ошибиться.

Инструмент, который не даёт тесту соврать

Через все примеры красной нитью шла одна беда: проверка не выполнилась, а тест об этом промолчал. У этой беды есть прямое лекарство — expect.assertions(n) и expect.hasAssertions().

Механика очень простая. Jest считает, сколько вызовов expect реально произошло за время теста. expect.assertions(3) требует ровно три, expect.hasAssertions() — хотя бы одну. Не совпало — тест красный. Это превращает «проверка тихо не выполнилась» в честный, заметный провал. Официальная документация Jest рекомендует expect.assertions именно для случая с try/catch и отклонением Promise — того самого, из‑за которого ложный зелёный проходит незамеченным.

Расставлять это руками в каждом тесте утомительно, поэтому удобнее включить проверку глобально. В файл из setupFilesAfterEnv добавляется:

beforeEach(() => {
  expect.hasAssertions();
});

Теперь любой тест, в котором не выполнилось ни одной проверки, автоматически красный — по всему проекту. Для асинхронных тестов это самая дешёвая страховка из существующих.

Вторая мера касается необработанных отклонений Promise. Забытый return оставляет за собой «висячий» отклонённый Promise; проследите, чтобы такие отклонения в вашем окружении и тест‑раннере не уходили в тихое предупреждение в логах, а заметно роняли тест.

Как смотреть на чужой асинхронный тест

Сведём всё в список — то, что должно останавливать взгляд на код‑ревью.

  1. async у тест‑функции, но внутри ни одного await — почти наверняка асинхронная работа запущена и брошена.

  2. .then( внутри теста, перед которым нет return или await — проверка в колбэке уйдёт в пустоту.

  3. expect внутри колбэка setTimeout, setInterval или обработчика события — тест закончится раньше, чем колбэк сработает.

  4. try/catch вокруг await без expect.assertions — проверка ошибки, которая молчит, если ошибки не случилось.

  5. .resolves или .rejects без await или return — матчер вернул Promise, которого никто не дождался.

  6. forEach или map с async‑колбэком, результат которого никуда не уходит — итерации не дождутся.

  7. done‑стиль без try/catch вокруг проверки — провал проверки превратится в таймаут вместо внятного сообщения.

Ни одного expect.assertions или hasAssertions в файле с асинхронными тестами — ничто не подстраховывает от молчаливо пропущенной проверки.

Что в итоге

Тест‑раннер не наблюдает за вашим кодом, он наблюдает за одним‑единственным Promise — тем, который вы ему вернули из функции теста. Всё, что зелёный async‑тест на самом деле доказывает, что этот Promise успешно разрешился. Выполнились ли при этом проверки, дождался ли тест настоящего результата или закончился раньше — об этом зелёный статус не сообщает ничего.

Отсюда привычка: каждый раз, когда в тесте появляется асинхронность, спрашивайте себя: дойдёт ли раннер до этой проверки, или я запустил работу и ушёл? Если между запуском асинхронной операции и концом теста нет ни await, ни return, ни done — проверка внутри неё ничего не значит.

Если хотите продолжить тему тестов на практике, обратите внимание на бесплатные открытые уроки:

На уроках можно познакомиться с преподавателями-практиками, протестировать формат обучения и задать вопросы по автоматизации.