When Component Tests Fail Before the First Assertion: Separating Module, DOM, and Application Errors

The article helps to distinguish ESM/CommonJS import failures, missing browser APIs in the test environment, and genuine component defects. It uses a small reproducible fixture, compares a DOM simulator with a real browser, and demonstrates a focused regression test for each failure category.

Author:  Müslüm Yılmaz, creator of FrontendAtlas

I had followed Angular’s guide for moving an app’s unit tests from Karma to Vitest, and the first run stopped with more than a thousand TypeScript errors before a single test started. My first question was whether this was normal.

Under Karma, every one of those tests had passed in a real Chrome. Angular 21 makes Vitest its default test runner for new projects, and its setup runs tests in jsdom, a simulated DOM, unless you ask for a real browser.

That first run never tested anything. Most of its errors came from a setting the guide doesn’t mention: tsconfig.spec.json still listed Jasmine’s types. Switching them to “vitest/globals”  cleared most of the errors.

What remained were type errors in the mocks of several dozen spec files, and tests built on fakeAsync (Angular’s helper for faking time), for which the guide offers a zone.js patch and recommends a rewrite to Vitest’s fake timers. Both need their own fixes, so I excluded the files with type errors and left the fakeAsync  tests in the run, still red.

Once the suite compiled, 88 tests failed. Only 14 of them failed at an assertion.

Most of the rest never reached one, so they couldn’t tell me anything about the components.

Of those, 52 broke on a single browser API that jsdom doesn’t have, and most of them passed in Chromium without a change.

When Component Tests Fail Before the First Assertion: Separating Module, DOM, and Application Errors

So before fixing anything, I asked two questions of every failing spec: did any test start at all, and does the same spec pass in a real browser? I ran every spec twice with the same Vitest 4, once in jsdom and once in Chromium.

When no test starts at all

Before the suite compiled, the 76 spec files I had excluded still stopped the other 116 spec files. The exclude list of the test target in angular.json only kept them out of the test run, while the type check still compiled everything tsconfig.spec.json  includes. The 76 had to be excluded there as well.

A run like that hasn’t rendered a single component. Its first error names the file and line to start from, however long the list below it is.

To see the same thing come from a dependency, I used a small fixture project. Its PriceTag component imports formatPrice by name from price-format, a CommonJS package. Plain Node rejects that import (SyntaxError: Named export ‘formatPrice’ not found), but Vitest’s deps.interopDefault option, on by default, lets the test pass. With the option off, the same test fails inside the component with TypeError: formatPrice  is not a function, although the import is the cause.

Switching PriceTag’s import to a deep path, price-format/lib/format, works with version 1.0.0 of the package, but version 1.1.0 adds an exports map that lists only the package root. Node’s documentation says that adding one to an existing package “will likely be a breaking change.” After the upgrade, my run stopped with TS2307: Cannot find module ‘price-format/lib/format ‘. No test in any spec file ran. The Chromium run failed at the same line; both use the same build.

Importing from the package root fixes it. The tests can’t warn about this, since they all pass until the upgrade and none of them runs after it. ESLint’s no-restricted-imports  rule reports the deep import earlier, while it still works:

'no-restricted-imports': ['error', {
  patterns: [{
    group: ['price-format/*'],
    message: "Import from 'price-format'. Deep paths are not part of its public API.",
  }],
}],

This entry covers price-format  only, so deep imports from other packages go unreported until their packages are added to it.

A browser API jsdom doesn’t have

Two minutes after the jsdom run in the first screenshot, I ran the same spec in Chromium, where both of its tests passed.

When Component Tests Fail Before the First Assertion: Separating Module, DOM, and Application Errors

The stack trace in the first screenshot starts in GuideShell, the layout component around the guide pages. Line 897 there calls window.matchMedia  (the browser API that checks a CSS media query from script), and jsdom doesn’t implement it. That TypeError failed 34 tests in ten spec files named after ten different articles. Grouped by the first stack line in my own code, all 34 pointed at line 897. Sort failures that way before you open any spec file.

The line above 897, my guard for server rendering, returns early when isBrowser is false. But Angular’s test setup reports a browser platform in jsdom too, which I hadn’t expected, so isBrowser is true there. A platform check only tells you where the code runs. To know whether matchMedia exists, the code has to look for it. When I added typeof window.matchMedia !== ‘function’  to that check, all 34 passed. The whole suite went from 88 failures to 54.

The other matchMedia failures came from the tests’ own mocks. The guide’s conversion step had turned their Jasmine spies into vi.spyOn(window, ‘matchMedia’). In jsdom there’s nothing to spy on, so those tests stop in their setup with “Error: vi.spyOn() can only spy on a function. Received undefined.” A vi.stubGlobal call sets window.matchMedia in both environments; vi.unstubAllGlobals()  after each test puts the original back. Before I added that reset to my fixture project, one test in Chromium failed only because the test before it had left its mock in place.

ThemeBadge, one of the fixture’s components, reads matchMedia behind a typeof  check. Its spec has the reset and one regression test:

afterEach(() => vi.unstubAllGlobals());
it('falls back to the light theme when matchMedia is missing', async () => {
  vi.stubGlobal('matchMedia', undefined);
  const fixture = TestBed.createComponent(ThemeBadge);
  await fixture.whenStable();
  expect(fixture.nativeElement.textContent).toContain('light');
});

Take the check out of ThemeBadge and this test fails in jsdom and in Chromium alike.

No spec checks where GuideShell places its panels, and in jsdom none could; a box set to 120 pixels wide measures zero there. The ten article specs check sections and links, so for them I think the extra condition and jsdom are enough. I’d keep that condition over a matchMedia  stub in the test setup. A stub would answer the media query for the whole suite from a file no spec mentions. If I ever want the panel positions tested, I’d write that test for Chromium.

Red in both environments

Only 14 of the 88 had failed at an assertion. Eleven came from the spec for CvLinterComponent, which checks CVs, and each one failed with the same message in jsdom as in Chromium. One expected the analyze button to say Re-analyze and found ‘ Analyzing… ‘ instead; the others looked for a report that wasn’t on the page yet. The component was still loading.

CvLinterComponent skipped a seven-second scan animation whenever it found window.__karma__, the global Karma defines on its test page. I had added that check myself in February, with the loading screen. Under Vitest the global isn’t there, so the tests looked at the page mid-animation. To confirm it, I put the global back for one run. Those tests passed in jsdom and in Chromium, while four fakeAsync tests in that spec stayed red for another reason. Before switching runners, search your app code outside the specs for __karma__ ; mine had it in one file. My fix would be a delay a test can set, so the component stops asking which runner it’s under.

None of the tests that failed in both environments crashed inside a component. The fixture project has one I built on purpose: OrderSummary reads items!.length, although its interface marks items as optional (the !, a non-null assertion, tells the compiler not to check). Both of my runs failed on that line with TypeError: Cannot read properties of undefined (reading ‘length’), at order-summary.ts:19. With items?.length ?? 0  they went green, and this regression test keeps them green:

it('shows a new order that has no items yet', async () => {
  const fixture = TestBed.createComponent(OrderSummary);
  fixture.componentRef.setInput('order', { id: 'A-1042' });
  await fixture.whenStable();
  expect(fixture.nativeElement.textContent).toContain('0 items');
});

In my runs, the first stack line told the two kinds apart: the OrderSummary crash started in order-summary.ts , while each CvLinterComponent failure started at a line in its spec.

Which check to run first

The first Chromium run, on the same code as the run with 88 failures, cleared 49 of them and added eleven of its own. I was back to asking whether this was normal. Nine of the eleven were in the spec for the robots meta tag, which tells search engines whether to index a page. The app sets it to noindex when the page URL has a query string (the part after the question mark). Vitest’s browser page carries ?sessionId=…&iframeId=… ; jsdom’s has none. Code that reads the page URL reads the runner’s page in a browser run. The other two failed in test-module setup, and I didn’t chase them.

The checks go in order of cost. The run summary already says whether any test started; if none did, look at the build. A browser run then sorts the environment failures out at once. I had expected the browser to be the slower run. It took about 20 seconds against 28 for jsdom, less than reading the stack traces one by one. Whatever stays red in both gets its first stack line read. In my runs, a component file meant the component had crashed there. A spec line meant the test, or app code that behaved differently under the new runner, as the Karma check did.

The browser run was not stable. Three Chromium runs gave three different failure counts, and one spec failed all of its tests in a full run and passed alone, twice; jsdom gave 88 every time. Take the jsdom count as the baseline, and read a Chromium count only after more than one run. I didn’t try happy-dom, or a runner other than Vitest.

Of the 88, 52 were the browser API, 19 were fakeAsync , 12 were the Karma check, and the last five were converted spies, a matcher and one style value that jsdom computes differently. In none of them did I find a component that had stopped working for its users.

References

  1. Angular documentation, “Migrating to Vitest”:
    https://angular.dev/guide/testing/migrating-to-vitest
  2. Node.js documentation, “Modules: Packages”, section “Package entry points”:
    https://nodejs.org/api/packages.html#package-entry-points
  3. Vitest configuration reference, “deps.interopDefault”:
    https://vitest.dev/config/deps#deps-interopdefault
  4. Vitest API reference, “vi.stubGlobal” and “vi.unstubAllGlobals”:
    https://vitest.dev/api/vi.html#vi-stubglobal
  5. Vitest guide, “Browser Mode”:
    https://vitest.dev/guide/browser/
  6. ESLint rules, “no-restricted-imports”:
    https://eslint.org/docs/latest/rules/no-restricted-imports
  7. jsdom README, “Unimplemented parts of the web platform”:
    https://github.com/jsdom/jsdom#unimplemented-parts-of-the-web-platform
  8. MDN Web Docs, “Window: matchMedia() method”:
    https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia

All pages as read on October 29, 2026.

About the Author

Müslüm Yılmaz is a senior frontend engineer in Istanbul with a focus on Angular architecture, performance and testing. Since 2019 Müslüm has built applications for banking, airline and energy products, and also runs FrontendAtlas, a frontend interview preparation site with runnable coding tasks.

Be the first to comment

Leave a Reply

Your email address will not be published.


*


This site uses Akismet to reduce spam. Learn how your comment data is processed.