Mert Özen Further With Every Line
Testing & Quality Frontend Testing — #05

Faking Dependencies in Angular Tests: Provider Overrides, vi.fn, ng-mocks, MSW and RouterTestingHarness

Mert Özen Nov 14, 2026 12 min 13 views
Faking Dependencies in Angular Tests: Provider Overrides, vi.fn, ng-mocks, MSW and RouterTestingHarness

Fake what leaves the process, such as HTTP, time and browser APIs, and keep cheap dependencies like CartStore and the router real: a test that mocked everything missed a bug that emptied the cart before placing the order. On Bookstore's checkout and order detail pages we show provider overrides, vi.fn, vi.spyOn, hand-written fakes, child components with ng-mocks, network-level fakes with MSW 3 and RouterTestingHarness tests with the real routes, all with real runs.

Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0), Vitest 5.1, jsdom 30, @testing-library/angular 19.6, MSW 3.0 and ng-mocks 14.18 on Node.js 24.

Guess First

The checkout page of Bookstore, the online bookstore used as the shared example across this site's series, combines the details from the form in post 4 with the cart lines, sends them to OrderApi, empties the cart and goes to the new order's page. After a refactor the place method looked like this:

async place(details: CheckoutDetails): Promise<void> {
  this.failed.set(false);
  try {
    this.cart.clear();
    const lines = this.cart.lines().map((line) => ({ isbn: line.book.isbn, quantity: line.quantity }));
    const order = await this.orders.place({ ...details, lines });
    await this.router.navigate(['/orders', order.id]);
  } catch {
    this.failed.set(true);
  }
}

The page's test fakes every dependency: OrderApi, CartStore and Router are each an object of vi.fn()s. The user fills in the form and presses "Place order":

it('sends the cart lines and opens the new order (all mocked)', async () => {
  const orders = { place: vi.fn().mockResolvedValue(placedOrder) };
  const cart = { lines: vi.fn().mockReturnValue([{ book: dune, quantity: 2 }]), clear: vi.fn() };
  const router = { navigate: vi.fn().mockResolvedValue(true) };
  await render(CheckoutPage, {
    providers: [
      { provide: OrderApi, useValue: orders },
      { provide: CartStore, useValue: cart },
      { provide: Router, useValue: router },
    ],
  });

  await fillAndSubmit(userEvent.setup());

  expect(orders.place).toHaveBeenCalledWith(expect.objectContaining({ lines: [{ isbn: dune.isbn, quantity: 2 }] }));
  expect(cart.clear).toHaveBeenCalled();
  expect(router.navigate).toHaveBeenCalledWith(['/orders', 42]);
});

Is there a bug in the code? If so, does this test catch it?

Answer: A Mock Does Not Carry the Real Behavior

The test passed. The code, however, has a serious bug: the cart is emptied before the lines are read, so in production every order reaches the API with empty lines. In the test, cart.lines is a vi.fn() that returns a fixed value; it gives the same lines whether clear() was called or not. In the real CartStore, clear() empties the signal and the next lines() returns an empty array. The mock does not carry the most important behavior of the real service: the relationship between its methods.

When I wrote the same test with the real CartStore and the real router, faking only OrderApi, the bug showed up immediately:

AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…} ]

  1st vi.fn() call:

  [
-   ObjectContaining {
-     "lines": [
    {
-         "isbn": "9780441013593",
-         "quantity": 2,
-       },
-     ],
+     "city": "Izmir",
+     "email": "ada@example.com",
+     "fullName": "Ada Lovelace",
+     "lines": [],
    },
  ]

The right order is to read the lines, send the order, and only then empty the cart; that is what Bookstore's code does. The question of this post is: what should you fake in an Angular test, what should you keep real, and when you fake something, with which tool? The conceptual side, the difference between dummy, stub, spy, mock and fake, is covered in the Unit Test series post "Test Doubles", and why over-mocking produces brittle tests in "When Not to Mock". Here we look at what those mean in Angular.

What Should You Fake?

The rule we use in Bookstore is simple: fake what leaves the process, keep what is cheap inside the process real. HTTP, time, randomness and browser APIs missing from jsdom are faked. CartStore, PricePipe, the router and small child components stay real; they are fast, deterministic, and their behavior is part of the test. The options and their costs:

ToolWhat it fakesWhen
Provider override + vi.fnone method of a servicea one-off answer, checking the call arguments
vi.spyOnone method of the real servicewhen the rest of the service must stay real
Hand-written fakea whole servicestateful behavior used across several tests
ng-mocks MockComponenta child componentwhen the child is expensive or has its own dependencies
HttpTestingControllerthe HttpClient backendtesting the shape of a request, Angular-specific
MSWthe networkpage tests, reusing the same handlers in other environments
RouterTestingHarnessnothing, the router is realguards, resolvers, route params

vi.fn, vi.spyOn and Hand-Written Fakes

In Angular the way to replace a dependency is dependency injection: give the token a different value through providers. vi.fn is the shortest route to a one-off answer. Passing a type parameter makes the mock match the real method's signature:

it('sends the cart lines and opens the new order', async () => {
  const place = vi.fn<OrderApi['place']>().mockResolvedValue(placedOrder);
  await render(CheckoutPage, { providers: [{ provide: OrderApi, useValue: { place } }], routes });
  const cart = fillCart();

  await fillAndSubmit(userEvent.setup());

  expect(place).toHaveBeenCalledExactlyOnceWith({
    fullName: 'Ada Lovelace',
    email: 'ada@example.com',
    city: 'Izmir',
    lines: [{ isbn: dune.isbn, quantity: 2 }],
  });
  expect(cart.lines()).toEqual([]);
  expect(TestBed.inject(Router).url).toBe('/orders/42');
});

routes here is [{ path: 'orders/:id', children: [] }]; the router is real, only the target page is empty. That way we check that the URL really is /orders/42, not which argument router.navigate was called with.

vi.spyOn replaces a single method of the real service and leaves the rest as it is. Thanks to the restoreMocks: true we put in vitest-base.config.mts in post 1, the spy is undone automatically before the next test:

it('keeps the cart and shows an error when the order fails', async () => {
  await render(CheckoutPage, { routes });
  const cart = fillCart();
  vi.spyOn(TestBed.inject(OrderApi), 'place').mockRejectedValue(new Error('503'));

  await fillAndSubmit(userEvent.setup());

  expect(await screen.findByRole('alert')).toHaveTextContent('We could not place your order. Please try again.');
  expect(cart.count()).toBe(2);
  expect(TestBed.inject(Router).url).toBe('/');
});

A hand-written fake is a small class with behavior. When it is used across several tests or has to keep state between calls, it reads better than chains of vi.fn:

class FakeOrderApi {
  readonly requests: PlaceOrderRequest[] = [];

  place(request: PlaceOrderRequest): Promise<Order> {
    this.requests.push(request);
    return Promise.resolve({ ...placedOrder, id: 100 + this.requests.length });
  }
}

it('opens every new order it places', async () => {
  await render(CheckoutPage, { providers: [{ provide: OrderApi, useClass: FakeOrderApi }], routes });
  const fake = TestBed.inject(OrderApi) as unknown as FakeOrderApi;
  const user = userEvent.setup();
  fillCart();

  await fillAndSubmit(user);
  expect(TestBed.inject(Router).url).toBe('/orders/101');

  fillCart();
  await user.click(screen.getByRole('button', { name: 'Place order' }));
  expect(TestBed.inject(Router).url).toBe('/orders/102');
  expect(fake.requests).toHaveLength(2);
});

For people coming from Jasmine, the equivalents: { place: vi.fn() } instead of jasmine.createSpyObj('OrderApi', ['place']), mockReturnValue instead of and.returnValue, mockResolvedValue instead of and.resolveTo, mockImplementation instead of and.callFake. The mapping is one to one; but the habit itself carries over too, and that is where the problem lies.

Faking a Child Component: ng-mocks

So far CheckoutPage's tests have used the real CheckoutForm; every test fills in the form with user-event. You can also fake the form and test only the page's own logic. ng-mocks' MockComponent creates a component that carries the real component's selector, inputs and outputs but has an empty template. Angular Testing Library's importOverrides option swaps an import of a standalone component in the test:

it('opens the order when the form emits', async () => {
  const place = vi.fn<OrderApi['place']>().mockResolvedValue(placedOrder);
  await render(CheckoutPage, {
    importOverrides: [{ replace: CheckoutForm, with: MockComponent(CheckoutForm) }],
    providers: [{ provide: OrderApi, useValue: { place } }],
    routes,
  });

  ngMocks.findInstance(CheckoutForm).placeOrder.emit({ fullName: 'Ada Lovelace', email: 'ada@example.com', city: 'Izmir' });

  await vi.waitFor(() => expect(TestBed.inject(Router).url).toBe('/orders/42'));
  expect(screen.queryByLabelText('Email')).not.toBeInTheDocument();
});

The last line shows that the form really is faked: there is no email field on the page. The peer dependency range of ng-mocks 14.18 covers Angular 22, and it worked without problems with the signal-based output().

This test is fast and unaffected by changes in the form. That is also its cost: the form is no longer tested the way the page uses it. If the name of the form's output changes, TypeScript complains on the findInstance(...).placeOrder line; but the form's validation, its submit behavior, and whether the page works together with the form are invisible in this test. In Bookstore we fake a child component in only two cases: when the child has heavy dependencies of its own (a map, an editor, a payment widget) or when rendering it noticeably slows the test down. CheckoutForm fits neither; this test is in the snapshot to show the approach.

Faking HTTP at Two Levels: HttpTestingController and MSW

In posts 2 and 3 we faked HTTP with HttpTestingController. It replaces HttpClient's backend: the request never reaches the network layer, and the test has to find and answer each request by hand. It is the right tool for service tests, because the service's contract is the request itself.

In page tests, answering every request one by one clutters the test. MSW works at a different level: in Node it intercepts fetch. In Angular 22 the default backend of HttpClient is FetchBackend (in the source, HttpBackend is defined with useExisting: FetchBackend), so no extra setup is needed in between. Handlers are written like a fake version of the API:

// src/mocks/handlers.ts
export const handlers = [
  http.get('/api/orders/:id', ({ params }) =>
    params['id'] === String(placedOrder.id)
      ? HttpResponse.json(placedOrder)
      : HttpResponse.json({ title: 'Not Found', status: 404 }, { status: 404 }),
  ),
  http.post('/api/orders', () => HttpResponse.json(placedOrder, { status: 201 })),
];
// src/testing/msw.ts
export const server = setupServer(...handlers);

export function useMockApi(): void {
  beforeAll(() => server.listen({ onUnhandledFrame: 'error' }));
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());
}

In MSW 3 the option for unhandled requests is called onUnhandledFrame; writing onUnhandledRequest as in MSW 2 examples gave a TS2353 compile error. The 'error' value reports a request without a handler like this instead of letting it through silently:

[MSW] Error: intercepted a request without a matching request handler:

  • GET /api/books

If you still wish to intercept this unhandled request, please create a request handler for it.

Relative URLs work because location.href is http://localhost:3000/ in Vitest's jsdom environment. A test's one-off answer is added with server.use(). The resetHandlers() in afterEach is required: when I removed it, the 403 handler added by the first test was still active in the second, which failed with HttpErrorResponse: Http failure response for http://localhost:3000/api/orders/42: 403 Forbidden. Another advantage of faking at the network level is seeing how the request actually goes out; the test below checks the JSON that was sent without faking OrderApi at all:

it('posts the order as JSON', async () => {
  let body: unknown;
  server.use(
    http.post('/api/orders', async ({ request }) => {
      body = await request.json();
      return HttpResponse.json(placedOrder, { status: 201 });
    }),
  );
  await render(CheckoutPage, { routes });
  fillCart();

  await fillAndSubmit(userEvent.setup());

  await vi.waitFor(() => expect(TestBed.inject(Router).url).toBe('/orders/42'));
  expect(body).toEqual({ fullName: 'Ada Lovelace', email: 'ada@example.com', city: 'Izmir', lines: [{ isbn: dune.isbn, quantity: 2 }] });
});

According to the MSW documentation, the same handlers array also runs in the browser with setupWorker from the msw/browser package; the Vitest browser mode tests in post 7 and, if you like, the development server can use the same fake API. Which one when: if you are testing that a service builds its request correctly, use HttpTestingController; if you are testing that a page or a route shows the user the right thing, use MSW.

The Router: Guards, Resolvers and Route Params with RouterTestingHarness

The order detail page lives at the /orders/:id route. Entry to the route is protected by authGuard, a resolver produces the page title, and the id parameter reaches the component as an input thanks to withComponentInputBinding():

export const authGuard: CanActivateFn = (_route, state) =>
  inject(Session).signedIn() || inject(Router).createUrlTree(['/sign-in'], { queryParams: { returnUrl: state.url } });

export const orderTitle: ResolveFn<string> = (route) => `Order ${route.paramMap.get('id')} · Bookstore`;

{
  path: 'orders/:id',
  canActivate: [authGuard],
  title: orderTitle,
  loadComponent: () => import('./orders/order-detail').then((m) => m.OrderDetail),
}

OrderDetail turns the id input into /api/orders/{id} with httpResource; on 403 and 404 responses it says "This order could not be found." Not even revealing whether another customer's order exists is a deliberate decision. Instead of testing the guard and the resolver separately as functions, we test them with the app's real route definition. RouterTestingHarness sets up a router outlet in the test and performs a real navigation:

describe('Order detail route', () => {
  useMockApi();

  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({ providers: [provideRouter(routes, withComponentInputBinding())] });
  });

  function signIn(): void {
    TestBed.inject(Session).user.set({ email: 'ada@example.com', token: 'test-token' });
  }

  it('shows the order whose id is in the URL', async () => {
    signIn();
    const harness = await RouterTestingHarness.create();

    await harness.navigateByUrl('/orders/42');

    expect(await screen.findByRole('heading', { name: 'Order BS-1042' })).toBeInTheDocument();
    expect(screen.getByRole('list', { name: 'Order lines' })).toHaveTextContent('2 × Dune');
    expect(screen.getByText('Total: ₺1.280,00')).toBeInTheDocument();
  });

  it('sends a signed-out visitor to sign in and remembers the order', async () => {
    const harness = await RouterTestingHarness.create();

    await harness.navigateByUrl('/orders/42');

    expect(TestBed.inject(Router).url).toBe('/sign-in?returnUrl=%2Forders%2F42');
    expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
  });

  it("does not reveal another customer's order", async () => {
    server.use(http.get('/api/orders/:id', () => HttpResponse.json({ title: 'Forbidden', status: 403 }, { status: 403 })));
    signIn();
    const harness = await RouterTestingHarness.create();

    await harness.navigateByUrl('/orders/77');

    expect(await screen.findByRole('alert')).toHaveTextContent('This order could not be found.');
  });
});

routes is the real definition from app.routes.ts. That also tests that the guard is really attached to this route, that id reaches the component through withComponentInputBinding, and that the lazy loaded component loads. Another test checks that TestBed.inject(Title).getTitle() is "Order 42 · Bookstore". This is the level we called "routed feature" in the test map from post 1: router, guard, page and children are real; only the network is faked. The four tests took 114 and 154 ms in total over two runs.

In Production

Handlers are a copy of the API, and they go stale when the API changes. If the shape of placedOrder drifts from the API's real response, every page test stays green while the app breaks. Writing the handler data with types generated from the API's OpenAPI definition turns that drift into a compile error; checking both sides together, end to end, against a real backend is the subject of the Integration and E2E Testing series.

Keep test helpers out of the app build. src/testing/msw.ts uses Vitest globals such as beforeAll; because tsconfig.app.json includes every src/**/*.ts file, ng build stopped with TS2304: Cannot find name 'beforeAll'. We excluded src/testing/** in tsconfig.app.json and added it to tsconfig.spec.json.

At the end of this post the suite has 15 files and 56 tests and runs in about 1.2 seconds. Page-level tests (60–170 ms) are noticeably slower than component tests because they include filling forms and navigating. That is still much faster than E2E tests; but testing every behavior at page level slows the suite down quickly. The rule is the one from post 1: the lowest level that can see the behavior.

Common Pitfalls

Mocking everything and testing nothing. The test at the start of this post repeated which methods the code calls in which order; because it checked the implementation rather than the behavior, it could not see the bug. Keep cheap in-process dependencies such as CartStore and Router real.

Carrying over the createSpyObj habit. A vi.fn() without a return value returns undefined. When I left OrderApi.place like that, the TypeError thrown while reading order.id landed in the page's catch block, and the test failed with a page showing "We could not place your order". The error message suggested the API had failed; the real problem was an incomplete mock. Typed mocks such as vi.fn<OrderApi['place']>() surface these gaps earlier.

Forgetting resetHandlers. A handler added with server.use() stays active in later tests until it is reset. It is MSW's version of the module state problem from post 1.

Calling TestBed.inject before render(). As we saw in post 4, Angular Testing Library configures TestBed itself; injecting a service first gives you a configuration error. Set up spies and fakes after render() or pass them through providers.

Testing a guard only as a function. You can call authGuard inside TestBed.runInInjectionContext and check the returned UrlTree, but that does not show the guard is attached to the route. Removing the guard from the route definition does not break such a test; a test written with RouterTestingHarness does break.

A Small Experiment

★☆☆ Add a server.use() handler that answers OrderDetail with a 500 and test that the page shows "Could not load the order."

★★☆ Remove authGuard from the orders/:id route and run the tests. Which test failed? Would a function test of the same guard written with TestBed.runInInjectionContext have caught this change?

★★★ Look at the SignInPage tests: after a successful sign-in, does it return to returnUrl? With a wrong password, does Session stay empty? Then add a 1-second delay() to the MSW handler and find out what you need to change for the test to keep passing.

The tests in this post found the DOM by role and name; but in the components of libraries such as Angular Material, which Bookstore will use later, the DOM structure is not under your control. In the next post we look at Angular's answer to that problem: component harnesses, using the CDK harnesses and writing your own harness for RatingInput.