Модуль 4 · урок 11 з 13
Мок-шар за контрактом
Це серце четвертої цілі: «mock incoming data strictly at the Angular service layer adhering to Swagger REST contracts». Тобто дані вигадані, але межа справжня — коли зʼявиться бекенд, ти заміниш нутрощі одного сервісу, і застосунок цього не помітить.
Крок 1: описати контракт
Спочатку — не код, а домовленість. Випиши ендпоїнти так, ніби їх уже реалізує бекенд. Це може бути просто файл у репозиторії:
GET /api/tasks?page=&size=&sort=&q=&status= → { items: TaskDto[]; total: number }
GET /api/tasks/{id} → TaskDto
POST /api/tasks → TaskDto (409, якщо code зайнятий)
PATCH /api/tasks/{id} → TaskDto
DELETE /api/tasks/{id} → 204
GET /api/tasks/code-taken?code= → { taken: boolean }
GET /api/users → UserDto[]
Саме цей список ти покажеш ліду й спитаєш: «чи так у вас виглядають контракти?» Питання добре тим, що воно про роботу, а не про навчання.
Крок 2: інтерфейс сервісу
export interface TaskApi {
list(params: TaskQuery): Observable<Page<Task>>;
byId(id: string): Observable<Task>;
create(payload: NewTask): Observable<Task>;
patch(id: string, patch: Partial<Task>): Observable<Task>;
remove(id: string): Observable<void>;
isCodeTaken(code: string): Observable<boolean>;
}
export interface Page<T> {
items: readonly T[];
total: number;
}
Тут одразу працюють generics і utility types із модуля 2. Реалізацій буде дві — мок і справжня, — а решта застосунку знає лише інтерфейс.
Крок 3: генератор даних
const CODES = ['KYIV', 'NOVA', 'DNPR', 'LVIV'];
const STATUSES: TaskStatus[] = ['todo', 'in_progress', 'blocked', 'done'];
export function makeTasks(count = 800): Task[] {
return Array.from({ length: count }, (_, i) => ({
id: `t-${i + 1}`,
code: `${CODES[i % CODES.length]}-${100 + i}`,
title: `Задача №${i + 1}`,
status: STATUSES[i % STATUSES.length],
assigneeId: i % 5 === 0 ? null : `u-${(i % 18) + 1}`,
estimateHours: 2 + (i % 12),
dueDate: new Date(Date.now() + (i % 30 - 10) * 86_400_000).toISOString(),
}));
}
На двадцяти рядках усе працює швидко, і жодна проблема з модуля 3 не видно.
Кількасот рядків — це поріг, за яким відчувається різниця між OnPush і без нього,
між track і без нього, і за яким пагінація перестає бути формальністю.
Мок має бути схожим на реальні дані обсягом, інакше він нічого не перевіряє.
Крок 4: мок-реалізація
@Injectable({ providedIn: 'root' })
export class TaskApiMock implements TaskApi {
private tasks: Task[] = makeTasks();
private failEvery = 7; // кожен сьомий запит навмисно падає
private calls = 0;
list(params: TaskQuery): Observable<Page<Task>> {
return this.simulate(() => {
let rows = this.tasks;
if (params.status && params.status !== 'all') {
rows = rows.filter(t => t.status === params.status);
}
if (params.q) {
const q = params.q.toLowerCase();
rows = rows.filter(t => t.title.toLowerCase().includes(q) || t.code.toLowerCase().includes(q));
}
if (params.sort) {
rows = [...rows].sort(compareBy(params.sort));
}
const start = (params.page - 1) * params.size;
return { items: rows.slice(start, start + params.size), total: rows.length };
});
}
create(payload: NewTask): Observable<Task> {
if (this.tasks.some(t => t.code === payload.code)) {
return throwError(() => new HttpErrorResponse({ status: 409, statusText: 'Conflict' }));
}
const task: Task = { ...payload, id: `t-${Date.now()}` };
this.tasks = [task, ...this.tasks];
return this.simulate(() => task);
}
isCodeTaken(code: string): Observable<boolean> {
return this.simulate(() => this.tasks.some(t => t.code === code), 250);
}
// одна обгортка на всі методи: затримка й випадкові збої
private simulate<T>(fn: () => T, ms = 300 + Math.random() * 500): Observable<T> {
this.calls++;
if (this.calls % this.failEvery === 0) {
return timer(ms).pipe(
switchMap(() => throwError(() => new HttpErrorResponse({ status: 500 }))),
);
}
return of(fn()).pipe(delay(ms));
}
}
Три речі, які роблять цей мок корисним, а не декоративним:
- Затримка. Без неї не побачити скелетонів і не перевірити, що кнопка блокується під час збереження.
- Керовані збої. Кожен сьомий виклик падає — і ти змушений написати обробку помилок по-справжньому, а не «колись потім».
- Стан живе між викликами. Створив задачу — вона зʼявляється у списку. Інакше CRUD із модуля 6 буде неможливо перевірити.
Крок 5: підміна одним рядком
// app.config.ts
providers: [
{ provide: TASK_API, useClass: TaskApiMock }, // зараз
// { provide: TASK_API, useClass: TaskApiHttp }, // коли зʼявиться бекенд
]
// core/api/task-api.token.ts
export const TASK_API = new InjectionToken<TaskApi>('TaskApi');
// у сторі
private readonly api = inject(TASK_API);
У модулі 8 тести стору отримають третю реалізацію — з передбачуваними даними й без затримок.
Оскільки залежність від токена, підміна робиться одним рядком у TestBed.
Тобто зусилля, вкладені зараз, повернуться двічі.