Вы неправильно тестируете асинхронный код: тест проходит раньше, чем выполняется проверка
- среда, 20 мая 2026 г. в 00:00:09
Привет, Хабр!
Самая распространённая ошибка в тестировании асинхронного кода — уверенность, что зелёный тест означает «проверка внутри отработала и прошла». Вы написали test(...), внутри есть expect, прогон зелёный — значит, код проверен. Кажется очевидным.
А потом выясняется, что тест с заведомо неверным expect всё равно остаётся зелёным, что проверка на выброшенную ошибку проходит и тогда, когда код перестал эту ошибку выбрасывать, а функция, давно сломанная, годами имеет при себе зелёный тест.
Корень у всех этих случаев один: тест‑раннер не выполняет ваш код и не ждёт его завершения. Он ждёт ровно то, что вы ему вернули. И если вы не вернули ему Promise со своей асинхронной работой, он считает тест законченным в тот момент, когда функция теста вернула управление, а проверка выполнится позже, в пустоте, где её провал уже никто не заметит.
В статье разберём, как именно раннер решает, что тест прошёл, почему .then без return выполняется уже после теста, почему try/catch в async‑тесте — частый источник ложного зелёного, что не так с forEach и setTimeout внутри тестов и какие инструменты не дают тесту соврать. Примеры на Jest, но контракт у Mocha, vitest и прочих тот же.
Начнём с механики, из которой дальше следует всё остальное.
Когда вы пишете test('...', fn), раннер просто вызывает fn(). По умолчанию он ожидает синхронный тест: выполнил функцию, никто не бросил исключение — зелёный, бросили — красный. Вся «асинхронность» теста для раннера держится на одном — на том, что функция теста ему вернула.
Если fn вернула Promise (а async‑функция возвращает Promise всегда), раннер этот Promise дожидается: тест зелёный, если он завершился успешно, и красный, если он отклонился или внутри что‑то бросило. Если же fn не вернула ничего, раннер не имеет ни малейшего понятия, что где‑то ещё крутится асинхронная работа. Для него тест закончился в момент return из функции.
И вторая половина механики, без которой картина неполная: Jest по умолчанию считает тест пройденным, если в нём не выполнилось ни одной проверки. Ноль вызовов expect — это не ошибка, это зелёный тест.
Сложите эти два факта вместе: раннер дожидается только того Promise, который вы вернули, а тест без единой выполненной проверки считается пройденным. Всё, что разобрано дальше, — следствия этих двух правил.
Тест, который выглядит совершенно нормально:
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, и всё станет хорошо». Не станет:
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, который вы ему отдали, а не всю асинхронную работу, которую вы запустили. Запустить и уйти — это не протестировать.
Предыдущие примеры ломались грубо, проверка вообще не доезжала. Этот случай потоньше: проверка написана правильно, корректно дождана, и тест всё равно врёт.
Задача — проверить, что на плохих данных функция бросает ошибку:
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 с асинхронным колбэком:
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; проследите, чтобы такие отклонения в вашем окружении и тест‑раннере не уходили в тихое предупреждение в логах, а заметно роняли тест.
Сведём всё в список — то, что должно останавливать взгляд на код‑ревью.
async у тест‑функции, но внутри ни одного await — почти наверняка асинхронная работа запущена и брошена.
.then( внутри теста, перед которым нет return или await — проверка в колбэке уйдёт в пустоту.
expect внутри колбэка setTimeout, setInterval или обработчика события — тест закончится раньше, чем колбэк сработает.
try/catch вокруг await без expect.assertions — проверка ошибки, которая молчит, если ошибки не случилось.
.resolves или .rejects без await или return — матчер вернул Promise, которого никто не дождался.
forEach или map с async‑колбэком, результат которого никуда не уходит — итерации не дождутся.
done‑стиль без try/catch вокруг проверки — провал проверки превратится в таймаут вместо внятного сообщения.
Ни одного
expect.assertionsилиhasAssertionsв файле с асинхронными тестами — ничто не подстраховывает от молчаливо пропущенной проверки.
Тест‑раннер не наблюдает за вашим кодом, он наблюдает за одним‑единственным Promise — тем, который вы ему вернули из функции теста. Всё, что зелёный async‑тест на самом деле доказывает, что этот Promise успешно разрешился. Выполнились ли при этом проверки, дождался ли тест настоящего результата или закончился раньше — об этом зелёный статус не сообщает ничего.
Отсюда привычка: каждый раз, когда в тесте появляется асинхронность, спрашивайте себя: дойдёт ли раннер до этой проверки, или я запустил работу и ушёл? Если между запуском асинхронной операции и концом теста нет ни await, ни return, ни done — проверка внутри неё ничего не значит.

Если хотите продолжить тему тестов на практике, обратите внимание на бесплатные открытые уроки:
21 мая, 20:00 — «ИИ как ассистент QA: пишем API-тесты с нуля».
Посмотрим, как использовать ИИ для подготовки API-тестов и быстрее переходить от сценария к рабочей проверке.
4 июня, 20:00 — «API под контролем: тестирование сервисов с помощью Postman».
Разберём практику тестирования API и покажем, как проверять сервисы через Postman.
На уроках можно познакомиться с преподавателями-практиками, протестировать формат обучения и задать вопросы по автоматизации.