refactor: migrate test suite from Jest to Jasmine and add zone.js polyfills

This commit is contained in:
Bruno Charest 2025-10-05 12:24:56 -04:00
parent 6c4febe205
commit 600238de44
7 changed files with 128 additions and 39 deletions

View File

@ -58,6 +58,10 @@
"options": { "options": {
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.cjs", "karmaConfig": "karma.conf.cjs",
"polyfills": [
"zone.js",
"zone.js/testing"
],
"include": [ "include": [
"src/**/*.spec.ts" "src/**/*.spec.ts"
], ],

10
package-lock.json generated
View File

@ -62,7 +62,8 @@
"postcss": "^8.4.49", "postcss": "^8.4.49",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0",
"zone.js": "^0.15.1"
} }
}, },
"node_modules/@algolia/abtesting": { "node_modules/@algolia/abtesting": {
@ -18132,6 +18133,13 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.24.1" "zod": "^3.24.1"
} }
},
"node_modules/zone.js": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
"devOptional": true,
"license": "MIT"
} }
} }
} }

View File

@ -66,7 +66,8 @@
"postcss": "^8.4.49", "postcss": "^8.4.49",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0",
"zone.js": "^0.15.1"
}, },
"resolutions": { "resolutions": {
"@angular/core": "20.3.2", "@angular/core": "20.3.2",

View File

@ -17,24 +17,44 @@ describe('sendBatch', () => {
data: {}, data: {},
}; };
let originalFetch: typeof fetch | undefined;
let fetchSpy: (jasmine.Spy & typeof fetch) | undefined;
beforeAll(() => {
originalFetch = globalThis.fetch;
});
beforeEach(() => { beforeEach(() => {
// Mock fetch fetchSpy = jasmine.createSpy('fetch') as jasmine.Spy & typeof fetch;
global.fetch = jest.fn(); (globalThis as { fetch: typeof fetch }).fetch = fetchSpy;
}); });
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); fetchSpy = undefined;
if (originalFetch) {
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
} else {
delete (globalThis as { fetch?: typeof fetch }).fetch;
}
});
afterAll(() => {
if (originalFetch) {
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
}
}); });
it('should send single record', async () => { it('should send single record', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ fetchSpy!.and.returnValue(
ok: true, Promise.resolve({
status: 200, ok: true,
}); status: 200,
} as Response)
);
await sendBatch([mockRecord], '/api/log'); await sendBatch([mockRecord], '/api/log');
expect(global.fetch).toHaveBeenCalledWith('/api/log', { expect(fetchSpy).toHaveBeenCalledWith('/api/log', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -44,15 +64,17 @@ describe('sendBatch', () => {
}); });
it('should send multiple records as array', async () => { it('should send multiple records as array', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ fetchSpy.and.returnValue(
ok: true, Promise.resolve({
status: 200, ok: true,
}); status: 200,
} as Response)
);
const records = [mockRecord, { ...mockRecord, event: 'NAVIGATE' as const }]; const records = [mockRecord, { ...mockRecord, event: 'NAVIGATE' as const }];
await sendBatch(records, '/api/log'); await sendBatch(records, '/api/log');
expect(global.fetch).toHaveBeenCalledWith('/api/log', { expect(fetchSpy).toHaveBeenCalledWith('/api/log', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -62,26 +84,38 @@ describe('sendBatch', () => {
}); });
it('should throw on HTTP error', async () => { it('should throw on HTTP error', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ fetchSpy!.and.returnValue(
ok: false, Promise.resolve({
status: 500, ok: false,
statusText: 'Internal Server Error', status: 500,
}); statusText: 'Internal Server Error',
} as Response)
await expect(sendBatch([mockRecord], '/api/log')).rejects.toThrow(
'HTTP 500: Internal Server Error'
); );
try {
await sendBatch([mockRecord], '/api/log');
fail('Expected sendBatch to throw on HTTP error');
} catch (error) {
expect(error).toEqual(jasmine.any(Error));
expect((error as Error).message).toBe('HTTP 500: Internal Server Error');
}
}); });
it('should throw on network error', async () => { it('should throw on network error', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); fetchSpy!.and.returnValue(Promise.reject(new Error('Network error')));
await expect(sendBatch([mockRecord], '/api/log')).rejects.toThrow('Network error'); try {
await sendBatch([mockRecord], '/api/log');
fail('Expected sendBatch to throw on network error');
} catch (error) {
expect(error).toEqual(jasmine.any(Error));
expect((error as Error).message).toBe('Network error');
}
}); });
it('should not send empty batch', async () => { it('should not send empty batch', async () => {
await sendBatch([], '/api/log'); await sendBatch([], '/api/log');
expect(global.fetch).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
}); });
}); });

View File

@ -1,17 +1,45 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { PLATFORM_ID } from '@angular/core';
import { LogService } from './log.service'; import { LogService } from './log.service';
import { environment } from './environment'; import { environment } from './environment';
describe('LogService', () => { describe('LogService', () => {
let service: LogService; let service: LogService;
let originalOnLine: boolean | undefined;
let originalRequestIdle: (typeof window)[keyof typeof window] | undefined;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({}); // Clear storages BEFORE creating the service instance
service = TestBed.inject(LogService);
// Clear localStorage and sessionStorage
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
// Make sure env won't trigger immediate flushes during assertions
environment.logging.enabled = true;
environment.logging.batchSize = 9999; // avoid immediate batch flush
environment.logging.debounceMs = 60 * 60 * 1000; // 1h to avoid timer firing
// Stub navigator.onLine and requestIdleCallback for deterministic behavior
originalOnLine = navigator.onLine;
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
originalRequestIdle = (window as any).requestIdleCallback;
(window as any).requestIdleCallback = (cb: Function) => cb();
TestBed.configureTestingModule({
providers: [{ provide: PLATFORM_ID, useValue: 'browser' }],
});
service = TestBed.inject(LogService);
});
afterEach(() => {
// Restore stubs
if (originalOnLine !== undefined) {
Object.defineProperty(navigator, 'onLine', { value: originalOnLine, configurable: true });
}
if (originalRequestIdle) {
(window as any).requestIdleCallback = originalRequestIdle;
} else {
delete (window as any).requestIdleCallback;
}
}); });
it('should be created', () => { it('should be created', () => {
@ -61,12 +89,13 @@ describe('LogService', () => {
it('should not log when disabled', () => { it('should not log when disabled', () => {
const originalEnabled = environment.logging.enabled; const originalEnabled = environment.logging.enabled;
environment.logging.enabled = false;
service.log('APP_START'); service.log('APP_START');
const queue = (service as any).queue; const queue = (service as any).queue;
expect(queue.length).toBe(0); expect(queue.length).toBe(0);
environment.logging.enabled = originalEnabled; environment.logging.enabled = originalEnabled;
}); });
@ -95,10 +124,9 @@ describe('LogService', () => {
]; ];
localStorage.setItem('obsiviewer.log.queue', JSON.stringify(mockQueue)); localStorage.setItem('obsiviewer.log.queue', JSON.stringify(mockQueue));
// Trigger loading using the existing instance
// Create new service instance (service as any).loadQueueFromStorage();
const newService = TestBed.inject(LogService); const queue = (service as any).queue;
const queue = (newService as any).queue;
expect(queue.length).toBe(1); expect(queue.length).toBe(1);
expect(queue[0].event).toBe('APP_START'); expect(queue[0].event).toBe('APP_START');

13
src/test.ts Normal file
View File

@ -0,0 +1,13 @@
import 'zone.js';
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';
// Initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
);

View File

@ -38,8 +38,9 @@
"./src/**/*.d.ts" "./src/**/*.d.ts"
], ],
"exclude": [ "exclude": [
"node_modules",
"node_modules" "./src/**/*.spec.ts",
"./src/**/*.spec.tsx"
], ],
"angularCompilerOptions": { "angularCompilerOptions": {
"disableTypeScriptVersionCheck": true "disableTypeScriptVersionCheck": true