Перейти к содержимому

46. Тестирование: Jest

Jest — это альтернатива Karma/Jasmine от Facebook, которая работает без браузера (JSDOM). Он быстрее, удобнее для снепшот-тестов и лучше интегрируется с современными инструментами 🚀


Окно терминала
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
# Устанавливаем Jest
npm install --save-dev jest jest-preset-angular @types/jest ts-jest
# Удаляем karma.conf.js и test.ts
rm karma.conf.js src/test.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;
setup-jest.ts
import 'jest-preset-angular/setup-jest';
import '@testing-library/jest-dom'; // Опционально: DOM matchers

{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --runInBand"
}
}

// Jasmine
expect(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-й вызов

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 Dialog
jest.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() },
}));
// Мокаем environment
jest.mock('../environments/environment', () => ({
environment: {
production: false,
apiUrl: 'http://test-api.com'
}
}));
// Мокаем localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, 'localStorage', { value: localStorageMock });

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

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'] });
});
});
});

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();
});
});