QA Interview Hub · База знань · Тести

TypeScript

Generics

Навіщо generics: типізований API-клієнт

Generic - це параметр типу. Функція або клас працюють з різними типами, але зберігають зв'язок між входом і виходом. Без generics доводиться обирати між дублюванням коду і any.

class ApiClient {
  constructor(private readonly baseUrl: string) {}

  async get<T>(path: string): Promise<T> {
    const res = await fetch(`${this.baseUrl}${path}`);
    if (!res.ok) throw new Error(`GET ${path} -> ${res.status}`);
    return (await res.json()) as T;
  }
}

const account = await client.get<Account>('/api/v1/accounts/42');
account.balance; // number, є автодоповнення

Тут важливо бути чесним: as T - це твердження, а не перевірка. Якщо сервер повернув інший об'єкт, тип збреше. Тому в зрілому фреймворку generic приймає схему, а тип виводиться з неї:

import { z } from 'zod';

async function getParsed<S extends z.ZodTypeAny>(path: string, schema: S): Promise<z.infer<S>> {
  const res = await fetch(path);
  return schema.parse(await res.json());
}

Тепер T не передається вручну, а виводиться з аргументу. Це загальне правило: найкращі generics ті, які TS виводить сам із параметрів, а не ті, які треба вказувати в кутових дужках.

Інші типові місця generics у тестах: ApiResult<T>, пагінована відповідь Page<T>, хелпер retry<T>(fn: () => Promise<T>): Promise<T>, фабрика тестових даних build<T>(defaults: T, overrides?: Partial<T>): T.

На співбесіді: Generics дозволяють написати один метод get<T>, який повертає правильний тип для будь-якого ендпоінта. Але as T нічого не перевіряє, тому я передаю в клієнт схему, а тип виводиться з неї - так тип і реальні дані не розходяться.

Обмеження, keyof і значення за замовчуванням

Обмеження extends каже, що параметр типу має відповідати певній формі. Без нього всередині функції про T нічого не відомо.

interface HasId { id: string }

function findById<T extends HasId>(items: readonly T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}

Комбінація з keyof дає типобезпечний доступ до полів. Класичний приклад - хелпер, який дістає колонку з результатів:

function pluck<T, K extends keyof T>(rows: readonly T[], key: K): T[K][] {
  return rows.map(r => r[key]);
}

const amounts = pluck(transactions, 'amount'); // number[]
// pluck(transactions, 'amout'); // помилка: опечатка в назві поля

T[K] - indexed access type, тип поля K в об'єкті T. Повернений тип точно відповідає вибраному полю.

Параметр типу може мати значення за замовчуванням:

interface Page<T, Meta = { total: number }> {
  items: T[];
  meta: Meta;
}
type AccountsPage = Page<Account>;

Типові помилки з generics:

  • Параметр типу використовується лише один раз. function log<T>(x: T): void нічим не краща за x: unknown. Generic має сенс, коли пов'язує кілька місць.
  • Ручне вказання типу там, де його можна вивести.
  • Надто складні generics у тестовому коді. Тест повинен читатися швидко, складна типізація - в бібліотечному шарі фреймворку.
На співбесіді: Через extends я обмежую параметр типу, наприклад T extends HasId, а K extends keyof T дає безпечний доступ до полів без опечаток. Якщо параметр типу з'являється тільки один раз, generic не потрібен.

Generics у Playwright: фікстури

У Playwright generics найчастіше зустрічаються у фікстурах. test.extend<T>() приймає тип, який описує нові фікстури, і після цього вони типізовані в кожному тесті.

import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { ApiClient } from './api/api-client';

type Fixtures = {
  loginPage: LoginPage;
  api: ApiClient;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  api: async ({ request }, use) => {
    await use(new ApiClient(request));
  },
});

test('user sees balance', async ({ loginPage, api }) => {
  // loginPage: LoginPage, api: ApiClient
});

Для worker-scoped фікстур є другий параметр: base.extend<TestFixtures, WorkerFixtures>(). Наприклад, тестовий акаунт, який створюється один раз на воркер.

Ще одне місце - типізація опцій проєкту. Фікстуру можна оголосити як опцію через { option: true } і задавати її значення в playwright.config.ts для різних проєктів, наприклад різні валюти або ринки.

Generics також корисні в хелперах очікування:

async function pollUntil<T>(fn: () => Promise<T>, done: (v: T) => boolean, timeoutMs = 10_000): Promise<T> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const value = await fn();
    if (done(value)) return value;
    await new Promise(r => setTimeout(r, 500));
  }
  throw new Error('pollUntil timeout');
}

Хоча в Playwright для цього вже є expect.poll, і я використовую його в першу чергу.

На співбесіді: У Playwright я типізую фікстури через test.extend<Fixtures>(), і тоді Page Object та API-клієнт мають тип у кожному тесті. Для фікстур рівня воркера є другий параметр типу.