Testing GraphQL APIs — GraphQL

Testing GraphQL APIs Test GraphQL API seperti test API biasa, tapi ada tooling khusus yang membantu. Integration Test import { ApolloServer } from…

Test adalah kode yang memeriksa kode-mu secara otomatis: ia menjalankan sesuatu, lalu membandingkan hasilnya dengan yang seharusnya. Untuk API GraphQL, yang diperiksa ada empat lapis: resolver, operation yang dijalankan terhadap schema, operation milik tiap aplikasi client, dan schema itu sendiri. Bayangin Toko Buku sebelum buka. Kasir menjalankan daftar belanja percobaan: scan Laskar Pelangi harus muncul 89.000, restock Bumi Manusia harus menambah stok, dan kartu member lama dari pelanggan tetap harus masih diterima mesin kasir yang baru.

🧪 Tebakan cepat: Resolver book melempar GraphQLError("Book not found"). Di dalam test, const result = await run("{ book(id: 99) { title } }"). Apakah baris itu melempar error sehingga test-nya gagal sendiri? Jawabannya ada di bagian "Menjalankan operation terhadap schema".

Resolver adalah fungsi biasa

Resolver cuma fungsi (parent, args, context, info). Jadi bisa dipanggil langsung di test, dengan data palsu dan context buatan. Contoh dengan Vitest (versi 5.0.3; Jest sama persis untuk bagian ini):

import { describe, expect, test } from "vitest";
import { createResolvers } from "./resolvers.js";

const freshDb = () => ({
  books: [{ id: 2, title: "Bumi Manusia", authorId: 2, price: 125000, stock: 0 }],
  authors: [{ id: 2, name: "Pramoedya Ananta Toer" }],
});

describe("resolver sebagai fungsi biasa", () => {
  test('Query.book(id: "2") memberi Bumi Manusia', () => {
    const resolvers = createResolvers(freshDb());
    expect(resolvers.Query.book(null, { id: "2" }, { user: null }).title).toBe("Bumi Manusia");
  });

  test("Mutation.updateStock menyimpan stok baru", () => {
    const db = freshDb();
    createResolvers(db).Mutation.updateStock(null, { id: "2", stock: 7 }, { user: null });
    expect(db.books[0].stock).toBe(7);
  });
});

Menjalankan operation terhadap schema

Test yang paling berharga mengirim operation sungguhan: parsing, validasi, resolver, dan bentuk response diuji bersama. Ini yang disebut integration test, dan untuk GraphQL ini pendekatan terpenting. Dengan graphql-js 17.0.2 dan @graphql-tools/schema 10.1.3:

import { graphql } from "graphql";
import { makeExecutableSchema } from "@graphql-tools/schema";

const run = (source, variableValues) => {
  const schema = makeExecutableSchema({ typeDefs, resolvers: createResolvers(freshDb()) });
  return graphql({ schema, source, variableValues, contextValue: { user: null } }); // context untuk test
};

test("Detail: judul dan stok", async () => {
  const result = await run("query Detail($id: ID!) { book(id: $id) { title stock } }", { id: "2" });
  expect(result).toEqual({ data: { book: { title: "Bumi Manusia", stock: 0 } } });
});

test("book(id: 99): data null dan NOT_FOUND", async () => {
  const result = await run("{ book(id: 99) { title } }");
  expect(result.data).toEqual({ book: null });
  expect(result.errors[0].extensions.code).toBe("NOT_FOUND");
});

Response untuk book(id: 99):

{"errors":[{"message":"Book not found","locations":[{"line":1,"column":3}],"path":["book"],"extensions":{"code":"NOT_FOUND"}}],"data":{"book":null}}

graphql() gak pernah melempar karena error resolver. Error-nya masuk ke errors, field-nya jadi null, dan response tetap dikembalikan. Itu jawaban tebakannya: test gak gagal sendiri, jadi kamu wajib memeriksa errors. Test yang cuma memeriksa data bisa lulus padahal ada error.

Kalau server-mu Apollo Server 5.5.1, server.executeOperation({ query, variables }, { contextValue }) menjalankan operation lewat pipeline-nya, plugin ikut jalan. Hasilnya { body: { kind: "single", singleResult: { data } } }, jadi baca response.body.singleResult.

Apa saja yang perlu dites saat schema baru dibuat: schema-nya valid (buildSchema melempar Unknown type "Boook". untuk type yang salah ketik), query mengembalikan data yang benar, mutation benar-benar mengubah data, penanganan error (input salah, belum login, gak boleh), dan kasus pinggir seperti null dan list kosong (Ahmad Tohari belum punya buku, jadi books: []).

Mock: data palsu dari schema

addMocksToSchema dari @graphql-tools/mock 9.1.16 membuat schema yang menjawab query apa pun dengan data palsu, tanpa resolver. Berguna untuk frontend yang mulai lebih dulu, atau untuk test di client:

import { addMocksToSchema } from "@graphql-tools/mock";

const mocked = addMocksToSchema({ schema: makeExecutableSchema({ typeDefs }) });
await graphql({ schema: mocked, source: "{ books { title price status } }" });
// {"data":{"books":[{"title":"Hello World","price":20,"status":"IN_STOCK"},{"title":"Hello World","price":-100,"status":"IN_STOCK"}]}}

String jadi "Hello World", Int dan enum acak, list berisi 2 item. Ganti dengan mocks: { Int: () => 89000 }, atau pakai preserveResolvers: true supaya resolver yang sudah ada tetap dipakai. Ingat, mock menguji bentuk, bukan isi.

Snapshot atau assertion eksplisit

expect(result).toMatchSnapshot() menyimpan hasil pertama ke file __snapshots__, lalu membandingkan tiap run berikutnya; kalau beda, test gagal dengan Snapshot ... mismatched, dan vitest -u menimpanya. Gampang ditulis, tapi snapshot cuma tahu "beda", bukan "salah". Saat tim terbiasa menekan update tanpa membaca diff-nya, snapshot gak melindungi apa-apa. Pakai assertion eksplisit (toEqual, toBe) untuk perilaku, dan snapshot untuk teks yang memang ingin ditinjau saat berubah, misalnya printSchema(schema).

Cek perubahan schema di CI

Pelajaran "Schema Design Patterns" menunjukkan perubahan yang memecah client lama. Di CI, graphql-inspector (CLI 7.0.0, core 8.0.0) membandingkan schema di branch main dengan schema di pull request: npx graphql-inspector diff git:origin/main:schema.graphql schema.graphql. Tiap perubahan diberi tingkat:

Perubahan Tingkat Pesan
price diganti priceRupiah BREAKING Field 'price' was removed from object type 'Book'
NON_BREAKING Field 'priceRupiah' was added to object type 'Book'
status jadi String BREAKING Field 'Book.status' changed type from 'StockStatus!' to 'String!'
BREAKING Type for argument 'status' on field 'Query.books' changed from 'StockStatus' to 'String'
nilai enum HABIS ditambah DANGEROUS Enum value 'HABIS' was added to enum 'StockStatus'
price diberi @deprecated NON_BREAKING Field 'Book.price' is deprecated

BREAKING memecah client yang ada, DANGEROUS bisa memecah client yang gak siap (misalnya switch tanpa cabang untuk HABIS), NON_BREAKING aman. Kalau ada BREAKING, perintah itu keluar dengan kode 1 (Detected 2 breaking changes), jadi merge-nya diblok. Apollo GraphOS punya rover graph check dan rover subgraph check, yang memeriksa perubahan terhadap operation yang benar-benar dipakai client.

Kontrak untuk client

Test kontrak memastikan operation tiap aplikasi client masih valid terhadap schema baru. Operation diambil dari repo tiap aplikasi (atau dari manifest trusted documents), lalu divalidasi:

import { buildSchema, parse, validate } from "graphql";

test("web toko: Katalog masih valid", () => {
  const errors = validate(buildSchema(typeDefs), parse("query Katalog { books { title price status } }"));
  expect(errors.map((e) => e.message)).toEqual([]);
});

Setelah price diganti nama, test ini gagal dengan pesan asli graphql-js: Cannot query field "price" on type "Book". Setelah status jadi String, kontrak dashboard admin gagal: String cannot represent a non string value: SOLD_OUT. CLI-nya: graphql-inspector validate "./src/**/*.graphql" schema.graphql.

⚠️ Jebakan yang sering ketemu

TL;DR: Resolver adalah fungsi biasa, jadi bisa dites langsung dengan data dan context palsu, tapi itu gak menguji schema. Test terpenting menjalankan operation sungguhan terhadap schema (graphql({ schema, source, variableValues, contextValue }) atau server.executeOperation di Apollo Server) dan memeriksa data dan errors, karena graphql() gak melempar. Uji schema valid, data query, efek mutation, error (input salah, belum login), dan kasus pinggir. addMocksToSchema memberi data palsu dari schema saja. Assertion eksplisit untuk perilaku, snapshot cuma untuk teks yang ingin ditinjau. Di CI, graphql-inspector diff menandai perubahan BREAKING, DANGEROUS, atau NON_BREAKING dan memblok merge kalau ada yang BREAKING, sedangkan test kontrak memvalidasi operation tiap aplikasi client terhadap schema baru.

Yang akan kamu pelajari