Как устроены хелперы Angular
Эта страница объясняет, почему хелперы Angular ведут себя именно так. Чтобы писать тесты, она не нужна: как ими пользоваться, показывает страница Angular. Читайте раздел отсюда, когда правило на другой странице вас удивило и хочется понять причину.
Некоторые разделы цитируют исходники самого Angular. Файлы и номера строк взяты из версии, указанной в разделе. Между релизами они сдвигаются.
Точки входа
Почему хелперы Angular разнесены по точкам входа
Импорт vitest-auto-spy/angular загружает весь граф модулей этой точки входа. Хелпер, который набор тестов вызывает один раз в setup-файле, не должен ехать с каждым импортом provideAutoSpy. Поэтому у узких хелперов свои точки входа: /angular-http, /angular-router, /signal-forms, /angular/diagnostics, /angular/doubles и /angular/matchers. trackInjections остаётся в /angular: его модуль всё равно нужен createWithAutoSpies.
/angular также не повторяет всё ядро. Он реэкспортирует лишь несколько корневых хелперов, их список есть на странице Установка. С vitest-auto-spy/bun-angular иначе: это точка входа для раннера, поэтому она реэкспортирует всё ядро.
Почему @angular/material не входит в зависимости
У библиотеки вообще нет зависимостей во время выполнения. А диалог — это форма одной библиотеки компонентов, а не самого Angular. Поэтому подмены диалога Material принимают токен и класс ref аргументами. MatDialogRef служит сразу тремя вещами. Это токен DI. Это форма, с которой сверяется подмена. И из него берётся тип её результата. Material упоминается только в импорте в вашей собственной спеке.
Обёртки openDialogReturning() нет намеренно. Она пересказала бы mockReturnValue другими словами. К тому же ей пришлось бы знать собственный тип MatDialog, а именно его этот дизайн и не пускает.
TestBed
Почему extendWithAutoSpies принимает одну карту
Это правило TestBed, а не ограничение типов. Фикстуры разрешаются лениво и независимо друг от друга. Возьмём base.extend('cart', …).extend('api', …). Фикстура cart настроит тестовый модуль и получит из него сервис через DI, а это создаёт модуль. Фикстура api вызовет configureTestingModule слишком поздно и упадёт с ошибкой Angular "Cannot configure the test module when the test module has already been instantiated". Все провайдеры должны быть известны до первого получения через DI.
beforeEach, который только настраивает модуль, по-прежнему совместим. Он выполняется раньше любой фикстуры, а повторные вызовы configureTestingModule сливаются до первого получения через DI. А вот beforeEach, который получает что-то через DI, уже несовместим. Потом это ничем не исправить: Angular уже всё решил.
extendWithAutoSpies требует Vitest 4.1: типы выводит именно форма-билдер test.extend. На более старом Vitest вызов сразу бросает ошибку (extendWithAutoSpies needs Vitest 4.1 or newer). Без этой проверки старый extend принял бы строку и зарегистрировал фикстуры с именами "0", "1", … А каждый тест получил бы undefined. Vitest не сообщает свою версию, поэтому проверка смотрит, сколько параметров объявляет extend: один до 4.0, три начиная с 4.1.
Почему setupAngularTestEnv запоминает режим на воркер
TestBed.initTestEnvironment можно вызвать один раз на платформу. При isolate: false платформа живёт весь срок воркера. Репозиторий, который переходит на zoneless постепенно, одними setup-файлами этого не выразит. Часть библиотек уже переключена, остальные ещё на zone.js. Второй файл в другом режиме, доставшийся воркеру, падает с Cannot set base providers because it has already been called. И сообщение не называет ни один из файлов.
test.projects в Vitest тоже не спасает: никто не обещает, что воркер обслуживает файлы только одного проекта. Работает другое. Режим определяется по файлу, который сейчас запустится. Если он отличается от установленного, окружение сносится, и только потом инициализируется другое.
Режим запоминается на воркер, а не на вызов. При isolate: false setup-файл выполняется для каждой спеки, а платформа живёт весь срок воркера. Режим, хранимый в вызове, в каждом файле снова был бы undefined. Тогда каждый файл платил бы за resetTestEnvironment() и ваш инициализатор. Запомненный режим всё равно проверяется. Если платформу тем временем снёс кто-то другой, окружение инициализируется заново.
Инициализаторы остаются вашими. Какую платформу, какие провайдеры и какую политику teardown выбрать проекту — решает не эта библиотека. Пакеты, которые их дают (@analogjs/vitest-angular, jest-preset-angular, написанный руками initTestEnvironment), не входят в её зависимости.
Почему autoDetect не показывает изменение проверке
Старые советы говорят, что спека должна включить автоматическое обнаружение изменений. Эти советы писались для тестов на zone.js. Начиная с Angular 19.0.0 значение по умолчанию у фикстуры зависит от режима. В @angular/core@21.2.17 это выглядит так:
// @angular/core/fesm2022/testing.mjs:164-167, строки перенесены
autoDetectDefault = this.zonelessEnabled ? true : false;
autoDetect = inject(ComponentFixtureAutoDetect, { optional: true }) ?? this.autoDetectDefault;Значит, в zoneless-наборе тестов автоматическое обнаружение изменений включено по умолчанию. Не нужно ни предоставлять ComponentFixtureAutoDetect, ни вызывать autoDetectChanges(). Но спека по-прежнему отвечает за то, когда оно сработает. Когда вы пишете в сигнал, Angular только планирует запуск обнаружения изменений, а не выполняет его на этой строке. Поэтому проверка на следующей строке всё ещё видит DOM до записи. Решение — await stable(fixture) из vitest-auto-spy/angular: он запускает обнаружение изменений и эффекты и дожидается их до вашей проверки. Ещё один вызов fixture.detectChanges() проблему не решает.
Две связанные пометки deprecated в той же версии указывают в ту же сторону:
@angular/core@21.2.17 | Что сказано в типах |
|---|---|
autoDetectChanges(autoDetect: boolean) | @deprecated в types/testing.d.ts:112; используйте autoDetectChanges() без аргумента из :121 |
TestBed.flushEffects() | @deprecated в :498 в пользу TestBed.tick() из :506 |
flushEffects() из этого пакета просто вызывает TestBed.tick(). Пакет поддерживает только Angular 20 и новее, поэтому запасного вызова ApplicationRef.tick() в нём больше нет. Набор тестов, который вызывает flushEffects(), уже пользуется API, которое Angular оставляет.
Как overrideComponentProvider добирается до вложенного компонента
TestBed.overrideProvider действует только на компонент, о котором знает компилятор TestBed. Standalone-компонент, созданный из шаблона родителя, не входит в imports тестового модуля. Поэтому переопределение до него не доходит. Чтобы это выяснить, нужно знать, как работает TestBedCompiler.queueType в Angular. overrideComponentProvider ставит компонент в очередь за вас: как импорт, если он standalone, и как объявление в остальных случаях.
TestBed.overrideComponent здесь не решение. Он заставляет перекомпилировать компонент в режиме JIT (JIT — компиляция компонента в браузере во время выполнения). В тестовом бандле, собранном заранее (AOT), такая перекомпиляция ищет директивы и пайпы компонента в области видимости времени выполнения. А бандлер эту область уже удалил. В итоге у компонента не остаётся ни одной из них.
Как проверяется переопределение
Каждый вызов overrideComponentProvider ставит в очередь одну запись: компонент, токен, спай. Он регистрируется один раз в том же хуке createComponent, что и диагностика Angular. На следующей фикстуре хелпер проверяет все записи из очереди, затем снимает регистрацию и очищает очередь. Проверка принадлежит фикстуре, которую построил этот вызов. Оставленная в хуке проверка сработала бы на чужом компоненте из более поздней спеки.
Хук стоит на экземпляре TestBed, которому делегирует каждый статический метод. Поэтому getTestBed().createComponent(HostComponent) проверяется так же, как TestBed.createComponent. Общий хук с диагностикой, а не вторая обёртка над createComponent, заодно фиксирует порядок двух проверок. Иначе его решало бы то, что установлено последним.
Токен разрешается через собственный инжектор компонента, а не через инжектор тестового модуля:
- если
fixture.debugElement.componentInstance— экземпляр переопределённого компонента, отвечает его инжектор; - иначе корень фикстуры просматривается по условию
element.componentInstance instanceof component, и отвечает инжектор найденного элемента.
Импорт @angular/platform-browser здесь не участвует. Обычным предикатом был бы By.directive, но он добавил бы импорт, который этой точке входа больше ни для чего не нужен. Вместо этого API DebugElement читается структурно.
Вложенный случай работает потому, что в Angular 21.2.17 дочерний компонент из шаблона родителя уже существует, когда createComponent возвращает результат. Ещё до любого detectChanges(). Это измерено, а не предположено. Проверка находит инжектор вложенного компонента на фикстуре, которую вернул вызов, без обнаружения изменений между ними.
Ответ, который не является объектом, выводится как есть (resolved … to not-a-service). Экземпляр класса называется по имени конструктора. На TestBed без createComponent в очередь вообще ничего не ставится. Хелпер откатывается к «без проверки», а не к устаревшей проверке на какой-то поздней фикстуре. Само переопределение всё равно применяется.
Почему проверка переопределения включена всегда
Проверка не входит в enableAngularDiagnostics. На то три причины:
- Хелпер существует потому, что обычный подход ломается молча. Неприменённое переопределение — это баг хелпера. Спрятать проверку его собственной корректности за флагом значило бы поставлять ту самую тихую поломку, ради устранения которой хелпер написан.
- Она не может сработать в спеке, которая не вызывала
overrideComponentProvider. В очереди пусто, поэтомуcreateComponentне оборачивается. - Она молчит, если компонент не отрисован. Нет инжектора — нет проверки.
У группы диагностик нет ни одного из двух последних свойств. Она действует на каждую спеку в наборе тестов, включая спеки, написанные задолго до её появления. Поэтому окрасить там зелёный набор тестов в красный — решение проекта, а не побочный эффект импорта.
Почему хост директивы должен быть standalone
Под билдером unit-test из Angular CLI Angular разрешает imports в двух местах. Одна и та же строка работает в одном из них и ничего не делает в другом:
| Где | Кто разрешает | NgModule там |
|---|---|---|
@Component({ imports }) | AOT-компилятор, во время сборки | работает: плоский список записывается в скомпилированный компонент |
TestBed.configureTestingModule({ imports }) | TestBedCompiler, во время выполнения, из ɵmod | ничего не привносит: ɵɵsetNgModuleScope не попадает в тестовый бандл |
Поэтому хост должен быть standalone и держать модуль в собственных imports. Хост со standalone: false внутри спеки ещё хуже. Он не принадлежит ни одному NgModule и поэтому не получает никаких импортов: ни NgClass, ни AsyncPipe, ничего. createDirectiveHost так и делает. Хост всегда standalone, scope становится его импортами, а props задаёт тип fixture.componentInstance.
Что keepTemplate читает из скомпилированного компонента
keepTemplate: true сохраняет строительные блоки шаблона. Для этого он читает импорты, которые компилятор записал в скомпилированный компонент (ɵcmp). Они бывают трёх видов: плоский массив, функция, которая его возвращает, или null у компонента без импортов. Вид не зависит от JIT или AOT. JIT всегда выдаёт функцию. AOT выдаёт массив, если только цикл между двумя компонентами не заставит отложить чтение. Все три вида обрабатываются. Когда-то массив вызывался как функция. Это давало TypeError: dependencies is not a function на каждом AOT-компоненте standalone без цикла, и только при keepTemplate: true.
Подмены
Почему value() у подмены ресурса бросает ошибку после сбоя
Подмена mockResourceProp в двух местах отказывается быть снисходительнее настоящего ресурса.
value() бросает ошибку, когда ресурс упал. Angular строит value как вычисление, которое пробрасывает ошибку дальше, а не возвращает последнее удачное значение. Код компонента, который читает products.value() без проверки hasValue(), ломается в приложении. Значит, он должен ломаться и в тесте. Ошибка несёт причину. Сторону спеки это не затрагивает: fail() на уже упавшем ресурсе, loading() и snapshot() читают сохранённое значение напрямую.
products.fail('offline');
expect(() => products.resource.value()).toThrow(); // как и настоящий ресурс
expect(component.errorMessage()).toBe('offline'); // ветка, которая сначала проверяет, в порядкеreload() отвечает так же, как в Angular: false, пока перезапускать нечего (ресурс ещё не запускался или всё ещё выполняется), и true в остальных случаях. Это всё равно спай, и он всё равно ничего не перезапускает. Если ветка — суть теста, переопределите его через reload.mockReturnValue(…).
hasValue() стоит прочитать дважды. Начиная с Angular v20 он зависит от значения, а не от статуса. Ресурс, объявленный с defaultValue, имеет значение с момента создания. Поэтому hasValue() равен true в состояниях loading, reloading и idle. false он бывает только в состоянии error или при значении undefined. Так что шаблон вида @if (products.hasValue()) { … } @else { <spinner/> } продолжает показывать список, пока грузится следующая страница.
Почему подмене window нужен ваш токен
DOCUMENT входит в Angular (с v20 — прямо в @angular/core), а токена WINDOW в нём не было никогда. Каждое приложение объявляет свой InjectionToken<Window>. Поэтому provideWindowDouble нужно передать ваш токен. Функция обобщена по типу токена. Для InjectionToken<AppWindow> переопределения сверяются и с членами этого интерфейса.
Методы, прочитанные из подмены, привязаны к настоящему объекту, а конструкторы отдаются нетронутыми. Методу нужно сохранить свой this. Но привязанная функция теряет собственные члены своей цели. А у Date, Promise, Object, Array, Number и Event всё живёт именно там. Поэтому они возвращаются как есть. Работают win.Date.now(), win.Promise.resolve(), win.Object.keys(…) и new win.Event('resize'). Выполняется и win.Event === window.Event — именно это читает instanceof в тестируемом коде. Конструктору привязка и не нужна: его this — создаваемый экземпляр.
Зачем нужен provideAutoSpyForToken
У токена, типизированного интерфейсом, нет класса, который можно прочитать. Отсюда и обычный обходной путь: класс PasscodeServiceMock, написанный в спеке, на который ставят спай и который подставляют провайдером. После этого Spy<PasscodeServiceMock> и Spy<PasscodeService> расходятся в calledWith. Кто-то тянется к приведению типа, а чаще к TestBed.inject<any>(TOKEN) с eslint-disable в начале файла. provideAutoSpyForToken берёт тип из токена, а injectSpy уже принимает токен. provideAutoSpy читает прототип класса, а у токена его нет. Так что здесь нужен не он.
Зачем нужна подмена Router
После ActivatedRoute Router — провайдер, который в реальных наборах тестов чаще всего пишут руками. В двух закрытых наборах тестов таких было 48, и все 48 — одна и та же строка: createAutoMock<Router>({ events: of(), url: '/' }, { returns: { navigate: Promise.resolve(true) } }). provideRouterDouble() заменяет эту строку на Router, у которого URL и события согласованы с маршрутом.
Матчеры и проверки
Почему матчеры бросают ошибку на неверном аргументе
Неверный аргумент бросает ошибку, а не проваливает проверку. Причина в том, что .not превращает провал в успех:
expect(products.value()).not.toBeLoading(); // зелёный, и про объект, о котором никто не спрашивалЭта строка ничего не проверяет, что бы ни делал ресурс. То же касается toHaveFocus, если ему дали не элемент. Чаще всего это запрос, который ничего не нашёл, и expect(missingEl).not.toHaveFocus() проходил. И toHaveDirectiveApplied, если ему дали не фикстуру и не DebugElement: expect(undefined).not.toHaveDirectiveApplied(X) проходил точно так же.
Зачем нужна диагностика Angular
У пяти проверок одна форма. Что-то, написанное в спеке, ничего не делает, никто об этом не сообщает, и тест проходит не по той причине, которую имел в виду автор. Они входят в пакет одной группой, а не пятью хелперами. Перевести набор тестов из «проходит» в «проходит по заявленной причине» — решение, которое принимают один раз, в setup-файле. К тому же четыре из пяти висят на том же хуке TestBed.configureTestingModule, который уже ставит диагностика таймингов.
Масштаб проблемы, измеренный на одном наборе тестов Angular. Из 71 спеки компонентов, чей компонент объявляет собственные providers, 43 регистрировали тот же токен ещё и в модуле. 22 из них обходили это через TestBed.overrideComponent({ set: { providers } }), 14 — через TestBed.overrideProvider, 4 доставали спай через инжектор компонента. А 7 не делали ничего — это та поломка, о которой сообщает shadowedProviders. overrideComponentProvider существовал с 3.1.0, и в том репозитории его использовали ноль раз. Это довод за проверку, а не против хелпера. Хелпер не найти по симптому, потому что симптома нет. На 40 спеках, которые так или иначе справились с этим случаем, проверка молчит.
На Angular-проекте из 1759 файлов включение группы нашло настоящие дефекты в 25 файлах и 324 тестах. Измеримой цены это не стоило: 12.5 с против 13.4 с на полный прогон.
Каждая проверка настроена одинаково, заметнее всего deadSchemas: ложный провал на правильной спеке обходится дороже пропуска. А pendingRequests молчит в проекте, который вообще не настраивает HTTP-тестирование Angular. Токен не найден, сообщать нечего, и ставить ничего не пришлось. Такой и должна быть необязательная интеграция.
Билдер unit-test
Почему фабрика vi.mock не должна использовать spread объекта
Билдер Angular всегда задаёт 'object-rest-spread': false в getFeatureSupport (@angular/build/src/tools/esbuild/utils.js:166 в 22.2.0). Это намеренный обход дефекта производительности V8, crbug/v8/11536. Поэтому { ...actual, x } никогда не остаётся spread: его переписывают в хелпер уровня бандла __spreadValues. Фабрики vi.mock переносятся в самый верх тестового бандла, выше кода, который определяет хелпер. Поэтому фабрика вызывает хелпер раньше, чем он появится.
Современный .browserslistrc не помогает: флаг не зависит от цели сборки. Разделение кода решает только, какой текст ошибки вы увидите, поэтому причиной легко счесть общие чанки, хотя дело не в них:
| Разделение | Что говорит прогон |
|---|---|
| вкл. | Cannot access '__vi_import_1__' before initialization: хелпер лежит в общем чанке |
| выкл. | __spreadValues is not a function: хелпер — это var уровня модуля |
Ни одно сообщение не упоминает ни spread, ни фабрику. Исправление — писать в фабрике Object.assign({}, actual, { … }) вместо { ...actual, … }, где actual — результат await importOriginal(); полный пример — на странице Билдер unit-test в Angular.
Почему относительный путь в vi.mock заблокирован
vi.mock('./thing') отклоняется намеренно, а не из-за случайности бандлинга. Билдер внедряет виртуальную точку входа angular:vitest-mock-patch (@angular/build/src/builders/unit-test/runners/vitest/build-options.js). Она патчит vi.mock, vi.doMock, vi.importMock, vi.unmock и vi.doUnmock, чтобы те бросали ошибку, когда путь совпадает с /^[./]/.
Почему патч @angular/build не входит в пакет
@angular/build 22.1.5 и 22.1.6 выключают разделение кода, и руководство по билдеру показывает для них локальный патч. Этот пакет намеренно не включает его в себя и не примет pull request, который бы это сделал:
- Сделать его автоматическим — значит завести скрипт
postinstall, который переписывает файлы чужого пакета внутриnode_modules. Для аудита цепочки поставок это самое тревожное, что может сделать тестовая библиотека. К тому же это противоречит принципу держать собственные бандлы пакета неминифицированными и читаемыми. - Он правит строку без фиксированного пути, поэтому рефакторинг в исходном пакете молча его сломает. Для пакета, который обещает, что каждая ошибка называет свою причину, хуже поломки не придумать.
- Полезен он был бы несколько недель:
@angular/build22.1.7 закрыл окно, ради которого он существовал.
Менять ли граф бандла на 596 МБ (столько дало выключенное разделение кода в одном проекте) на что-то другое — решает команда приложения. Диагноз уместен здесь, а правка чужого пакета — нет.
Как coverage сопоставляет файлы под билдером
Coverage сопоставляется дважды, и первый проход видит чанки. @vitest/coverage-v8 вызывает isIncluded на URL каждого выполненного скрипта до пересчёта по source map. Если включён excludeAfterRemap, он вызывает её ещё раз на пересчитанном пути исходника. @angular/build сам включает excludeAfterRemap, поэтому оба прохода используют один список. Тесты выполняются над бандлом, так что первый проход сравнивает ваши glob-шаблоны со spec-*.js и chunk-*.js, а не с файлами .ts. Список glob-шаблонов исходников отбрасывает там все счётчики, и отчёт выходит пустым.
Vitest 4 перекомпилирует glob-шаблоны для каждого файла. @vitest/coverage-v8 решает, попадает ли файл в отчёт, вызовом isIncluded. Та вызывает picomatch со всем массивом шаблонов. Её globCache запоминает ответ по имени файла, но не скомпилированный матчер. Поэтому для каждого имени файла заново компилируется каждый шаблон. В Vitest 5 это исправили в исходном проекте: BaseCoverageProvider.getGlobMatchers() строит матчеры один раз и хранит их. На Vitest 5 лишних затрат и обёртки больше нет. @angular/build:unit-test принимает Vitest 5 начиная с 22.2.0 (до 22.2 его диапазон заканчивался на ^4). Analog для того же нужен версии 2.7.5 или новее. Обходной путь для Vitest 4 — на странице Билдер unit-test в Angular.
Storybook
Почему expect из Storybook отвергает эти спаи
В storybook/test есть инструментированный expect: каждая проверка появляется в панели Interactions. Со спаями этой библиотеки он падает с TypeError: [Function] is not a spy or a call to a spy!.
Инструментатор оборачивает каждый аргумент-функцию инструментированного вызова в новую стрелочную функцию. Исключение — функции с собственными перечислимыми ключами. По этому правилу узнаётся vi.fn(): его методы mock* — собственные свойства. Спаи этой библиотеки держат эти методы на общем прототипе, отсюда и экономия памяти. Поэтому правило их не видит, и матчер получает обёртку. expect из vitest не инструментирован и получает сам спай. Два способа обойти это описаны на странице Storybook с Angular.