Привет, Хабр!
Самая распространённая ошибка в тестировании асинхронного кода — уверенность, что зелёный тест означает «проверка внутри отработала и прошла». Вы написали 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, который вы вернули, а тест без единой выполненной проверки считается пройденным. Всё, что разобрано дальше, — следствия этих двух правил.
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; проследите, чтобы такие отклонения в вашем окружении и тест‑раннере не уходили в тихое предупреждение в логах, а заметно роняли тест.
Как смотреть на чужой асинхронный тест
Сведём всё в список — то, что должно останавливать взгляд на код‑ревью.
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.
На уроках можно познакомиться с преподавателями-практиками, протестировать формат обучения и задать вопросы по автоматизации.
