REST годами приучал индустрию думать тестами по путям: у каждого эндпоинта свой контракт, свой тест, своя полочка. GraphQL эту мебель сломал: один POST эндпоинт, строгая типизация и бесконечное множество допустимых запросов, которые клиент волен собирать сам. Проверять по путям стало нечего, а проверять по сути стало больше: типы схемы, поведение резолверов, формат ошибок, глубину вложенности и производительность одного и того же эндпоинта под разными запросами.
Хороший набор тестов для GraphQL складывается слоями, как матрёшка: валидация схемы, юнит-тесты резолверов, исполнение запросов целиком без сети, сам HTTP слой и поведение под нагрузкой. Инструменты зрелые: Apollo Server несёт стенд в комплекте, graphql-inspector следит за схемой, supertest за сетью, k6 за числами. Дальше слои разобраны по очереди, с кодом и версиями, которые легко проверить в пакетных реестрах.
Слои тестирования GraphQL от типов схемы до HTTP эндпоинта и подписок
Первый слой это сама схема. SDL обязана компилироваться в исполняемую структуру без конфликтов типов, и это дешёвая проверка, которая отсеивает половину опечаток. Второй слой это резолверы, функции по одному на поле с сигнатурой parent, args, context и info, и любой из аргументов подменяется в тесте за секунду. Третий слой исполняет запросы целиком против собранного сервера без сети, ловя связки полей, которые поодиночке работают безупречно. Четвёртый слой это HTTP: JSON тело с полями query, variables и operationName, заголовки, статус-коды. Пятый это подписки, живой канал поверх WebSocket с протоколом graphql-ws. Шестой слой это производительность: свобода собирать глубокие запросы превращает один эндпоинт в подарок и в мишень.
Особенность одного эндпоинта: поверхность атаки не перечислишь списком, клиент может скомбинировать поля так, как автор схемы не задумывал, и валидация пропустит корректный, но разорительный по стоимости запрос. Отсюда двойная мораль тестов: они проверяют и то, что должно работать, и то, что не должно пройти.
Отдельная сила схемы это интроспекция: сервер способен рассказать о собственной структуре, и на этом держатся все инструменты статьи, от валидаторов до фаззеров. Поэтому тесты в GraphQL принято называть по операциям, а не по путям: GetUserById, FeedQuery, CreateOrder, и по именам легко найти провал в отчёте сборки.
Валидация схемы и контроль ломающих изменений в непрерывной интеграции
Схема это публичный договор с клиентами, и ломать его без предупреждения нельзя. Инструмент graphql-inspector, свежие версии седьмой линейки, сравнивает две версии SDL командой diff и честно перечисляет удалённые поля, изменённые типы и потерянную обязательность. В CI такую проверку вешают на каждый pull request, и ломающее изменение перестаёт доезжать до прода молча. Apollo Rover умеет сверять схему ветки с зарегистрированной версией графа и блокировать слияние при расхождении, а его команда check считает затронутые операции процентом, а не строками, поэтому спор о серьёзности правки опирается на числа. Правило миграций звучит так: расширять можно свободно, урезать только по плану с пометками устаревания и этапом на переезд клиентов. И отдельная радость для ревизора: команда coverage подсвечивает поля, которые ни один тест не исполнял, это готовый список дыр в покрытии.
Схема-first подход даёт ещё один рычаг: SDL хранится в репозитории рядом с кодом, его дифф читается в ревью как обычный код, а снапшот схемы в CI ловит любое расхождение между объявленным и собранным. Когда команды договорились о контракте раньше кода, обе стороны пишут против одного файла, и споры заканчиваются до первой строки бэкенда.
Юнит-тесты резолверов с моками источников и прямым вызовом graphql
Резолвер в идеале это чистая функция: входы предсказуемы, выходы проверяемы. Зовут его с четырьмя аргументами, и мок контекста с источниками пишется быстрее, чем сам резолвер. Но проверять резолвер поодиночке скучно, продуктивнее исполнять целые запросы функцией graphql из graphql-js, подсовывая подложные источники через contextValue:
import { graphql } from "graphql";
import { schema } from "../src/schema.js";
test("user query resolves posts through loaders", async () => {
const result = await graphql({
schema,
source: `query { user(id: "7") { name posts { title } } }`,
contextValue: { loaders: fakeLoaders() }
});
expect(result.errors).toBeUndefined();
expect(result.data.user.posts).toHaveLength(2);
});
Такой тест не поднимает ни сети, ни сервера, исполняется за миллисекунды и первым ловит расхождения между схемой и резолверами. Скорость здесь не роскошь: быстрые тесты запускают на каждом сохранении, а не раз в сутки.
Один из самых недооценённых сюжетов это расплата за null. Если резолвер поля бросает ошибку, движок превращает поле в null, поднимает её наверх и кладёт в массив errors, а nullable типы в схеме решают, остановится ли запрос целиком. Тесты обязаны проверять связку: что происходит с соседними полями, когда падает одно, и не уносит ли одна ошибка всю страницу.
Чтобы моки не размножались вручную, заводят фабрику тестового контекста: одна функция собирает contextValue с подложными источниками, авторизованным пользователем и счётчиками вызовов, и все юнит-тесты тянут одинаковый набор. Это превращает подготовку теста в одну строку.
executeOperation в Apollo Server как испытательный стенд без сети
Apollo Server четвёртой версии сменил createTestClient на executeOperation, и в актуальной пятой ветке, включая свежие выпуски 5.5, это основной испытательный стенд. Метод прогоняет весь конвейер сервера, парсинг, валидацию, построение контекста, резолверы и форматирование ошибок, минуя сеть:
import { ApolloServer } from "@apollo/server";
import assert from "node:assert";
import { typeDefs, resolvers } from "../src/server.js";
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
test("hello query goes through the whole pipeline", async () => {
const response = await server.executeOperation({
query: `query Hello($name: String!) { hello(name: $name) }`,
variables: { name: "Maria" }
});
assert(response.body.kind === "single");
expect(response.body.singleResult.data.hello).toBe("Hello Maria");
});
Документация прямо рекомендует сужать тип ответа через assert, а ошибки парсинга и исполнения приезжают внутри поля errors результата. Подписки таким способом не проверяются, их гоняют настоящим клиентом graphql-ws против поднятого сервера, зато запросы и мутации покрываются стендом целиком.
Границы стенда стоит знать заранее: executeOperation не проходит через мидлвари веб-фреймворка, заголовки, куки и лимиты тела запроса, поэтому аутентификацию по заголовкам проверяют только на HTTP слое, а на серверных тестах авторизованного пользователя подкладывают прямо в контекст. Плагины телеметрии на стенде отключают, чтобы ночная сборка не возила мусор наружу.
HTTP эндпоинт под supertest и статус-коды ошибок по спецификации
Сетевой слой проверяют supertest: приложение поднимается целиком, и тест шлёт настоящий POST на /graphql с JSON телом. Запрос с неизвестным полем ловит HTTP 400 и массив errors в теле, ошибки исполнения приезжают со статусом 200 рядом с частичными значениями, и оба сценария заслуживают собственных тестов:
import request from "supertest";
import { createApp } from "../src/app.js";
test("unknown field fails fast with 400", async () => {
const res = await request(createApp())
.post("/graphql")
.send({ query: "{ unknownField }" });
expect(res.status).toBe(400);
expect(res.body.errors).toBeDefined();
});
Сюда же относится гигиена ошибок: прод не должен светить стеки наружу, за это отвечает настройка includeStacktraceInErrorResponses со значением false, а интроспекцию в бою принято закрывать и тестом проверять, что она действительно закрыта.
Формат ошибок стоит переводить в коды: расширение extensions с полем code, от GRAPHQL_VALIDATION_FAILED до BAD_USER_INPUT и INTERNAL_SERVER_ERROR, позволяет клиенту реагировать по смыслу, а не по тексту сообщения. Тесты закрепляют ожидаемые коды за сценариями, и после правки формулировок контракт с клиентами не ломается.
Отдельный класс проверок это переменные: неверный тип, отсутствующее обязательное значение, пустая строка вместо ID. Валидация ловит их до резолверов, и тесты закрепляют это: сценарий отправляет и правильный вариант, и заведомо битый.
Подписки проверяют настоящим клиентом graphql-ws: тест открывает соединение, подписывается на события, публикует триггер и ждёт сообщения, а затем аккуратно закрывает канал. Отдельной проверки заслуживает лимит одновременных соединений, потому что подписки держат канал открытым дольше любого запроса.
Моки поверх схемы для быстрых интеграционных проверок и фронтенда
Когда бэкенд ещё пишется, а фронтенд уже ждёт, выручают моки поверх схемы. Инструментарий graphql-tools накидывает правдоподобные значения по типам полей, скаляры настраиваются вручную, а флаг preserveResolvers позволяет оставить часть настоящих резолверов нетронутой и мокать только соседние:
import { makeExecutableSchema } from "@graphql-tools/schema";
import { addMocksToSchema } from "@graphql-tools/mock";
const schema = makeExecutableSchema({ typeDefs, resolvers });
addMocksToSchema({
schema,
mocks: { User: () => ({ name: "Maria" }) },
preserveResolvers: true
});
Моковая схема решает две задачи сразу: фронтенд собирает интерфейс против настоящего контракта, а бэкенд получает мгновенный стенд для интеграционных проверок. Сюда же относится фаззинг, генерация случайных запросов по интроспекции: она находит поля, падающие на необычных значениях, раньше, чем их найдёт пользователь.
У моков есть клиентская пара: MockedProvider из клиентских наборов позволяет фронтенду проверять компоненты против объявленных операций без сервера вообще. Связка из моков на сервере и провайдера на клиенте держит в тонусе обе команды, пока настоящие резолверы догоняют интерфейс.
Повторяемость моков важнее их красоты: генератор значений запускают с фиксированным семенем, иначе падение теста будет зависеть от настроения случайных строк. Правдивые по форме значения, правильные длины, понятные символы, и ни одного случайного провала на ровном месте.
Нагрузочные испытания ограничение глубины запросов и поиск скрытых N+1
Гибкая вложенность запросов это налог на сервер: один неудачный запрос способен утащить целую таблицу через цикл вложенных полей. Инструменты простые: k6 и Artillery гоняют настоящие HTTP запросы и считают перцентили, а лимиты ставят заранее, типичный предел глубины около десяти уровней, плюс оценка стоимости и персистентные запросы, когда вместо текста уезжает хеш. Скрытые N+1 ловятся в юнит-тестах через DataLoader: счётчик обращений к источнику не должен превысить числа батчей, и если тест видит двадцать запросов вместо двух, запрос уходит на доработку.
Каденция в CI выстраивается по весу: юнит-тесты и executeOperation на каждое сохранение, сетевые проверки и валидацию схемы на каждый pull request, нагрузочные сценарии по ночам. Перцентиль p95 честнее среднего, а p99 показывает хвост, который видит самый неудачливый пользователь.
Для нагрузочных сценариев готовят реалистичные запросы по мотивам продакшена: топ операций обычно покрывает большую часть трафика, и гнать самые тяжёлые комбинации ради красивых цифр смысла нет. Порядок внедрения тестов сходится в короткую лестницу:
- повесить graphql-inspector diff и валидацию схемы на каждый pull request;
- покрыть ключевые резолверы юнит-тестами через graphql с моками источников;
- прогнать главные сценарии через executeOperation без сети;
- проверить сетевой слой supertest, включая 400 и маскирование стеков;
- поднять моки поверх схемы для фронтенда и интеграционных стендов;
- ограничить глубину и стоимость запросов и покрыть лимиты тестами;
- поставить нагрузочные сценарии на k6 и следить за p95 в сборке.
REST проверял контракт по путям, GraphQL проверяет его по слоям: схема, резолвер, конвейер, сеть, нагрузка. Каждый уровень закрыт собственным инструментом, ломающее изменение ловится до слияния, а упавший перцентиль до релиза. И если тесты ловят каждое изменение схемы, недели перестают быть страшными: контракт жив, клиенты целы, а ночные сборки вместо тревог приносят отчёты.