Тестирование GraphQL-сервера сбивает с толку привычками из мира REST. В REST каждый эндпоинт это отдельный адрес со своим методом, и проверять их можно по одному обычными запросами. В GraphQL же один адрес обслуживает любые запросы, а клиент сам выбирает, какие поля и в какой комбинации запросить из единой схемы. Это рождает две специфические задачи. Первая в том, как проверить резолверы и источники данных, не поднимая полноценный сервер на каждый тест. Вторая в том, как уберечься от ломающих изменений схемы, ведь стоит удалить или переименовать поле, и все клиенты, что его запрашивали, молча сломаются.
Apollo Server даёт элегантный ответ на первую задачу через метод прогона операции напрямую. Этот метод исполняет запрос или мутацию через весь конвейер обработки сервера, но без отправки реального HTTP-запроса. На вторую задачу отвечают проверки схемы, которые сличают предлагаемое изменение со схемой и с реальными запросами клиентов, помечая опасные правки до выкатки. Вместе эти два инструмента закрывают обе характерные боли GraphQL.
Дальше идёт разбор обеих стратегий: как устроено интеграционное тестирование резолверов через прямой прогон операций и как выстроить защиту от ломающих изменений схемы. Кода ровно столько, сколько нужно увидеть механику, и весь он в отдельных блоках с разбором вокруг.
Прямой прогон операции через конвейер сервера без подъёма HTTP
Сердце интеграционного тестирования в Apollo Server это метод прямого прогона операции. Он исполняет операцию через весь запросный конвейер сервера, но не открывает сетевой порт и не шлёт настоящий HTTP-запрос. Это принципиально, потому что конвейер Apollo Server поддерживает множество подключаемых модулей, влияющих на исполнение операции, и прямой прогон проводит запрос через них все, давая максимально полный тест без накладных расходов на настоящий сервер.
Удобная деталь в том, что при таком тестировании не нужно вручную запускать сервер. Метод прогона сам выполнит запуск, если он ещё не произошёл, так что лишнего шага не требуется. Базовый тест резолвера выглядит лаконично: создаётся экземпляр сервера с конфигурацией, исполняется операция, проверяется результат.
const server = new ApolloServer(config)
const result = await server.executeOperation({
query: GET_USER,
variables: { id: 1 },
})
expect(result.errors).toBeUndefined()
expect(result.data?.user.name).toBe('Ида')
Тут стоит ухватить несколько тонкостей. Текст операции всегда передаётся под ключом запроса, даже если это мутация, потому что интерфейс повторяет протокол GraphQL поверх HTTP, где запрос и мутация идут одинаково. Передать его можно как строкой, так и в виде разобранного дерева операции через специальный тег. А ошибки разбора, валидации и исполнения операции возвращаются не как исключения, а в поле ошибок результата, ровно как в настоящем ответе GraphQL. Поэтому проверять надо именно поле ошибок, а не оборачивать вызов в перехват исключений.
Это интеграционный тест по своей природе, потому что он проверяет связку резолверов и источников данных вместе, а не отдельную функцию в изоляции. Прямой прогон даёт единую точку входа, чтобы провести операцию через конвейер и убедиться, что вся цепочка от запроса до данных отрабатывает верно.
Передача контекста и подмена источников данных в тесте
Реальные резолверы почти всегда зависят от контекста: в нём живёт клиент базы данных, данные аутентификации, разрешения пользователя. Чтобы тест был осмысленным, контекст нужно уметь задать. Метод прогона операции принимает контекст вторым аргументом, и есть два пути его сформировать. Первый это передать тестовый контекст напрямую, подсунув данные вместо вычисления их из запроса. Второй это воспользоваться настоящей контекстной функцией сервера, передав ей подготовленный объект.
const result = await server.executeOperation(
{ query: GET_USER, variables: { id: 1 } },
{
contextValue: {
db: prismaTestClient,
user: { id: 1, role: 'admin' },
},
}
)
Тестовый контекст с прямой подстановкой данных удобен, когда хочется изолировать резолвер от логики вычисления контекста и сосредоточиться на самом резолвере. Для слоя данных в контекст обычно кладут тестовый клиент базы, например подключённый к одноразовому контейнеру, о чём шла речь в разговоре про интеграционные тесты с настоящей базой. Так резолвер ходит в реальную базу с предсказуемыми данными, а тест проверяет всю цепочку правдиво.
Полезный приём для GraphQL-тестов это фрагменты. Вместо того чтобы заново выписывать список запрашиваемых полей в каждом тесте, набор полей выносят в переиспользуемый фрагмент и подставляют в запросы. Это убирает дублирование и держит тесты в синхроне со схемой: поменялся набор полей, правка в одном месте расходится по всем тестам.
Что именно проверять в резолверах и почему важны и успех, и ошибка
Тест резолвера не сводится к проверке счастливого пути. Полноценное покрытие GraphQL-операции включает несколько уровней. Первый это успешное исполнение с корректными данными на правильном входе. Второй это поведение при ошибках: что вернёт сервер на неверный аргумент, на отсутствие прав, на запрос несуществующей сущности. Поскольку ошибки GraphQL приходят в поле ошибок результата, их проверка естественно вписывается в тот же стиль.
test('запрос чужого профиля без прав возвращает ошибку', async () => {
const result = await server.executeOperation(
{ query: GET_USER, variables: { id: 999 } },
{ contextValue: { db: prismaTestClient, user: { id: 1, role: 'user' } } }
)
expect(result.body.singleResult.errors?.[0].extensions?.code).toBe('FORBIDDEN')
})
Третий уровень это проверка работы с переменными операции, потому что именно через них клиент передаёт ввод, и неверная обработка переменных частый источник багов. Четвёртый это побочные эффекты мутаций: мало проверить, что мутация вернула ожидаемый ответ, надо убедиться, что она действительно изменила состояние, например создала запись в базе. Здоровый набор тестов GraphQL покрывает все эти уровни, а не ограничивается одним успешным запросом, потому что коварство прячется именно в обработке краёв.
Вторая боль GraphQL и природа ломающих изменений схемы
Перейдём ко второй характерной задаче, к защите от ломающих изменений схемы. Природа GraphQL такова, что схема это публичный контракт между сервером и всеми его клиентами. Клиенты пишут запросы, опираясь на конкретные поля и их типы. Стоит удалить поле, переименовать его, сделать ранее необязательный аргумент обязательным, и каждый клиент, что на это поле или аргумент опирался, сломается. Причём сломается не на этапе сборки сервера, а в рантайме у пользователя, что хуже всего.
Классический пример ломающего изменения это добавление обязательного аргумента к полю, которое клиенты исторически запрашивали без него. Сервер компилируется, тесты резолверов проходят, но старые запросы клиентов внезапно становятся невалидными. Или удаление поля, которым, как казалось, никто не пользуется, а на деле его дёргает забытый мобильный клиент. Обычные тесты резолверов такие вещи не ловят, потому что они проверяют поведение кода, а не совместимость схемы с уже существующими запросами.
Отсюда вывод. Защита от ломающих изменений это отдельный слой, не покрываемый тестами резолверов. Нужен механизм, который сравнивает новую версию схемы со старой и говорит, какие изменения безопасны, а какие сломают существующих клиентов. И этот механизм должен встраиваться в пайплайн, чтобы опасная правка не доехала до выкатки.
Проверки схемы и сравнение со старой версией в пайплайне
Инструменты проверки схемы работают по общему принципу. Они обходят поля, аргументы и типы схемы, превращают их в адреса элементов схемы и сравнивают со старой версией, формируя список изменений. Каждое изменение классифицируется: безопасное оно или ломающее. Удаление поля ломающее, добавление нового необязательного поля безопасное, превращение необязательного аргумента в обязательный ломающее. Этот список и есть отчёт о совместимости.
Существуют два подхода к классификации, и разница между ними важна. Первый, более простой, опирается только на структуру схемы и считает ломающим любое изменение, теоретически способное сломать клиента. Он безопасен, но слишком осторожен: запрещает в том числе правки, которые на деле никого не сломают, потому что удаляемым полем давно никто не пользуется. Второй подход учитывает реальные данные о запросах клиентов за некоторый период. Он сличает предлагаемое изменение не только со схемой, но и с операциями, которые клиенты действительно исполняли за последнее время, обычно за прошедшую неделю.
# проверка схемы в пайплайне перед выкаткой
rover graph check my-graph@staging --schema ./schema.graphql
Второй подход куда практичнее, потому что отвечает на вопрос не теоретически, а по фактам. Если поле удаляется и за отслеживаемый период его никто не запрашивал, изменение безопасно, и проверка его пропускает. Если же исторически клиенты запрашивали поле без аргумента, а команда хочет сделать аргумент обязательным, проверка пометит это как ломающее, потому что видит реальные запросы, которые сломаются. Так дорогая осторожность сменяется точным вердиктом на основе фактического использования.
Часть инструментов хранит данные об операциях у себя, опираясь на аналитику использования из продакшена. Другие отказываются хранить данные и отслеживают изменения схемы прямо через историю системы контроля версий, так что каждое изменение видно в истории коммитов привычным образом. Выбор между ними зависит от того, что для команды важнее: учёт боевых данных или приватность и простота на базе уже знакомого инструмента.
Мокирование схемы и валидация запросов клиентов против неё
Между двумя основными слоями есть полезная промежуточная техника, ускоряющая разработку и ловящая часть проблем рано. Это мокирование схемы. Можно исполнять запросы против схемы, настроенной с заглушечными резолверами и значениями по умолчанию для скалярных типов, не имея ещё реализованной логики. Это позволяет проверять клиентскую часть приложения и форму ответов до того, как написаны настоящие резолверы, и помогает командам клиента и сервера двигаться параллельно.
Польза двойная. Команда фронтенда получает работающую заглушку программного интерфейса сразу после согласования схемы и не ждёт, пока бэкенд допишет резолверы. А команда бэкенда получает раннюю проверку того, что схема вообще пригодна для нужных клиенту запросов. Заглушки возвращают правдоподобные данные нужных типов, чего достаточно, чтобы собрать и проверить интерфейс.
Родственная техника это валидация запросов клиентов против схемы ещё до исполнения. Инструменты умеют проверять, что все операции, которые шлют клиенты, валидны относительно текущей схемы, и подсвечивать ошибки до рантайма. Это перекидывает мостик между двумя слоями: с одной стороны проверяется код резолверов, с другой совместимость схемы, а валидация операций ловит несоответствие запросов и схемы в зазоре между ними.
// исполнение запроса против схемы с заглушечными резолверами
const result = await server.executeOperation({ query: GET_USER })
expect(result.body.singleResult.data?.user).toBeDefined()
Важно понимать место этой техники. Мокирование не заменяет ни интеграционные тесты резолверов, ни проверки схемы, а дополняет их на раннем этапе, когда настоящей логики ещё нет. Полагаться только на заглушки опасно, потому что они по определению не проверяют реальное поведение, лишь форму. Поэтому мок-схема это инструмент скорости разработки и ранней обратной связи, а не гарантия правильности.
Встраивание обеих стратегий в рабочий процесс
Чтобы свести стратегию тестирования GraphQL к рабочим ориентирам, ниже единственный список этой статьи, расставленный по важности:
- Резолверы и источники данных проверять интеграционно через прямой прогон операции по конвейеру сервера, без подъёма HTTP, как самый полный и быстрый способ;
- Контекст в тестах задавать явно, подсовывая тестовый клиент базы и данные пользователя, чтобы проверять всю цепочку правдиво;
- Покрывать не только счастливый путь, но и ошибки, права, переменные и побочные эффекты мутаций, потому что баги живут на краях;
- Защиту от ломающих изменений схемы выстраивать отдельным слоем проверок схемы, не полагаясь на тесты резолверов, которые её не ловят;
- Предпочитать классификацию изменений на основе реальных запросов клиентов теоретической, чтобы не запрещать безопасные правки и точно ловить опасные;
- Встраивать проверку схемы в пайплайн как обязательный шаг перед выкаткой, иначе её вердикт остаётся рекомендательным.
Эти шесть правил разделяют две стороны задачи, которые легко перепутать. Тесты резолверов отвечают на вопрос, правильно ли работает код. Проверки схемы отвечают на вопрос, не сломает ли изменение существующих клиентов. Смешивать их или надеяться, что одно покроет другое, ошибка: это разные слои с разными инструментами.
Итог укладывается в одну мысль. GraphQL ставит две задачи, которых нет в привычном REST. Резолверы проверяются прямым прогоном операции через конвейер сервера без HTTP, что даёт полный интеграционный тест связки кода и данных, причём с заданным контекстом и покрытием не только успеха, но и ошибок, прав и побочных эффектов. Совместимость же схемы с клиентами это отдельная боль, которую тесты резолверов не видят, и закрывается она проверками схемы, сличающими изменение с реальными запросами клиентов и встроенными в пайплайн как обязательный заслон. Команда, которая держит оба слоя, выкатывает изменения GraphQL уверенно. Команда, которая проверяет только резолверы, рано или поздно ломает забытого клиента переименованным полем и узнаёт об этом от пользователя, а не от пайплайна.