Migrating from Karma and Jasmine to Vitest and What Changed in Angular Testing from 12 to 22
Moving from Karma to Vitest takes two steps: migrate-karma-to-vitest converts the build settings and refactor-jasmine-vitest the test code, but a clean report does not mean the tests are right. On Bookstore's old client we show the check lost when the done callback was removed, mock types that did not compile, two ways to handle fakeAsync, a gradual move alongside Karma and what changed in Angular testing from 12 to 22, 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, Zone.js 0.16 and, for the state before the migration, Karma 6.4 and Jasmine 6.3 on Node.js 24.
Guess First
legacy-web, the old Angular client of Bookstore, the online bookstore used as the shared example across this site's series, has been upgraded to Angular 22, but its tests still run on Karma and Jasmine. They are written in the style of the Angular series' introduction to testing (Angular 12, Karma). The test of the book search service looks like this:
it('should search books with a GET request', (done: DoneFn) => {
const books: Book[] = [{ isbn: '9780441013593', title: 'Dune', price: 450 }];
service.search('dune').subscribe((result) => {
expect(result).toEqual(books);
done();
});
const req = httpMock.expectOne('/api/books?search=dune');
expect(req.request.method).toBe('GET');
req.flush(books);
});
Angular 22's migration and refactoring schematic moved the suite to Vitest. The schematic's report is clean:
| Files Scanned | 6 |
| Files Transformed | 4 |
| Files Skipped | 2 |
| Total TODOs | 0 |
## No Manual Changes Required
The test became this:
it('should search books with a GET request', async () => {
const books: Book[] = [{ isbn: '9780441013593', title: 'Dune', price: 450 }];
service.search('dune').subscribe((result) => {
expect(result).toEqual(books);
});
const req = httpMock.expectOne('/api/books?search=dune');
expect(req.request.method).toBe('GET');
req.flush(books);
});
A few weeks later someone adds a filter to search(), and the service no longer emits results with a single book at all. Does the migrated test catch this bug? Would the old Jasmine test have caught it?
Answer: done Is Gone, and So Is the Check
I tried the bug in both versions. On Vitest the suite stayed green:
Tests 11 passed (11)
The original test on Karma failed:
BookService should search books with a GET request FAILED
Error: Timeout - Async function did not complete within 5000ms (set by jasmine.DEFAULT_TIMEOUT_INTERVAL)
In the Jasmine test, done() did two jobs: it made the test asynchronous, and it checked that "the callback ran at least once". When the callback never ran, done() was never called and the test hit the timeout. The schematic removed done and made the test async; the assertion is still inside subscribe, but if the callback never runs, the assertion never runs either and the test passes empty. The report says "No Manual Changes Required" because the schematic translated the syntax correctly; the meaning, however, changed.
The right way is to turn the value into a Promise with firstValueFrom and await it. If the observable completes without emitting, the Promise is rejected:
it('should search books with a GET request', async () => {
const books: Book[] = [{ isbn: '9780441013593', title: 'Dune', price: 450 }];
const result = firstValueFrom(service.search('dune'));
const req = httpMock.expectOne('/api/books?search=dune');
expect(req.request.method).toBe('GET');
req.flush(books);
expect(await result).toEqual(books);
});
With the same bug this test failed: EmptyError: no elements in sequence. This post covers the steps of moving from Karma and Jasmine to Vitest, what the schematic converts and what it cannot, and what changed in testing from Angular 12 to 22. Vitest becoming the default in Angular 21 is covered by the Angular series post on Angular 21; here we migrate an existing suite.
The Two Steps of the Migration
The migration is done with two separate tools: one changes the build settings, the other the test code.
The first step is angular.json. The test target of legacy-web used the old @angular/build:karma builder, and the polyfills, assets and styles were inside the test target. In Angular 22, the migration that converts this is one of the optional migrations of ng update:
npx ng update @angular/cli --name migrate-karma-to-vitest
--- Karma to Vitest Migration Summary ---
Projects migrated: 1
- legacy-web
Note: To refactor your test files from Jasmine to Vitest, consider running the following command:
ng g @schematics/angular:refactor-jasmine-vitest <project_name>
UPDATE tsconfig.spec.json (409 bytes)
UPDATE angular.json (2560 bytes)
UPDATE package.json (1061 bytes)
The migration switched the test target to @angular/build:unit-test and wrote runner: "vitest". Test-specific build options moved to a new testing build configuration; aot: false and the zone.js/testing polyfill are there too, to keep the Karma behavior. In tsconfig.spec.json the jasmine types became vitest/globals, and vitest was added to package.json. One thing was not added; the first run ended like this:
The following packages are required but were not found:
- A DOM environment is required for non-browser tests. Please install either "jsdom" or "happy-dom".
After npm install --save-dev jsdom, the build stopped at the test files this time: TS2304: Cannot find name 'jasmine', Cannot find name 'DoneFn', Cannot find name 'spyOn'. The test code is still Jasmine.
The second step is the schematic that converts the test code. The --fake-async option also converts fakeAsync tests to Vitest's fake timers:
npx ng g @schematics/angular:refactor-jasmine-vitest --fake-async
Most conversions were mechanical and correct. jasmine.createSpyObj was replaced by an object holding vi.fn(), and.returnValue became mockReturnValue, and fail() became expect.fail(). In Jasmine spyOn does not call the real method by default, in Vitest it does; the schematic knows this difference and removed and.callThrough() as redundant. The converted debounced search test:
describe('BookSearchComponent', () => {
beforeEach(() => {
vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it('should search after the user stops typing', async () => {
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
input.value = 'dune';
input.dispatchEvent(new Event('input'));
await vi.advanceTimersByTimeAsync(299);
expect(bookService.search).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
fixture.detectChanges();
expect(bookService.search).toHaveBeenCalledWith('dune');
expect(fixture.nativeElement.querySelector('li').textContent).toBe('Dune');
});
});
The tick(299) → await vi.advanceTimersByTimeAsync(299) conversion is the same thing we did with fake timers in post 2. The schematic adds the beforeEach at the very top of the describe; for readability you may want to move it below the let declarations.
Clean Report, Red Build
Although the schematic's report said zero TODOs, the converted code did not compile:
✘ [ERROR] TS2322: Type '{ getTotal: Mock<Procedure>; }' is not assignable to type 'MockedObject<CartService>'.
Type '{ getTotal: Mock<Procedure>; }' is missing the following properties from type 'CartService': items, add
The object that replaces createSpyObj carries only getTotal, but the variable's type is MockedObject<CartService>, which expects the whole CartService. Jasmine's SpyObj type was more lenient here. We fixed it by hand in two files, limiting the type to the method the mock actually carries:
let cartSpy: { getTotal: Mock<CartService['getTotal']> };
beforeEach(() => {
cartSpy = {
getTotal: vi.fn<CartService['getTotal']>().mockName('CartService.getTotal'),
};
TestBed.configureTestingModule({
providers: [{ provide: CartService, useValue: cartSpy }],
});
service = TestBed.inject(ShippingService);
});
We also updated the test that used HttpClientTestingModule by hand; it has been deprecated since Angular 18, and the replacement is providers: [provideHttpClient(), provideHttpClientTesting()]. This change has nothing to do with the test runner, so it is outside the schematic's scope. Finally we removed the Karma packages (karma, karma-chrome-launcher, karma-coverage, karma-jasmine, karma-jasmine-html-reporter, jasmine-core, @types/jasmine, and istanbul-lib-instrument, which Karma needs for coverage). The 6 files and 11 tests passed on Vitest in 1.0–1.1 seconds. For a suite of this size there is no time gain: the whole command took 2.1 seconds on Karma, including opening the browser, and 1.9–2.1 seconds on Vitest. The gain is elsewhere: no browser to install in CI, the vi API and Vitest's watch mode, and all the tools from posts 1–8.
Two Ways to Handle fakeAsync
fakeAsync and tick rely on Zone.js's patched version for Jasmine. When I ran the schematic without --fake-async, the fakeAsync tests stayed as they were, and both failed on Vitest:
Error: Expected to be running in 'ProxyZone', but it was not found.
There are two solutions. The first is to convert the tests to Vitest fake timers with --fake-async, as above. The second, described in the Angular documentation, is to add Zone.js's Vitest patch to the test polyfills:
"testing": {
"aot": false,
"optimization": false,
"extractLicenses": false,
"polyfills": ["zone.js", "zone.js/testing", "zone.js/plugins/vitest-patch"]
}
With this line the fakeAsync tests passed unchanged. The Angular team recommends native async and Vitest fake timers; once the app moves to zoneless, fakeAsync will not work anyway. In Bookstore we would use the patch only during the transition, to get a suite with many fakeAsync tests green first.
Gradual Migration: Staying on Karma and Running Side by Side
A large suite does not have to move in one go. The unit-test builder still supports Karma: with runner: "karma" the same builder runs the tests with Karma, and ng new --test-runner=karma produces exactly that. During the transition you can run the files that have not moved yet with Karma in a separate target and the migrated ones with Vitest. The setup I tried:
"test-karma": {
"builder": "@angular/build:unit-test",
"options": {
"runner": "karma",
"tsConfig": "tsconfig.spec.karma.json",
"buildTarget": ":build:testing",
"include": ["src/app/cart/cart.service.spec.ts"],
"browsers": ["ChromeHeadless"]
}
}
tsconfig.spec.karma.json uses the jasmine types and tsconfig.spec.json uses vitest/globals; the two type sets clash in the same tsconfig. ng run legacy-web:test-karma ran the two Jasmine tests on Karma, and ng test ran the migrated ones on Vitest. As you migrate file by file and shrink the include list, the Karma target empties out; once it is empty, delete the target and the Karma packages. Keep this period short: two runners, two type sets and two different matcher behaviors confuse the team.
What Changed in Angular Testing from 12 to 22
When you open an old test file, knowing which line became outdated in which version makes it easier to decide what to change. We verified the versions against Angular's changelog and blog posts:
| Version | Change | How it looks in an old test |
|---|---|---|
| 12 | Protractor no longer added to new projects and deprecated; End of Life in September 2023 according to the Angular blog | e2e/protractor.conf.js, browser.get, element(by.css(...)) |
| 15.2 | RouterTestingHarness | RouterTestingModule and router.navigate + fixture.detectChanges |
| 18 | HttpClientTestingModule deprecated, replaced by provideHttpClientTesting() | imports: [HttpClientTestingModule] |
| 19 | components are standalone by default | declarations: [XComponent] |
| 20 | TestBed.flushEffects() removed, replaced by TestBed.tick(); zoneless stable in 20.2 | flushEffects() calls |
| 21 | Vitest is the default test runner, zoneless is the default; a migration for RouterTestingModule | karma.conf.js, src/test.ts, fakeAsync |
| 22 | OnPush by default, TestBed.getLastFixture(), migrate-karma-to-vitest and refactor-jasmine-vitest --fake-async | state written through componentInstance |
Moving end-to-end tests written with Protractor to Playwright is covered in the Integration and E2E Testing series post on reading old tests. The effect of the change in 19 showed up when I deleted the standalone: false line from an old component: three tests that used declarations failed with "is marked as standalone and can't be declared in any NgModule - did you intend to import it instead". A standalone component goes into imports in a test. The effect of zoneless and OnPush on old tests, and why lines like componentInstance.count = 2 no longer update the screen, we saw in post 3.
In Production
Run the build migration first and the code schematic second, in separate commits. The diffs stay small, and when something goes wrong it is clear which step caused it. Attach the schematic's report (jasmine-vitest-<date>.md) to the pull request, but do not take the "No Manual Changes Required" line as proof that the tests are correct.
Compare the number of tests before and after the migration. Karma ran 11 tests, and so did Vitest. If the number drops, a file has fallen outside include or tests are being skipped silently. The same goes for coverage: on Vitest, coverage comes with @vitest/coverage-v8 (post 1); do not expect exactly the same numbers as Karma's istanbul-based report, because the measurement methods differ.
Review asynchronous tests one by one. Tests using done, fakeAsync and waitForAsync are the riskiest part of the migration. Finding the tests that assert inside a callback with git grep "subscribe(" in spec files and moving them to firstValueFrom or lastValueFrom is an afternoon's work.
Common Pitfalls
Silently skipping tests that could not be converted. When the schematic cannot convert a test, it leaves a TODO comment; to get the suite green, the team may disable those tests with it.skip. Vitest shows this only in the summary line:
Test Files 6 passed (6)
Tests 10 passed | 1 skipped (11)
The "1 skipped" under a line that says "6 passed" is easy to miss. Link skipped tests to an issue and track the number of skipped tests in CI; for a test that still needs to be written, it.todo is a more honest marker.
Losing the check that done carried. That is the situation at the start of this post: the test passes even if the assertion inside the callback never runs. Bring the check back with firstValueFrom or expect.assertions(n).
Forgetting to install jsdom. The migration adds vitest but not the DOM environment; the error message is clear, but it is surprising the first time it shows up in CI.
Applying the migration and new APIs at the same time. If you change HttpClientTestingModule, declarations and fakeAsync while also switching the runner, finding the cause of a red test gets hard. First get the same tests green on Vitest, then modernize them.
A Small Experiment
★☆☆ Add another test to the BookService test in legacy-web, written so that it fails when the filter is added: what does firstValueFrom do for a search that returns an empty list?
★★☆ Make WishlistButtonComponent standalone, remove CatalogModule and update the tests to use imports. Then rewrite the test with Angular Testing Library as in post 4.
★★★ Move BookSearchComponent to signals and the debounced() from post 2, remove Zone.js and make the app zoneless. Which tests failed, and what replaced the fixture.detectChanges() lines?
This post ends the Frontend Testing series. In nine posts we tested Bookstore's Angular client first in jsdom at the pure function, service, component and routed feature levels, then with harnesses, in a real browser and end to end with Playwright; finally we moved an old suite to today's tools. From here, you can continue with the GitHub Actions series post "Angular CI with GitHub Actions" to run the tests in CI, and with the Integration and E2E Testing series for full-stack flows with a real backend.