In Angular, when can you unit-test a service with a plain new instead of TestBed, and how do you swap its dependencies for doubles?
answer
- constructor parameters versus inject()
- no injection context outside DI
- a provider is the test seam
- useValue or useClass on the token
basics
~10 sPlain new works when dependencies arrive as constructor parameters; a service using inject() needs an injection context, so use TestBed.inject() and replace dependencies with providers such as { provide: UserProfileApi, useValue: fake }.
solid answer
~40 sIf a service takes its collaborators as constructor parameters, I can test it with `new UserProfileStore(fakeApi)`: no Angular, just objects. Most current services use `inject()` instead, and `inject()` needs an injection context, so a bare `new` throws NG0203. Then I use TestBed: `configureTestingModule({ providers: [{ provide: UserProfileApi, useValue: fakeApi }] })` and `TestBed.inject(UserProfileStore)`. The provider replaces the real dependency for this test's injector; `useClass` works for a reusable fake class. Root-provided services need no provider entry, and a test provider for the same token overrides the root one. `TestBed.runInInjectionContext(() => new X())` is an option when I need to construct manually but still resolve `inject()` calls.
code
ts · 37 linesimport { Injectable, inject, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
@Injectable({ providedIn: 'root' })
export class UserProfileApi {
async load(id: string): Promise<{ id: string; displayName: string }> {
throw new Error('real network call');
}
}
@Injectable({ providedIn: 'root' })
export class UserProfileStore {
private api = inject(UserProfileApi);
readonly displayName = signal('');
async select(id: string): Promise<void> {
const profile = await this.api.load(id);
this.displayName.set(profile.displayName);
}
}
describe('UserProfileStore', () => {
it('stores the display name of the selected user', async () => {
const fakeApi: Pick<UserProfileApi, 'load'> = {
load: async (id) => ({ id, displayName: 'Ada' }),
};
TestBed.configureTestingModule({
providers: [{ provide: UserProfileApi, useValue: fakeApi }],
});
const store = TestBed.inject(UserProfileStore);
await store.select('42');
expect(store.displayName()).toBe('Ada');
});
});go deeper
Recall TestBed.inject for getting a service and { provide: Token, useValue: fake } for replacing one of its dependencies.
Explain why inject() rules out a bare new, what NG0203 and NG0201 mean, and how a test-module provider overrides a root-provided one.
Show judgment about which collaborators to fake, how to type fakes against the real API so they cannot drift silently, and when runInInjectionContext is the cleaner tool.
Discuss how a team's fake library and shared providers keep hundreds of service tests consistent, and the cost of fakes that no longer match production behaviour.
## Two ways to get a service under test A **service** in Angular is a class whose instance is created and handed out by **dependency injection (DI)**. To unit-test one you need an instance plus control over its **dependencies**, the other services it asks for. There are two ways to get that. - **Plain construction**: `new UserProfileStore(fakeApi)`. No Angular machinery; the test passes collaborators in by hand. - **TestBed**: `TestBed.configureTestingModule({ providers: [...] })` builds a test injector, and `TestBed.inject(UserProfileStore)` asks it for the instance, exactly as the application would. ## When plain `new` works, and when it cannot Plain construction works when the class receives everything through **constructor parameters**, or has no dependencies at all. The test is then an ordinary object test: fast, no configuration, fully explicit. It stops working when the class calls **`inject()`**, the default style for new Angular code: - `inject()` in a field initializer or constructor body needs an **injection context**. Called from a bare `new`, it throws **NG0203** ("`inject()` must be called from an injection context"). - APIs built on top of `inject()` need one too, unless you hand them an injector or `DestroyRef` explicitly: `effect()`, `toSignal()`, and `takeUntilDestroyed()` without an argument. `TestBed.runInInjectionContext(() => new UserProfileStore())` is a middle path: the constructor runs inside TestBed's environment injector, so `inject()` resolves from the providers you configured. Most teams simply use `TestBed.inject()`, which builds the instance inside that injector and returns the same instance to every later request in the test. ## Swapping a dependency with providers The **provider** is the seam. In `configureTestingModule`, a provider for the dependency's token replaces the real one for this test's injector: | Provider form | What the service receives | Typical use | |---|---|---| | `{ provide: UserProfileApi, useValue: fake }` | the object you built | a small hand-written fake or a runner spy object | | `{ provide: UserProfileApi, useClass: FakeUserProfileApi }` | a new instance of the fake class | a reusable fake with its own logic | | no provider | the real `UserProfileApi` | when the real one is cheap and deterministic | Points that matter in interviews: 1. A service marked `@Injectable({ providedIn: 'root' })`, or `@Service()` since v22, needs **no provider entry** to be injectable in tests; a test-module provider for the same token **overrides** the root one. 2. A class with plain `@Injectable()` (no `providedIn`), or `@Service({ autoProvided: false })`, **must** be listed, or `TestBed.inject` fails with **NG0201** ("No provider found"). 3. Type the fake against the real class (`Pick<UserProfileApi, 'load'>` or a full implementation) so a renamed method breaks the test at compile time. 4. Fake only what the unit touches. Replacing every collaborator turns the test into a check of your own fakes. 5. TestBed is reset between tests, so each spec configures its own providers and gets fresh instances. ## What kind of double to hand in The provider decides *where* the double goes; the double itself can take several shapes: - A **stub** returns fixed values (`load` always resolves to the same profile). It controls the input to the unit under test. - A **fake** is a small working implementation, such as an in-memory map of profiles. It suits tests that make several calls and expect consistent state. - A **spy** records calls so the test can assert that the service asked for profile `42` exactly once. Spy helpers come from the test runner, not from Angular, so the syntax differs between runners; the provider wiring does not. Whatever the shape, assert on the **service's observable behaviour** first (the signal it exposes, the value it returns) and on interactions only when the interaction is itself the requirement, for example "does not call the API twice for the same id". ## Choosing between them - Use **plain `new`** for pure logic classes and constructor-injected services where speed and explicitness matter. - Use **TestBed** for anything that uses `inject()`, signals with effects, `DestroyRef`, or `HttpClient`; it is also the only option when the service must see the same provider graph as the app. - Do **not** refactor a service back to constructor injection only to avoid TestBed. `inject()` is the recommended style, and TestBed setup for a service is a few lines. One trap belongs to TestBed's override API rather than to this topic: a dependency provided in a **component's** own `providers` array is not replaced by a module-level provider, because the component's injector sits closer to the consumer. Overriding it uses TestBed's override methods.
- What happens if you write new UserProfileStore() when the class uses inject() in a field initializer?The field initializer calls `inject()` outside any injection context, so Angular throws NG0203, '`inject()` must be called from an injection context'. Either obtain the instance through `TestBed.inject()`, or construct it inside `TestBed.runInInjectionContext(() => new UserProfileStore())`, which resolves the `inject()` calls from TestBed's configured providers.
- TestBed.inject(UserProfileStore) fails with NG0201. What is missing?No provider exists for the class. That happens when it is decorated with plain `@Injectable()` without `providedIn`, or with `@Service({ autoProvided: false })`. Add the class itself to the `providers` array of `configureTestingModule`, or make it root-provided if that is how the app provides it.
- Why not replace every dependency with a fake by default?Each fake is a claim about how the real collaborator behaves, and it can drift. Replace dependencies that are slow, non-deterministic or reach outside the process, such as the network, time or storage, and keep cheap, pure collaborators real so the test still exercises the code the app runs.
saying these in an interview costs you the question
- Any Angular service can always be tested with a plain new
- A root-provided service must still be listed in TestBed's providers
- A test provider cannot override a service provided in root
- inject() works anywhere as long as TestBed has been configured
- Switch services to constructor injection just to avoid TestBed