46. Тестирование: Jest
🃏 Тестирование Angular: Jest
Заголовок раздела «🃏 Тестирование Angular: Jest»Jest — это альтернатива Karma/Jasmine от Facebook, которая работает без браузера (JSDOM). Он быстрее, удобнее для снепшот-тестов и лучше интегрируется с современными инструментами 🚀
Миграция с Karma на Jest
Заголовок раздела «Миграция с Karma на Jest»npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
# Устанавливаем Jestnpm install --save-dev jest jest-preset-angular @types/jest ts-jest
# Удаляем karma.conf.js и test.tsrm karma.conf.js src/test.tsjest.config.ts — конфигурация
Заголовок раздела «jest.config.ts — конфигурация»import type { Config } from 'jest';
const config: Config = { preset: 'jest-preset-angular',
setupFilesAfterFramework: ['<rootDir>/setup-jest.ts'],
testEnvironment: 'jsdom',
transform: { '^.+\\.(ts|js|html|svg)$': [ 'jest-preset-angular', { tsconfig: '<rootDir>/tsconfig.spec.json', stringifyContentPathRegex: '\\.(html|svg)$', }, ], },
moduleNameMapper: { // Алиасы путей из tsconfig '^@app/(.*)$': '<rootDir>/src/app/$1', '^@env/(.*)$': '<rootDir>/src/environments/$1', // Статические файлы '\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/src/__mocks__/fileMock.ts', },
collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.spec.ts', '!src/main.ts', '!src/environments/**', ],
coverageThresholds: { global: { branches: 70, functions: 80, lines: 80, statements: 80, }, },
testPathPattern: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$',};
export default config;import 'jest-preset-angular/setup-jest';import '@testing-library/jest-dom'; // Опционально: DOM matcherspackage.json скрипты
Заголовок раздела «package.json скрипты»{ "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:ci": "jest --ci --coverage --runInBand" }}Jest matchers vs Jasmine
Заголовок раздела «Jest matchers vs Jasmine»// Jasmineexpect(value).toBeTruthy();expect(spy).toHaveBeenCalledWith(arg);
// Jest — практически идентично!expect(value).toBeTruthy();expect(mockFn).toHaveBeenCalledWith(arg);
// Jest-exclusive matchers:expect(value).toMatchSnapshot(); // Снепшотexpect(array).toMatchObject([{id: 1}]); // Частичное совпадениеexpect(fn).toThrowErrorMatchingSnapshot(); // Снепшот ошибкиexpect(mockFn).toHaveBeenNthCalledWith(2, 'arg'); // N-й вызовjest.fn() и jest.spyOn()
Заголовок раздела «jest.fn() и jest.spyOn()»describe('UserService', () => { it('should use jest.fn() for mocks', () => { // Создаём мок-функцию const mockGetAll = jest.fn().mockReturnValue(of([{ id: '1', name: 'Яша' }]));
const service = new UserService({ get: mockGetAll } as any);
service.getAll().subscribe(users => { expect(users).toHaveLength(1); });
expect(mockGetAll).toHaveBeenCalledTimes(1); expect(mockGetAll).toHaveBeenCalledWith('/api/users'); });
it('should use jest.spyOn()', () => { const service = TestBed.inject(UserService); const spy = jest.spyOn(service, 'getAll').mockReturnValue(of([]));
component.ngOnInit();
expect(spy).toHaveBeenCalled(); });});Мокирование модулей
Заголовок раздела «Мокирование модулей»// Мокаем весь Angular Material Dialogjest.mock('@angular/material/dialog', () => ({ MatDialog: jest.fn().mockImplementation(() => ({ open: jest.fn().mockReturnValue({ afterClosed: jest.fn().mockReturnValue(of(true)) }) })), MatDialogModule: { ɵmod: jest.fn(), ɵinj: jest.fn() },}));
// Мокаем environmentjest.mock('../environments/environment', () => ({ environment: { production: false, apiUrl: 'http://test-api.com' }}));
// Мокаем localStorageconst localStorageMock = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn(),};Object.defineProperty(window, 'localStorage', { value: localStorageMock });Snapshot testing — снепшоты
Заголовок раздела «Snapshot testing — снепшоты»describe('UserCardComponent', () => { it('should match snapshot', () => { const fixture = TestBed.createComponent(UserCardComponent); fixture.componentInstance.user = mockUser; fixture.detectChanges();
expect(fixture.nativeElement).toMatchSnapshot(); // Создаёт/сравнивает __snapshots__/user-card.component.spec.ts.snap });
it('should match inline snapshot', () => { const pipe = new DateFormatPipe(); expect(pipe.transform(new Date('2024-01-15'), 'short')).toMatchInlineSnapshot( `"15 янв"` ); });});
// Обновить снепшоты:// jest --updateSnapshot или jest -uТестирование Observables с RxJS Testing
Заголовок раздела «Тестирование Observables с RxJS Testing»import { TestScheduler } from 'rxjs/testing';
describe('UserService debounceSearch', () => { let scheduler: TestScheduler;
beforeEach(() => { scheduler = new TestScheduler((actual, expected) => { expect(actual).toEqual(expected); }); });
it('should debounce search by 300ms', () => { scheduler.run(({ cold, hot, expectObservable }) => { // hot — уже "запущенный" Observable (simulates user typing) const search$ = hot('-a-b---c-------', { a: 'j', b: 'ja', c: 'yas' });
const result$ = search$.pipe(debounceTime(300, scheduler));
// a и b не прошли (следующий пришёл раньше 300ms) // c прошёл (300ms тишины) expectObservable(result$).toBe('-------c-------', { c: 'yas' }); }); });
it('should switchMap search requests', () => { scheduler.run(({ cold, hot, expectObservable }) => { const mockHttp = (query: string) => cold('--r', { r: [`Result for ${query}`] });
const source$ = hot('a-b---', { a: 'foo', b: 'bar' }); const result$ = source$.pipe(switchMap(q => mockHttp(q)));
// Первый запрос 'foo' отменяется, только 'bar' доходит expectObservable(result$).toBe('----r-', { r: ['Result for bar'] }); }); });});Тестирование NgRx с Jest
Заголовок раздела «Тестирование NgRx с Jest»import { provideMockStore, MockStore } from '@ngrx/store/testing';
describe('UsersPageComponent with NgRx', () => { let component: UsersPageComponent; let fixture: ComponentFixture<UsersPageComponent>; let store: MockStore;
const initialState = { users: { users: [], loading: false, error: null, } };
beforeEach(() => { TestBed.configureTestingModule({ imports: [UsersPageComponent], providers: [ provideMockStore({ initialState }) ] });
fixture = TestBed.createComponent(UsersPageComponent); store = TestBed.inject(MockStore); component = fixture.componentInstance; fixture.detectChanges(); });
it('should dispatch loadUsers on init', () => { const dispatchSpy = jest.spyOn(store, 'dispatch'); component.ngOnInit(); expect(dispatchSpy).toHaveBeenCalledWith(loadUsers()); });
it('should show spinner when loading', () => { store.setState({ users: { ...initialState.users, loading: true } }); fixture.detectChanges();
const spinner = fixture.debugElement.query(By.css('mat-spinner')); expect(spinner).toBeTruthy(); });});