Angular
The vitest-auto-spy/angular entry adds provideAutoSpy — a shorthand for providing an auto-spy in a TestBed — plus injectSpy, shallow component rendering, DI-driven instantiation, zoneless waiting, a signal matcher, TestBed diagnostics and the signal/readonly property mockers.
import { injectSpy, provideAutoSpy } from 'vitest-auto-spy/angular';
TestBed.configureTestingModule({
providers: [
provideAutoSpy(MyService),
// accepts the same second argument as createSpyFromClass
provideAutoSpy(ApiService, { onlyMethodsToSpyOn: ['get', 'post'] }),
],
});
let myService: Spy<MyService>;
beforeEach(() => {
myService = injectSpy(MyService);
});The spies are change-detection agnostic, so they work in both zoneless and zone.js Angular projects — nothing here touches NgZone or change detection. You still need the usual Vitest + Angular wiring (@analogjs/vite-plugin-angular plus a TestBed setup file).
Running the same suite on Bun
bun test cannot run Angular specs out of the box — Bun ships no DOM and cannot resolve templateUrl. vitest-auto-spy/bun-angular closes both from one preload and re-exports everything on this page except registerSignalMatchers and the TestBed diagnostics, which need the runner's expect.extend and suite-level hooks.
An abstract class DI token
abstract class LocalStorage extends AbstractStorage {}, provided in production as { provide: LocalStorage, useClass: BrowserLocalStorage }, is the standard way to declare a DI token in an Angular codebase — and it used to be the one shape provideAutoSpy could not serve. It failed twice over: the bare call compiled and produced an empty double, while the config form that would fix it did not compile at all (TS2345: Cannot assign an abstract constructor type to a non-abstract constructor type).
Both halves work now.
abstract class LocalStorage extends AbstractStorage {
abstract read(key: string): string | null;
abstract write(key: string, value: string): void;
}
TestBed.configureTestingModule({ providers: [provideAutoSpy(LocalStorage)] });
const storage = injectSpy(LocalStorage);
storage.read.calledWith('token').mockReturnValue('abc');ClassType<T> now carries an abstract construct signature — nothing in this library ever calls new on the token, so requiring a concrete one bought no safety. At runtime the abstract members are erased before they reach a prototype, so there is nothing to discover; when discovery comes back empty the factory hands back the createAutoMock proxy, which answers every method of the declared type. injectSpy recognises it as an auto-spy and stays quiet, and the hand-written workaround — { provide: LocalStorage, useValue: createAutoMock<LocalStorage>() } — is no longer needed.
One concrete member changes that, and it is worth knowing which side of the line a token is on:
abstract class LocalStorage {
abstract read(key: string): string | null;
clear(): void {} // discovery is no longer empty, so the fallback does not fire
}
const storage = injectSpy(LocalStorage);
storage.clear; // a spy
storage.read; // undefined — and `Spy<T>` says it is thereabstract read() is erased before it reaches a prototype, so only clear is discovered and the abstract members are simply absent — the call then dies as storage.read is not a function inside the component, not in the spec. Nothing can detect it automatically: TypeScript erases abstract, so at runtime this class and a concrete one are the same object. Ask for it:
providers: [provideAutoSpy(LocalStorage, { fillMissing: true })];See fillMissing for what it does and does not fill.
Seeding the double in the provider
Both factories take both halves: returns for what a spied method answers, overrides for a member that is not a method result — an Observable property, a plain field, a signal.
provideAutoSpy(FavoritesService, {
returns: { load: of([]) },
overrides: { favoritesCacheUpdated$: of(undefined), favoriteItems: [] },
});
provideAutoSpyForToken(PRODUCTS, undefined, { returns: { getProducts: of([]), getById: of(null) } });Until 3.5.0 the two helpers had one half each — provideAutoSpyForToken took property seeds, provideAutoSpy took method configuration — so a double needing both was provided in one statement and finished in another, in a beforeEach below it.
A seeded overrides member is stored verbatim and is no longer a spy, which is the line between the two: seed data there, and name a method in returns when it must stay assertable. The reason to prefer either over a second statement is not brevity — the shortcut people take instead is an exported const provider carrying the values, and under isolate: false that is one set of spies shared by every file that imports it.
Observable properties behind a token
observablePropsToSpyOn is the third option both forms now share, and it matters more on the token path than on the class one. A class tells the factory which members are methods; a type does not, so every unnamed key of a token-driven double is a function spy — an Observable property included, which the code under test then subscribes to as if it were a function, with the failure surfacing far from the double.
provideAutoSpyForToken(FAVORITES, undefined, { observablePropsToSpyOn: ['favorites$'] });
// …
injectSpy(FAVORITES).favorites$.nextWith([{ id: 1 }]);A member also named in overrides keeps its seed — hand the double a real Subject there when the spec drives the stream itself, and name it here when nextWith is what the spec wants; the class factory resolves the same contradiction the same way. Until 3.5.0 the option existed only on the class path, so a token with observable members sent people back to a hand-written double — which is what prefer-provide-auto-spy and prefer-create-spy-from-class exist to steer them away from.
Do not write a local injectSpy
A wrapper of the shape TestBed.inject(token as never) as Spy<T>, typed <T>(token: abstract new (...args: never[]) => T), is a common thing to find already in a repository. The library's is strictly wider: it accepts a ClassType<T>, an InjectionToken<T> and an abstract constructor, warns when the injector returns something that is not a spy, and carries no type assertion for the project's lint rules to argue with. Two functions with the same name and different signatures means the import order in each file decides which one it gets — delete the local one, or re-export this one under that name.
Lazy spies by default
Angular tests spy a wide service and call a couple of its methods, so a spy is built on first access rather than eagerly up-front. Everything else is unchanged: Object.keys, vi.isMockFunction, calledWith, resetAutoSpy / clearAutoSpy all behave identically, because the placeholder is an enumerable accessor.
provideAutoSpy(WideService); // lazy — the default
provideAutoSpy(WideService, { lazySpies: false }); // opt out: build every spy eagerlyThis is the core default rather than something this entry adds — createSpyFromClass behaves the same. Until v2 only provideAutoSpy turned it on, which made the Angular path quietly faster than the plain one for no reason anybody could see. What it buys, on a forty-method class with two methods touched: 27 ms and 35 MB against 257 ms and 425 MB.
Is it fast enough to call in every beforeEach?
Yes, and it is the fastest of the three ways to build a double. Measured on the repo's own benchmark (npm run bench, a ten-method class):
| Call | ops/sec | per call |
|---|---|---|
provideAutoSpy(Service) — lazy, the default | 118 900 | ~8 µs |
createSpyFromClass(Service) — eager | 34 600 | ~29 µs |
createAutoMock<Service>() + 4 accesses | 30 600 | ~33 µs |
The gap is lazySpies, which provideAutoSpy turns on and the plain factory does not: a wide service where a test touches two methods builds two spies instead of twenty. Prototype discovery is cached per class, so calling it once per test does not re-walk the chain.
At ~8 µs, five providers across two thousand tests come to under a tenth of a second for the whole suite. If a spec feels slow, the time is in TestBed — which is what enableTestBedDiagnostics() measures, and usually what renderShallow fixes.
Two things do cost more, and both are avoidable:
{ lazySpies: false }gives up the win above. Only worth it when a spec enumerates the spy object itself rather than calling methods on it.autoSpyAccessors: truewalks the prototype chain for getters and setters on every call, and that walk is not cached. Name the accessors you need instead when a class is spied per test.
Shallow component rendering
renderShallow is the standard TestBed sequence a component-heavy suite ends up copy-pasting — configureTestingModule + NO_ERRORS_SCHEMA + overrideComponent with emptied imports and a blank template — given a name:
import { provideAutoSpy, renderShallow } from 'vitest-auto-spy/angular';
const { fixture, component } = renderShallow(TaskListComponent, {
providers: [provideAutoSpy(TaskService), provideHttpClient()],
inputs: { projectId: 42 }, // set through componentRef.setInput, before the first CD
});| Option | Default | What it does |
|---|---|---|
providers | [] | Providers for the testing module. EnvironmentProviders (provideHttpClient(), …) welcome |
imports | [] | Extra imports for the testing module (a stub module, a routing harness) |
inputs | — | Values for the component's inputs — signal inputs take the value, not the signal |
keepTemplate | false | Keep the real template (for viewChild, content projection, host bindings) |
keepChildren | [] | Child components/directives/pipes that stay resolvable; everything else is dropped |
template | '' | A stand-in template to render instead of a blank one |
beforeCreate | — | Runs after the module is configured, before the component exists — the seam for stubbing a dependency a field initializer reads |
detectChanges | true | Run the first change detection, and therefore ngOnInit |
fixture is a real ComponentFixture; nothing here replaces @angular/core/testing. Blanking the template keeps lifecycle hooks, inputs, signals and DI — everything a spec that asserts on TypeScript state actually reads.
What it saves, measured
On a private Angular 22 zoneless suite (784 specs, the AOT @angular/build:unit-test builder), three of its most expensive component specs were converted and the ten-file batch re-run three times — medians, same batch, same machine:
| Spec (479 tests in the batch, all still green) | Before | After | Change |
|---|---|---|---|
| a container with a deep child tree (34 tests) | 129 ms | 61 ms | 2.1× |
| a list rendering 58 fixtures | 133 ms | 75 ms | 1.8× |
| a small leaf component (20 tests) | 29 ms | 38 ms | 0.8× |
| the three together | 291 ms | 174 ms | 1.7× |
The third row is the honest half of the result: a leaf component has almost no subtree to remove, so the per-test overrideComponent costs more than it saves. Shallow rendering pays where there is a real child tree to skip. Use the diagnostics to find the files worth converting rather than guessing.
Building a class with auto-spied dependencies
The alternative a project writes by hand is a providers array listing each dependency with a useValue object of vi.fn()s, rewritten whenever a dependency is added. Here the injector itself answers an unknown token with a spy, so the spec names only what it wants to control:
import { createWithAutoSpies } from 'vitest-auto-spy/angular';
const { instance, spies, injector } = createWithAutoSpies(CartService, {
providers: [{ provide: TaxService, useValue: realTax }], // explicit providers win
});
spies.get(PricingService).total.mockReturnValue(100);
expect(instance.checkout()).toBe(100);The class is built through its own Angular factory, so constructor parameters and inject() field initializers resolve normally. An unprovided token gets a createSpyFromClass spy (a class) or a createAutoMock proxy (an InjectionToken); inject(X, { optional: true }) still returns null, exactly as it would in the app. spies.get(token) resolves through the same injector the instance used — so it returns the explicit provider when there is one — and spies.autoSpiedTokens() lists what was invented.
Plain providers only
This builds an Injector.create() injector, which does not accept the EnvironmentProviders returned by provideHttpClient() and friends. A class that needs those belongs in a TestBed — renderShallow, or a plain configureTestingModule.
Zoneless waiting
import { flushEffects, stable } from 'vitest-auto-spy/angular';
component.filter.set('open');
await stable(fixture); // flush effects, then await the fixture
expect(component.visible()).toEqual([openTask]);
flushEffects(); // the no-fixture half: services, stores, runInInjectionContext codefixture.detectChanges() runs a single change-detection pass and does not flush pending effects, so an assertion right after it reads state that has not finished computing. In a zoneless app the state that matters is signal-derived and effects are what move it forward. stable does both, in the right order; flushEffects prefers TestBed.tick() (Angular ≥ 20) and falls back to ApplicationRef.tick().
The wait is bounded
stable gives the fixture 2000 ms and then throws the cause. A fixture that never stabilises — a real HttpClient request nothing completed, a PendingTasks entry nothing released, a setInterval running under real timers — used to hang here until Vitest reported a 5 s file-level timeout naming neither the helper nor the fixture, which blames the file for the state of one component.
await stable(fixture, { timeout: 5000, label: 'the products fixture' });Pass label when a spec awaits more than one fixture, so the failure says which. Pass { timeout: 0 } to disable the watchdog and wait indefinitely — worth it only for a deliberately long real-timer test. The watchdog runs on a timer captured at import, so vi.useFakeTimers() cannot stop it: a watchdog the code under test can freeze is not a watchdog.
Resources: httpResource() and resource()
Angular's resource primitives need a different wait each, and neither is the one a spec reaches for. Measured on Angular 21.2.17, zoneless TestBed:
| What | What it needs to settle |
|---|---|
httpResource(), after its response is flushed | one tick + one microtask |
resource() with an async loader | two rounds of the same |
httpResource() that has just been created | a tick, or it makes no request |
Getting it wrong does not fail loudly. It asserts against the resource's default value — a green test proving nothing, until the day the default changes. settleResource is the loop both converge under:
import { flushEffects, settleResource } from 'vitest-auto-spy/angular';
const products = TestBed.runInInjectionContext(() => httpResource<Product[]>(() => '/api/products'));
flushEffects(); // the request is issued here — not when the resource was created
TestBed.inject(HttpTestingController).expectOne('/api/products').flush([product]);
await settleResource(products, { label: 'the product resource' });
expect(products.value()).toEqual([product]);That flushEffects() is not optional and settleResource cannot replace it. An httpResource issues no request until something ticks, so there is nothing for expectOne to find until then — and awaiting first would spend the whole budget on a resource that stays loading for a reason no amount of waiting fixes. One tick to get the request out, your flush, then one wait to take delivery. A plain resource() needs no flush and so needs no tick: await settleResource(data) is the whole of it.
The wait ends on any settled status, error and idle included — waiting for those would be waiting for something that cannot happen. On expiry it names the resource and the flush it is missing.
Not flushEventLoopUntil
flushEventLoopUntil takes real event-loop turns and never ticks. A resource awaited through it finishes the whole budget having issued zero requests, then fails saying the condition was never met. Its docstring used to claim this exact use case; it never worked.
Skipping the request entirely — mockResourceProp
Everything above is the answer when the request is the point. Often it is not: the spec is about a component's own logic, it never wanted an HttpTestingController, and the value it needs is one it picked in advance. mockResourceProp replaces the property with a double the spec moves directly.
import { mockResourceProp } from 'vitest-auto-spy/angular';
const service = injectSpy(ProductService);
const products = mockResourceProp(service, 'products', []);
expect(component.emptyState()).toBe(true);
products.set([product]); // status → 'resolved'
expect(component.emptyState()).toBe(false);
products.loading(); // status → 'loading', hasValue() → false
expect(component.spinner()).toBe(true);
products.fail('offline'); // status → 'error', error() → Error('offline')
expect(component.errorMessage()).toBe('offline');Nothing is ever in flight, so there is nothing to wait for — no tick, no flush, no budget, and no way for the test to pass against a default value by accident. The resource starts 'resolved' at the initial value, because that is the state most assertions want and the one that would otherwise have to be arranged.
Reactivity is genuine: the double is built from real signal()s, so a computed() reading products.value() recomputes and an effect() watching products.status() runs, exactly as against a real httpResource. A plain object with the same keys would satisfy every read and notify nothing.
| Member | What it is |
|---|---|
set(value) | resolve with a value; clears any error |
fail(error) | fail with an Error or a message string |
loading() | put it back in flight |
reload | the spied reload() — assert the call, nothing is re-issued |
resource | the installed double, for asserting on it directly |
Undone by restoreMockedProps() like every other property patch, so a suite running setupAutoSpy() needs no teardown of its own.
Asserting a resource
registerResourceMatchers() adds three matchers that read the value and the status, because either one alone is misleading.
registerResourceMatchers(); // once, in the setup file
expect(component.products).toBeLoading();
httpTesting.expectOne('/api/products').flush([product]);
await settleResource(component.products);
expect(component.products).toHaveResourceValue([product]);
expect(other.products).toHaveResourceError(/503/);The one that earns its place is toHaveResourceValue: it fails a resource that has not resolved even when its default value matches. That is precisely the assertion this family exists to stop passing — expect(products.value()).toEqual([]) is just as happy against a resource still loading with its default [] as against one that genuinely resolved to nothing. The failure names the status it was actually in and the flush that is missing.
Duck-typed on { status, value, error } with error optional, so httpResource, resource, rxResource and a mockResourceProp double all work. Handed something that is not a resource, each matcher says so rather than throwing a TypeError — the two ways to get there are passing products.value() instead of products, and passing a property that was never a resource, and both are silent otherwise.
Running one effect on demand
flushEffects() asks the scheduler to run everything currently dirty. Sometimes a spec needs one specific effect to run now — typically because its trigger has been replaced with a static signal, so it will never become dirty on its own:
import { mockReadonlyProp, runEffect } from 'vitest-auto-spy/angular';
mockReadonlyProp(component, 'state', signal(State.Selected));
runEffect(component.highlightEffect);
expect(component.icon()).toBe('starFilled');runEffect runs the body with the signal values as they stand, cleanup registration intact, without marking the effect clean — a later flush still behaves normally.
Do not reach for vi.mock('@angular/core') instead
The instinct is to replace effect() with the identity function so the callback becomes something the spec holds. Under the Angular unit-test builder that is not available: specs are bundled, @angular/core lands in a chunk other chunks already depend on, and substituting it re-enters that chunk mid-initialisation. The run dies with Cannot access '__vi_import_N__' before initialization, which says nothing about mocking. The same applies to vi.mock() with a relative path — once bundled there is no module boundary left to replace.
It reads Angular's reactive node off the EffectRef, so it is tied to an internal-by-convention detail. If a future Angular moves the effect body, runEffect throws with a message saying to assert the effect's result instead — set the signals it reads, await stable(fixture), check what came out. That is the more durable shape wherever it is practical.
Asserting a signal
import { registerSignalMatchers } from 'vitest-auto-spy/angular';
registerSignalMatchers(); // once, in your setup file
expect(component.total).toHaveSignalValue(3);
expect(component.items).toHaveSignalValue([{ id: 1 }]);expect(component.total).toBeTruthy() passes for every signal ever created — a signal is a function. The matcher reads it, deep-compares with the runner's own equality, and rejects anything that is not a zero-argument getter, so the missing-parentheses mistake fails instead of quietly passing.
Signal / readonly property mocking
import { mockAccessorsProp, mockReadonlyProp, mockReadonlyPropGetter, mockValueProp, restoreMockedProps } from 'vitest-auto-spy/angular';
mockReadonlyProp(service, 'isReady', true); // static value (incl. signals)
mockReadonlyPropGetter(service, 'label', () => 'A'); // dynamic getter
mockValueProp(service, 'retries', 3); // plain writable value
mockAccessorsProp(service, 'theme'); // spied get + setEvery helper records the descriptor it overwrote, so a single restoreMockedProps() puts them all back — and each one also returns the undo for its own patch, for a stub that has to come off inside a single test. That matters when the patched object outlives the spec file (a global, a class prototype, a singleton), which is always the case under Vitest's isolate: false: setupAutoSpy() wires the afterEach for you.
Nothing about these helpers is Angular-specific: they are exported from the core entry too, and vitest-auto-spy/angular keeps re-exporting them unchanged. countMockedProps() reports how many patches are still applied.
Driving a signal
createSpyFromClass walks the prototype, and a signal() / computed() field is not there — it is assigned on the instance. Listing it in methodsToSpyOn does not help either: that makes it a function spy, and a function spy answers undefined until configured, so a component reading service.count() gets nothing where it expects a value.
What a spec wants is the signal real and writable, so the component reacts the way it does in the application. That is the two-line pair every suite ends up writing — a writable handle for the test, a readonly one for the service:
const count = signal(0);
mockReadonlyProp(service, 'count', count);mockSignalProp is that pair with the handle returned rather than declared:
import { mockSignalProp } from 'vitest-auto-spy/angular';
const service = injectSpy(CounterService);
const count = mockSignalProp(service, 'count', 0);
expect(component.label()).toBe('0 items');
count.set(42);
await fixture.whenStable();
expect(component.label()).toBe('42 items');The signal comes from @angular/core, so reactivity is genuine: a computed() downstream recomputes, an effect() runs, a template binding updates. A stand-in with a set method would satisfy service.count() and silently notify nothing — the failure this helper exists to avoid rather than cause.
Returning the handle also removes the temptation to reach for service.count and call .set on it: Signal<T> has no set, so that only type-checks after an assertion.
Where a spec spends its time
// vitest.setup.ts
import { enableTestBedDiagnostics } from 'vitest-auto-spy/angular';
if (process.env['SPEC_TIMING']) {
enableTestBedDiagnostics();
}[vitest-auto-spy] src/app/…/layer-editor.component.spec.ts — TestBed 353ms of 661ms (53%), logic 308ms, 155 component(s), 132 module config(s)One line per spec file: how much of its wall clock went into TestBed (module configuration, template compilation, component creation) versus plain logic, and how many components it created. That is the list of rewrite candidates, and the number that says whether a rewrite helped.
| Option | Default | Notes |
|---|---|---|
report | one line per file | Receives the SpecTiming object — collect the timings yourself |
minTestBedMs | 0 | Stay quiet about files cheaper than this |
disableTestBedDiagnostics() puts the untouched TestBed back; instrumentTestBed(), getTestBedTiming(), formatSpecTiming() and reportSpecTiming() are the pieces underneath, for a suite that wants the numbers without the per-file line. The clock is captured at import time, so a spec using vi.useFakeTimers() is still measured honestly rather than reported as free, and the report goes to process.stdout — not console.info, which vitest-auto-spy/console replaces with a silent mock.
Overriding a provider the component declares for itself
provideAutoSpy registers on the testing module, and a testing-module provider loses to one the component declares in its own @Component({ providers: [...] }) — route-scoped services, per-component stores, provideX() helpers. Nothing reports the loss: the spec configures a spy, the component keeps the real service, and the assertion fails two steps away from the cause.
How far away is worth spelling out, because this is one of the most-reported traps in the whole library. @Component({ providers: [DeleteAccountService] }) with a module-level provideAutoSpy(DeleteAccountService) builds the real service, and what fails is whatever that service touches first — in one observed case a logger, with TypeError: Cannot read properties of undefined (reading 'pipe'). That message names neither the component, nor the provider, nor the spy.
There are two fixes and the choice is about intent. Use overrideComponentProvider when the spec wants a double at the component level; use TestBed.overrideComponent(..., { remove: { providers } }) when the module already provides the spy and the component's own declaration is simply in the way:
TestBed.overrideComponent(ProfileComponent, { remove: { providers: [DeleteAccountService] } });import { overrideAutoSpy, overrideComponentProvider } from 'vitest-auto-spy/angular';
// the component is instantiated through a parent's template, so it is not in `imports` yet
const menu = overrideComponentProvider(CatalogPageComponent, NavigationBuilderService); // → Spy<NavigationBuilderService>
// or, when the component is already in the testing module
TestBed.configureTestingModule({ imports: [CheckoutComponent] }).overrideProvider(PaymentMethodService, overrideAutoSpy(PaymentMethodService));overrideProvider(X, provideAutoSpy(X)) is not broken, contrary to what this page used to say. provideAutoSpy returns { provide, useValue }; overrideProvider reads useValue off it and ignores the extra provide, so the spy is installed. Prefer overrideAutoSpy because it says what it does and hands the spy back directly, not because the other form is a no-op.
The silent failure that is real: overrideProvider only reaches a component the TestBed compiler knows about. A standalone component instantiated through a parent's template is not in the testing module's imports, so the override never applies to it, and finding that out means knowing how TestBedCompiler.queueType works. overrideComponentProvider queues the component — as an import when it is standalone, as a declaration otherwise.
Do not reach for TestBed.overrideComponent here. It forces a JIT recompilation, and under an AOT test bundle that recompilation resolves the component's directives and pipes from a runtime scope the bundler has stripped, leaving it with none of them — see the next section.
An NgModule that contributes nothing
Under an AOT test bundle — what @angular/build:unit-test produces, and what a Jest suite moving to the native builder starts getting — ɵɵsetNgModuleScope is stripped, because only the TestBed reads it. Every NgModule then has an empty ɵmod.declarations / ɵmod.exports at runtime.
Nothing notices while AOT is in charge: the flat dependency list is already baked into each ɵcmp. But the moment the TestBed resolves a scope itself — through imports: [SomeModule], or through a JIT recompilation after overrideComponent — it resolves it from nothing, and reports that in four different ways, none of which mentions the module:
NG0303: Can't bind to 'appTruncate' since it isn't a known property of 'div'
NG0301: Export of name 'focusable' not found!
NG0304: 'ui-smart-row' is not a known element
(nothing at all — an attribute directive simply never instantiates)import { assertNgModuleScopes } from 'vitest-auto-spy/angular';
assertNgModuleScopes(DirectivesModule, PipesModule);
TestBed.configureTestingModule({ imports: [DirectivesModule, PipesModule] });The error names the module and the cause, and the fix is to declare what the spec needs in the TestBed module directly. Pass only modules you import for their declarations — a providers-only module is legitimately empty and would be reported as a false positive.
Focus assertions
import { registerFocusMatchers } from 'vitest-auto-spy/setup';
registerFocusMatchers(); // once, in the setup file
expect(fixture.nativeElement.querySelector('.play')).toHaveFocus();Focus tests are written in one of two shapes and both report nothing useful. expect(document.activeElement).toBe(button) prints two enormous DOM dumps with no visible difference; expect(activeFocus() === getElement(row)).toEqual(value) collapses the comparison to a boolean before expect ever sees it, and fails with expected false to deeply equal true — a message compatible with every possible cause.
The three causes worth telling apart are: the expected element does not exist at all (by far the most frequent), focus is still on <body> because nothing claimed it, and focus is on a different element. toHaveFocus names which of the three happened and describes both nodes by tag, id and class rather than by dumping their subtrees.
injectSpy and tokens
injectSpy takes an InjectionToken as well as a class, which matters in a codebase where half the dependencies live behind one (LIST_DATA_PROVIDER_TOKEN, ROOT_MEDIA_ELEMENT).
For a generic class, name the type argument. TestBed.inject infers Service<any> from the constructor rather than the declared default, and the any then surfaces much later as a mismatch between AddPromiseSpyMethods<unknown> and WithMockReturnValue<…> — eight levels deep, with nothing in the message about type parameters:
const config = injectSpy<FeatureFlagService>(FeatureFlagService);Zone and zoneless in the same run
// vitest-setup.ts
import { setupAngularTestEnv } from 'vitest-auto-spy/angular';
import { setupZoneTestEnv, setupZonelessTestEnv } from 'jest-preset-angular/setup-env';
setupAngularTestEnv({
zoneless: (testPath) => testPath.includes('/libs/catalog/') || testPath.includes('/apps/storefront/'),
initZone: setupZoneTestEnv,
initZoneless: setupZonelessTestEnv,
});TestBed.initTestEnvironment may be called once per platform, and under isolate: false the platform lives for the whole worker. A repository migrating to zoneless gradually — a few libraries switched, the rest still on zone.js — therefore cannot express itself in setup files at all: the second file the worker picks up in the other mode fails with Cannot set base providers because it has already been called, and the message names neither file.
Vitest's own answer, test.projects, does not solve it either. Nothing promises that a worker serves files of one project, and a worker handed a file of the other mode fails exactly the same way.
What works is to decide the mode from the file about to run and, when it differs from the one installed, tear the environment down before initialising the other. The mode is remembered per worker, so a run of files in the same mode pays for one initialisation and no resets.
The initialisers stay yours: which platform, which providers and which teardown policy a project wants is not something this library should decide, and the packages that supply them (@analogjs/vitest-angular, jest-preset-angular, a hand-written initTestEnvironment) are not dependencies of it.
A host for a directive under test
import { createDirectiveHost, registerDirectiveMatchers } from 'vitest-auto-spy/angular';
const Host = createDirectiveHost({
template: `<div [appTruncate]="enabled" [truncateText]="text"></div>`,
scope: [DirectivesModule],
props: { enabled: false, text: 'hello' },
});
TestBed.configureTestingModule({ imports: [Host] });
const fixture = TestBed.createComponent(Host);
fixture.componentInstance.enabled = true; // typed from `props`Under the native builder the two halves of Angular disagree about where imports is resolved, and the same line is alive in one place and dead in the other:
| Where | Resolved by | An NgModule there |
|---|---|---|
@Component({ imports }) | the AOT compiler, at build time | works — the flat list is baked into ɵcmp |
TestBed.configureTestingModule({ imports }) | TestBedCompiler, at runtime, from ɵmod | contributes nothing — ɵɵsetNgModuleScope is not emitted into a test bundle |
So the host must be standalone and must carry the module in its own imports. A host written standalone: false inside a spec is worse still: it is compiled outside any scope at all, with no NgClass, no AsyncPipe, nothing. createDirectiveHost is that knowledge applied — the host is always standalone, scope becomes the component's imports, and props types fixture.componentInstance.
toHaveDirectiveApplied
registerDirectiveMatchers(); // once, in the setup file
expect(fixture).toHaveDirectiveApplied(TruncateDirective, 'div');Angular reports a directive that is out of scope in three different wrong ways: NG0303 sends the reader to the @NgModule where the directive is correctly declared; NG0304 reports an absent directive as an absent component; and a directive used as a bare attribute, with no binding, reports nothing at all — a green test asserting on a directive that never ran.
The matcher asserts the fact, and its failure names the cause and the fix, including the one that looks like a fix and is not: schemas: [NO_ERRORS_SCHEMA] applies to a testing module's declarations and never to a standalone component, so next to a standalone component it is a dead entry that reads as if something were deliberately silenced.
Patching a property of a spy
const playback = injectSpy(PlaybackStateService);
mockSignalProp(playback, 'navigationState', 'idle'); // a real, writable signal
mockReadonlyProp(playback, 'currentItem', signal(item));The mock*Prop helpers accept the Spy<T> that injectSpy / asSpy returns, and check the value against the member's own type rather than the spy-decorated one. Without that, a signal-valued member on a spy is typed Signal<T> & Mock & …, no real signal can be written into it, and the spec has to keep the instance under a second name purely to patch it.
For a signal-valued getter prefer mockSignalProp over gettersToSpyOn: a spied getter returns undefined until it is configured, while a real signal keeps every computed() and effect() downstream of it reactive.
A dependency behind an InjectionToken
TestBed.configureTestingModule({ providers: [provideAutoSpyForToken(PASSCODE_SERVICE_TOKEN)] });
const passcode = injectSpy(PASSCODE_SERVICE_TOKEN); // Spy<PasscodeService>A token typed with an interface has no class to read, which is where the usual workaround comes from: a PasscodeServiceMock written in the spec, spied, and provided — after which Spy<Mock> and Spy<PasscodeService> disagree about calledWith and somebody reaches for an assertion (or, more often, TestBed.inject<any>(TOKEN) with an eslint-disable at the top of the file). provideAutoSpyForToken reads the type off the token; injectSpy already accepts one. Note the name: provideAutoSpy reads a class prototype, which a token has none of, so it is not the call that works here.
The second argument is needed more often than it looks. A spy answers undefined until it is told otherwise, which is fatal the moment the code under test chains off it: a constructor doing inject(LOGGER).channel('auth').debug('…') dies on the .debug of undefined before the spec's first line runs, because nothing in production wrote ?. there. Seed the link that returns the object:
provideAutoSpyForToken(LOGGER, { channel: vi.fn().mockReturnThis() });For a chain more than one link long, mockDeep<T>() is the double that answers at every level.
injectSpy says when it got the real thing
[vitest-auto-spy] injectSpy(DeviceRegistryService): the injector returned a plain instance, not an
auto-spy. Register it with provideAutoSpy(DeviceRegistryService) …A provider the spec forgot to register is otherwise found much later — when .mockReturnValue(…) is called on the real method, or, if the class has no private members to make the types disagree, never. The warning is printed once per token.