javascript

Пагинация. Ошибки тестирования списков и курсоров

  • суббота, 22 августа 2026 г. в 00:00:02
https://habr.com/ru/companies/otus/articles/1070840/

У списка операций были тесты и на API, и на интерфейс. API возвращал не больше 20 элементов и nextCursor, а браузер показывал первые 20 строк. Всё проходило.

В продакшене пользователь прокрутил список, переключил фильтр с «Все» на «Ошибочные» и увидел странную смесь. Часть старых операций осталась, одна строка повторилась, а несколько ошибочных операций не появились. Во вкладке Network были такие запросы:

GET /operations?status=ALL&limit=20&cursor=all-2
GET /operations?status=ALL&limit=20&cursor=all-2
GET /operations?status=FAILED&limit=20&cursor=all-2

Первые два запроса ушли с одним курсором. В третьем сохранился курсор от другого фильтра. Позже пришёл медленный ответ на один из старых запросов, и фронт добавил его элементы в уже обновлённый список. Каждый отдельный ответ API при этом выглядел корректно.

Пагинация ломается не только внутри запроса к базе. Это цепочка: бэкенд определяет порядок и границу страницы, фронт хранит курсор и объединяет ответы, а пользователь меняет фильтры, повторяет запросы и возвращается на экран. Разберём шесть ситуаций, в которых локальные проверки зелёные, а список целиком работает неправильно.

Проверка одной страницы не проверяет пагинацию

Тест первой страницы подтверждает схему ответа, ограничение limit и начальную сортировку. Он не обнаружит повтор на стыке страниц, пропущенный элемент, зацикленный курсор или неверный признак конца списка.

Для API нужна хотя бы одна интеграционная проверка полного прохода по известному набору данных. Количество записей не должно быть кратно размеру страницы. Например, возьмите 47 записей при limit=10. Часть записей стоит создать с одинаковым временем, чтобы такая группа пересекала границу страниц.

async function loadAllOperations(params) {
  const items = [];
  const seenIds = new Set();
  let cursor = null;

  for (let requestNumber = 0; requestNumber < 100; requestNumber++) {
    const response = await api.get("/operations", {
      ...params,
      cursor
    });

    expect(response.status).toBe(200);
    expect(response.body.items.length).toBeLessThanOrEqual(params.limit);
    expectSorted(response.body.items, ["createdAt DESC", "id DESC"]);

    for (const item of response.body.items) {
      expect(seenIds.has(item.id)).toBe(false);
      seenIds.add(item.id);
      items.push(item);
    }

    const nextCursor = response.body.nextCursor || null;
    if (nextCursor === null) return items;

    expect(nextCursor).not.toBe(cursor);
    cursor = nextCursor;
  }

  throw new Error("Pagination did not finish");
}

На неизменяемом тестовом наборе результат такого прохода можно сравнить со списком id, полученным напрямую из базы с той же сортировкой. Отдельно полезны границы 0, 1, limit - 1, limit, limit + 1 и 2 * limit: на них обычно появляются лишняя пустая страница или преждевременное завершение.

Через UI доказывать полноту всего набора неудобно. Браузерный тест будет медленным, а при падении не покажет, кто потерял запись — API или код объединения страниц. Целостность обхода лучше проверять на уровне API, а во фронтовых тестах сосредоточиться на управлении запросами и состоянием списка.

У страницы должна быть однозначная граница

Сортировка ORDER BY created_at DESC выглядит стабильной, пока у записей разное время. В реальной системе несколько операций легко получают одинаковый created_at. Для них база не обязана сохранять один и тот же взаимный порядок между двумя запросами.

Документация PostgreSQL отдельно предупреждает: при использовании LIMIT и OFFSET сортировка должна задавать однозначный порядок, иначе разные запросы могут вернуть несогласованные части результата. Подробности есть в разделе про LIMIT и OFFSET.

Обычно к бизнес‑сортировке добавляют уникальное поле:

ORDER BY created_at DESC, id DESC

Курсор для следующей страницы должен содержать обе части позиции — createdAt и id. Для пагинации по ключу запрос может выглядеть так:

SELECT id, created_at, amount, status
FROM operations
WHERE tenant_id = :tenantId
  AND (created_at, id) < (:cursorCreatedAt, :cursorId)
ORDER BY created_at DESC, id DESC
LIMIT :limitPlusOne;

Строгое <, а не <=, не даёт повторить граничную запись. Запрос limit + 1 позволяет понять, нужна ли следующая страница, без отдельного COUNT(*). В тестовых данных группа одинаковых createdAt должна начинаться на одной странице и заканчиваться на другой — иначе составной курсор фактически не проверяется.

Пагинация со смещением реагирует на изменения ещё заметнее. Если после первой страницы в начало списка добавилась новая запись, offset=20 уже указывает на другую границу и может повторить последний элемент первой страницы. При удалении записи перед границей один элемент можно пропустить.

Курсор уменьшает зависимость от сдвига позиций, но не создаёт снимок данных автоматически. Для живой ленты нормально, что новые элементы появляются между запросами. Для экспорта или сверки обычно нужен зафиксированный набор. Это поведение должно быть описано в контракте до того, как тест начнёт сравнивать точное содержимое всех страниц.

Фронт не должен отправлять два запроса с одним курсором

В бесконечной прокрутке следующую страницу часто запускает IntersectionObserver. Его обработчик может вызываться несколько раз и получать сразу несколько событий пересечения. Это нормальное поведение API браузера, а не редкий сбой. Оно описано в документации Intersection Observer.

Если обработчик проверяет только видимость нижнего элемента, два срабатывания успевают прочитать один и тот же nextCursor и отправить одинаковые запросы. Оба ответа успешны, после чего фронт дважды добавляет одну страницу.

Одной блокировки кнопки или индикатора загрузки недостаточно: флаг запроса нужно установить до запуска fetch. Удобно блокировать комбинацию из текущего набора параметров и курсора:

const requestsInProgress = new Set();

async function loadNextPage() {
  if (state.nextCursor === null) return;

  const cursor = state.nextCursor;
  const queryKey = state.queryKey;
  const query = state.query;
  const requestKey = JSON.stringify([queryKey, cursor ?? null]);

  if (requestsInProgress.has(requestKey)) return;
  requestsInProgress.add(requestKey);

  try {
    const page = await operationsApi.list({
      ...query,
      cursor
    });

    if (state.queryKey !== queryKey) return;

    state.items = [...state.items, ...page.items];
    state.nextCursor = page.nextCursor || null;
    return page;
  } finally {
    requestsInProgress.delete(requestKey);
  }
}

Поле queryKey здесь обозначает стабильный ключ текущей выборки: например, сериализованные фильтры и сортировку. Серверу необязательно возвращать его в ответе — клиент может сохранить ключ рядом с запросом и сравнить после получения результата.

Защитное удаление повторяющихся id на фронте допустимо, но не должно маскировать ошибку API. Если соседние страницы от сервера пересекаются, интеграционный тест должен упасть. На клиенте удаление дублей — последняя защита интерфейса, а не доказательство правильной пагинации.

При смене фильтра старый ответ нельзя добавлять в новый список

Пользователь запросил вторую страницу всех операций, а затем переключился на status=FAILED. Новый запрос оказался быстрым, старый — медленным. Если обработчики просто добавляют элементы по мере получения ответов, страница старого списка попадёт в новый.

При изменении фильтра или сортировки нужно одновременно:

  • очистить элементы и ошибки старой выборки;

  • сбросить курсор на первую страницу;

  • отменить активный запрос, если это возможно;

  • игнорировать ответ, если он относится к предыдущей версии параметров.

Отмена через AbortController останавливает fetch и чтение ответа; это стандартный механизм браузера, описанный в MDN. Но одной отмены мало: ответ мог завершиться до вызова abort(), а его обработка — продолжиться. Поэтому дополнительно нужен номер версии или queryKey.

let queryVersion = 0;
let activeController = null;

async function resetAndLoad(query) {
  const version = ++queryVersion;
  const queryKey = makeQueryKey(query);

  activeController?.abort();
  activeController = new AbortController();

  state = {
    query,
    queryKey,
    items: [],
    nextCursor: undefined,
    error: null,
    appliedRequests: new Set()
  };

  try {
    const page = await operationsApi.list({
      ...query,
      cursor: null,
      signal: activeController.signal
    });

    if (version !== queryVersion) return;

    state.items = page.items;
    state.nextCursor = page.nextCursor || null;
  } catch (error) {
    if (error.name !== "AbortError" && version === queryVersion) {
      state.error = error;
    }
  }
}

Курсор нельзя переносить между разными фильтрами и сортировками ещё и потому, что он описывает позицию внутри конкретной выборки. Google AIP-158 рекомендует сохранять параметры исходного запроса при переходе между страницами и отвечать ошибкой, если они изменились. Там же зафиксировано важное правило: курсор должен быть непрозрачным для клиента. Фронт передаёт его без разбора и не строит из него собственную логику. См. AIP-158: Pagination.

Фронтовый тест должен специально поменять фильтр при незавершённом запросе и вернуть ответы в обратном порядке. Если всегда отвечать мгновенно, гонка останется непроверенной.

Короткая страница не всегда означает конец списка

Клиентская проверка items.length < limit часто используется как признак последней страницы. Это работает только тогда, когда такое поведение обещано конкретным API.

Источник данных может применить ограничение до части фильтров и вернуть короткую или даже пустую промежуточную страницу с курсором продолжения. Например, DynamoDB сначала читает ограниченный набор, а затем применяет FilterExpression. Поэтому ответ может не содержать подходящих элементов, но иметь LastEvaluatedKey. Обход продолжается, пока ключ не исчезнет. Это описано в документации DynamoDB.

AIP-158 также допускает страницу с меньшим количеством элементов, включая ноль, до конца коллекции. Признаком конца считается отсутствие следующего токена, а не длина массива.

Для фронта пустая промежуточная страница создаёт дополнительную проблему: список не увеличился, нижний элемент не сдвинулся, и IntersectionObserver может не дать нового полезного срабатывания. Клиентскому слою приходится запросить продолжение самостоятельно:

async function requestUntilItemsOrEnd(query, cursor) {
  for (let attempt = 0; attempt < 20; attempt++) {
    const page = await operationsApi.list({
      ...query,
      cursor
    });
    const nextCursor = page.nextCursor || null;

    if (page.items.length > 0 || nextCursor === null) {
      return { ...page, nextCursor };
    }

    if (nextCursor === cursor) {
      throw new Error("Cursor did not advance");
    }
    cursor = nextCursor;
  }

  throw new Error("Too many empty pages with continuation cursors");
}

Ограничение числа переходов и проверка, что курсор меняется, защищают клиент от бесконечного цикла при дефекте сервера. В тесте достаточно смоделировать три ответа: обычную страницу, пустую страницу с новым курсором и страницу с элементами. Данные из третьего ответа должны появиться в интерфейсе без дополнительного действия пользователя.

Повтор после ошибки не должен повторять элементы

Запрос следующей страницы вернул 503. Фронт показал кнопку «Повторить», но успел записать новый курсор до успешного добавления элементов. После повтора он запросил уже следующую страницу и потерял данные. Возможен и обратный вариант: элементы добавились, состояние запроса не обновилось, и повтор добавил их второй раз.

Применение страницы должно быть одной операцией состояния: элементы и следующий курсор обновляются вместе только после успешного ответа. До этого текущий курсор остаётся доступным для повтора. Для дополнительной защиты можно запоминать ключ уже применённого запроса:

function applyPage(state, action) {
  if (action.queryKey !== state.queryKey) return state;
  if (state.appliedRequests.has(action.requestKey)) return state;

  return {
    ...state,
    items: [...state.items, ...action.items],
    nextCursor: action.nextCursor || null,
    appliedRequests: new Set([
      ...state.appliedRequests,
      action.requestKey
    ]),
    error: null
  };
}

В компонентном тесте первая попытка должна завершиться ошибкой, а вторая — успехом для того же курсора. После этого проверяем, что первая страница сохранилась, вторая добавилась один раз, курсор продвинулся и сообщение об ошибке исчезло.

Что проверять на разных уровнях

Пытаться доказать всё одним сквозным тестом невыгодно. Уровень проверки лучше выбирать по тому состоянию, которое нужно контролировать.

Уровень

Что проверять

Интеграционный тест API

Полный проход, однозначный порядок, отсутствие дублей и пропусков, одинаковые значения сортировки, правила работы курсора

Компонентный тест фронта

Два сигнала загрузки, смена фильтра, поздний ответ, пустая промежуточная страница, ошибка и повтор

Сквозной тест в браузере (E2E)

Один‑два критичных пользовательских сценария: запрос следующей страницы, изменение фильтра во время загрузки и итоговое содержимое DOM

Playwright позволяет перехватить fetch и XHR, управлять порядком ответов и проверить одновременно Network и DOM. Актуальные способы подмены ответов описаны в документации Playwright.

Понимание того, что и на каком уровне стоит тестировать, — отдельный навык. Проверить себя и увидеть темы, которые стоит подтянуть, можно во вступительном тесте по продвинутой автоматизации тестирования на Java.

Ниже пример сценария, в котором старая страница намеренно отвечает после смены фильтра. Объекты allOperationsPage1, allOperationsPage2 и failedOperationsPage1 — заранее подготовленные ответы API:

test("old page is not appended after filter change", async ({ page }) => {
  let markOldRequestStarted;
  let releaseOldResponse;
  let markOldResponseSent;

  const oldRequestStarted = new Promise(resolve => {
    markOldRequestStarted = resolve;
  });
  const oldResponseAllowed = new Promise(resolve => {
    releaseOldResponse = resolve;
  });
  const oldResponseSent = new Promise(resolve => {
    markOldResponseSent = resolve;
  });

  let oldPageRequests = 0;

  await page.route("**/api/operations?*", async route => {
    const url = new URL(route.request().url());
    const status = url.searchParams.get("status");
    const cursor = url.searchParams.get("cursor");

    if (status === "ALL" && cursor === "all-2") {
      oldPageRequests++;
      markOldRequestStarted();
      await oldResponseAllowed;
      await route.fulfill({ json: allOperationsPage2 });
      markOldResponseSent();
      return;
    }

    await route.fulfill({
      json: status === "FAILED"
        ? failedOperationsPage1
        : allOperationsPage1
    });
  });

  await page.goto("/operations");
  await expect(page.getByTestId("operation-row")).toHaveCount(20);

  await page.getByTestId("list-end").scrollIntoViewIfNeeded();
  await oldRequestStarted;

  await page.getByRole("combobox", { name: "Статус" })
    .selectOption("FAILED");

  const rows = page.getByTestId("operation-row");
  await expect(rows).toHaveText(["FAILED #1", "FAILED #2"]);

  releaseOldResponse();
  await oldResponseSent;

  await expect(rows).toHaveText(["FAILED #1", "FAILED #2"]);
  expect(oldPageRequests).toBe(1);
});

Этот тест не доказывает правильность SQL или полноту всех страниц. Он проверяет свой риск: фронт отправляет один запрос для курсора и не применяет устаревший ответ после смены фильтра.

Что проверить перед выпуском списка с пагинацией

  • Есть ли API‑тест, который проходит несколько страниц до отсутствия курсора?

  • Задаёт ли сортировка однозначный порядок, включая записи с одинаковым временем?

  • Не отправляет ли фронт несколько запросов с одной комбинацией фильтров и курсора?

  • Сбрасываются ли список, курсор и ошибка при изменении фильтра или сортировки?

  • Игнорируется ли ответ от предыдущей версии параметров?

  • Продолжается ли загрузка после короткой или пустой страницы с курсором?

  • Повторяется ли после ошибки тот же запрос без потери и дублирования элементов?

  • Есть ли ограничение от зацикленного или неизменяющегося курсора?

Пагинацию нельзя полноценно проверить только с фронта или только через API. API‑тест отвечает на вопрос, можно ли пройти набор без случайных пропусков и повторов. Фронтовый тест проверяет, не испортил ли клиент корректные страницы параллельными запросами, устаревшими ответами и неверным восстановлением после ошибки. А сквозной тест нужен на их стыке — там, где отдельные зелёные проверки чаще всего перестают описывать реальное поведение пользователя.

Когда пагинация ломается только в продакшене, проблема редко сводится к одному неудачному запросу. Нужно уметь проверять всю цепочку: API, состояние интерфейса, гонки запросов и поведение пользователя. Тогда тесты перестают просто подтверждать, что «ответ пришёл», и начинают находить сценарии, которые действительно ломают продукт.

Если хотите глубже разобраться в таких проверках и научиться воспроизводить их в автотестах, приходите на открытые уроки:

  • 3 сентября, 20:00. «UI и API тестирование с Java и Playwright». Записаться

  • 22 сентября, 20:00. «Playwright JS: как быстро начать писать автотесты?». Записаться

Весь список бесплатных уроков августа можно найти в дайджесте.