Тестирование GraphQL-схемы
Я отправляю PR с новым resolver’ом в GraphQL-схему. Коллега говорит, что смерджил и задеплоил. Но когда я пытаюсь обратиться к новому resolver’у, он недоступен, и я не понимаю почему. Смотрю инфраструктурный (IaC) тест — схема изменилась, но моего resolver’а нет. Начинаю сходить с ума и теряю время.
Именно так могло бы быть, если бы у меня не было тестов.
Проблема тестирования Infrastructure-as-Code
Когда разрабатываешь Infrastructure-as-Code (по крайней мере на AWS — другие платформы не пробовал), часто замечаю, что написанные тесты на самом деле не проверяют то поведение, которое мне важно.
Главная цель тестирования — это проверка поведения системы. Но некоторые тесты вообще не занимаются поведением. Вместо этого они делают снимок исходного кода, фактически фиксируя его и сигнализируя об изменениях в коде. На первый взгляд звучит разумно. Но для меня это недостаток — мы тестируем то, как код выглядит, а не то, что код делает.
Типичный тест схемы
При тестировании Infrastructure-as-Code можно написать вот такой тест для проверки GraphQL-схемы:
describe("GraphQL schema tests", () => {
const stack = new StackWithSchema();
const template = Template.fromStack(stack);
it("should have a GraphQL API schema", () => {
template.resourceCountIs("AWS::AppSync::GraphQLSchema", 1);
template.hasResourceProperties("AWS::AppSync::GraphQLSchema", {
ApiId: { "Fn::GetAtt": [Match.stringLikeRegexp(`GraphQL*`), "ApiId"] },
Definition: "schema {... }", // Тут внутри схема
});
});
});
Но что именно проверяет этот тест? Он проверяет схему в .json-выхлопе из AWS CDK. Он не проверяет схему, задеплоенную на реальном GraphQL-сервере — то есть не проверяет то поведение, которое мне на самом деле нужно.
По сути это snapshot-тест кода, а не тест поведения. Лучше, чем ничего, но в идеале хочется проверять реальный функционал. Я заметил, что интеграционные тесты часто неявно покрывают поведение инфраструктуры — например, если resolver недоступен по API-ключу, ошибка очевидна. Если у Lambda нет доступа к DynamoDB — ошибка уже менее очевидна и нужно копаться в логах.
Тестирование схемы через интроспекцию
К счастью, GraphQL предоставляет встроенный способ тестировать схему через интроспекцию. В GraphQL есть определение схемы как типа:
extend type __Schema {
description: String
types: [__Type!]!
queryType: __Type!
mutationType: __Type
subscriptionType: __Type
directives: [__Directive!]!
}
Меня интересуют resolver’ы и типы, на которые они ссылаются, поэтому я написал такой интеграционный тест:
const introspectOperations = `{
__schema {
queryType {
fields {
name
}
}
mutationType {
fields {
name
}
}
subscriptionType {
fields {
name
}
}
}
}`;
const introspectTypes = `{
__schema {
types {
name
fields {
name
}
}
}
}`;
describe("Schema tests", () => {
const byName = (field: any) => field.name;
function testSchemaFields(schemaFields: any) {
const sortedSchemaFields = schemaFields.sort(byName);
expect(sortedSchemaFields).toMatchSnapshot();
}
it.each(["query", "mutation", "subscription"])(
"should verify %s operations",
async (operation) => {
const response = await sendGraphQLRequest({
query: introspectOperations,
});
const schema = response.data.__schema;
expect(schema).toBeTruthy();
const operationFields = schema[`${operation}Type`]?.fields || [];
testSchemaFields(operationFields);
},
);
it("should verify types", async () => {
const response = await sendGraphQLRequest({ query: introspectTypes });
const schema = response.data.__schema;
expect(schema).toBeTruthy();
const typeFields = schema.types;
testSchemaFields(typeFields);
});
});
Несколько заметок по коду:
- Обычно избегаю
any, но тут уж можно потерпеть. - Проверка
expect(schema).toBeTruthy();необязательна — просто проверяет, что GraphQL-сервер ответил схемой. - Реализацию
sendGraphQLRequest()опускаю, так как она зависит от инфраструктуры, но внутри обычный HTTP-запрос. - Сортировка (
schemaFields.sort(byName)) нужна, чтобы тест не падал из-за разного порядка полей схемы.
Как это работает на практике
Каждый раз, когда я деплою новую схему для GraphQL-сервиса (AWS AppSync), я запускаю этот тест в рамках интеграционных тестов. Он дает немедленную обратную связь о поведении задеплоенной схемы.
Вернемся к исходной ситуации. Я добавил новый resolver, коллега сказал, что смерджил и задеплоил изменение. Когда я запустил тест схемы, новый resolver отсутствовал. Мы проверили логи и обнаружили, что он смерджил мой код, но деплоя не было.
Тест дал мне быстрый, однозначный способ показать, что ожидаемое поведение не появилось. Это было не мнение, а фактов — и убедить коллегу оказалось просто. В этом и есть сила хорошего тестирования.