The most useful tests for a graphitron-rewrite-generated schema are consumer execution tests: full GraphQL requests sent through your generated facade, against a real PostgreSQL instance (Testcontainers in CI, native local DB for the inner loop), with assertions on both the response shape (did the right rows come back) and the runtime cost (how many JDBC round-trips fired, what SQL ran, did DataLoader batches consolidate). The compilation tier (the rewrite checks this in the example module) and the pipeline tier (the rewrite checks this in graphitron) are upstream of you; what’s left for the consumer is "the contract between SDL, Java service code, and the database is what I expect".
This recipe walks the canonical setup: starting Postgres, building the schema, executing requests, and the two assertion families that capture what schema authors most need to verify. It’s anchored on GraphQLQueryTest (graphitron-sakila-example/src/test/java/no/sikt/graphitron/rewrite/test/querydb/GraphQLQueryTest.java) which uses every shape the recipe describes; copy it as the starting template for your application.
For the rewrite-internal tier model (unit/pipeline/compilation/execution), see the test-tier guide. This recipe is about the consumer’s execution-tier setup, not the rewrite’s tier choices.
The setup: Postgres + DSLContext + GraphQL
Three things must be in place before a request runs:
-
A
DSLContextpointing at a Postgres instance with your application’s schema migrations applied. The example module uses Testcontainers in CI and accepts atest.db.urlsystem property for the inner loop; either path produces aDSLContextagainst the same database shape. -
A call to
Graphitron.newExecutionInput(dsl, …)per request, threading theDSLContextand any@service/@conditioncontextArgumentsthrough the typed parameter list. -
A
GraphQLSchemaproduced by your generatedGraphitron.buildSchema(b → {})facade. The schema is built once per test class (@BeforeAll); requests reuse it.
The skeleton:
@ExecutionTier
class GraphQLQueryTest {
static PostgreSQLContainer postgres;
static DSLContext dsl;
static GraphQL graphql;
static final AtomicInteger QUERY_COUNT = new AtomicInteger();
static final List<String> SQL_LOG = new CopyOnWriteArrayList<>();
@BeforeAll
static void startDatabase() throws Exception {
var localUrl = System.getProperty("test.db.url");
if (localUrl != null) {
dsl = DSL.using(localUrl,
System.getProperty("test.db.username", "postgres"),
System.getProperty("test.db.password", "postgres"));
} else {
postgres = new PostgreSQLContainer("postgres:18-alpine")
.withInitScript("init.sql");
postgres.start();
dsl = DSL.using(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
dsl.configuration().set(new DefaultExecuteListenerProvider(new ExecuteListener() {
@Override public void executeStart(ExecuteContext ctx) {
QUERY_COUNT.incrementAndGet();
var sql = ctx.sql();
if (sql != null) SQL_LOG.add(sql.toLowerCase(Locale.ROOT));
}
}));
GraphQLSchema schema = Graphitron.buildSchema(b -> {});
graphql = GraphQL.newGraphQL(schema).build();
}
@AfterAll
static void stopDatabase() {
if (postgres != null) postgres.stop();
}
}
The ExecuteListener is what makes the round-trip-count and SQL-shape assertions possible: every JDBC statement increments QUERY_COUNT and adds its rendered SQL to SQL_LOG. Tests that need to assert "this was one batched query, not N per-parent queries" reset QUERY_COUNT before executing and assert on it after.
The Testcontainers fallback runs Postgres 18 alpine with init.sql as the bootstrap; consumer tests use whatever migrations your application runs in production. The test.db.url system property is the inner-loop optimisation: when set, the test connects to a local Postgres without spinning a container, which shaves about 10 seconds per test class start.
Executing a request
A helper method captures the request shape; tests call it with the GraphQL string:
private Map<String, Object> execute(String query) {
var input = Graphitron.newExecutionInput(dsl).query(query).build();
var result = graphql.execute(input);
assertThat(result.getErrors()).isEmpty();
return result.getData();
}
Three things to know about this helper:
-
Graphitron.newExecutionInput(…)is the per-request wiring point. Its parameter list is schema-driven: aDSLContextfirst, then one typed parameter percontextArgumentdeclared in your@service/@condition/@tableMethoddirectives. The example wires the samedslfor every test; production tests that exercise per-tenant routing build a per-tenantDSLContextat request entry and pass it asdefaultDsl. The per-tenant routing recipe is being rewritten under R45 alongside the tenant-column work. -
contextArgumentsthread through typed factory slots. A@service(contextArguments: ["tenantId"])site adds aString tenantIdparameter to the generated factory; the test passes the value directly. There is no per-request anonymousGraphitronContextimpl; the sealed interface is populated by the factory. -
DataLoaderRegistryis mandatory and the factory attaches one. graphql-java requires a registry per request; the factory pre-attaches a freshDataLoaderRegistryso generatedSplit*fetchers cancomputeIfAbsenton it at first lookup. Override via.dataLoaderRegistry(custom)only when you need a pre-populated registry;.graphQLContext(b → b.put(…))calls merge with the entries the factory put.
The assertThat(result.getErrors()).isEmpty() line is the default safety net. Tests that expect an error (validation failure, redacted exception) use a sister method executeRaw that returns the raw ExecutionResult and asserts on the error list.
Value assertions: what came back
The most direct shape: pull GraphQL response fields out of the Map<String, Object> and assert on them. AssertJ’s as(LIST), as(MAP), as(list(Map.class)) factories navigate the nested-map shape cleanly:
@Test
void customers_returnsAllCustomers() {
Map<String, Object> data = execute("{ customers { customerId firstName lastName } }");
assertThat(data).extractingByKey("customers", as(LIST)).hasSize(5);
}
@Test
void customers_filteredByActive() {
Map<String, Object> data = execute("{ customers(active: true) { customerId firstName } }");
assertThat(data).extractingByKey("customers", as(list(Map.class)))
.hasSize(3)
.allSatisfy(c -> assertThat(c).containsKey("firstName"));
}
Two patterns worth standardising on:
-
Assert what came back, not what didn’t.
containsOnlyKeys("firstName", "lastName")catches the regression where selection-set scoping leaks (e.g. customer rows accidentally projecting film columns). PlaincontainsKeydoesn’t. -
Cross-check with the seed data. The example seeds five films with deterministic IDs; tests that depend on row counts (
hasSize(5)) or specific titles (containsExactly("ACADEMY DINOSAUR", …)) hard-code values frominit.sql. This is fragile if the seed shifts but valuable as a contract: the test breaks loudly when the data-fixture and the schema-fixture diverge.
For unions and interfaces, assert on __typename to verify the runtime type resolver routes correctly:
@Test
void search_returnsMixedTypes() {
Map<String, Object> data = execute("{ search { __typename ... on Film { title } ... on Actor { firstName } } }");
assertThat(data).extractingByKey("search", as(list(Map.class)))
.extracting(r -> r.get("__typename"))
.containsExactlyInAnyOrder("Film", "Film", "Actor", "Actor");
}
Performance assertions: how many round-trips fired
This is what the QUERY_COUNT and SQL_LOG machinery exists for. The pattern: reset before, execute, assert after.
@Test
void splitTableField_multipleParents_scatterPerParent() {
QUERY_COUNT.set(0);
Map<String, Object> data = execute(
"{ languageByKey(language_id: [1, 2, 3]) { languageId films { filmId } } }");
// Expect 2 JDBC round-trips: 1 for languageByKey root + 1 batched for films.
// Unbatched scatter would fire 1 + N=3 = 4.
assertThat(QUERY_COUNT.get()).isEqualTo(2);
// Value assertions follow...
}
The 2-vs-4 difference is the test’s primary signal: a regression that breaks DataLoader batching turns 2 into 4, the value assertions still pass, and only the count assertion catches the regression. This is the canonical reason to write performance assertions: the user-visible response is the same, but per-request cost has gone up by a factor of N.
SQL_LOG is the same idea at finer granularity: assert that a particular SQL shape did or didn’t run.
@Test
void totalCount_isLazyOnSelection_noCountSqlWhenUnselected() {
SQL_LOG.clear();
execute("{ filmsConnection(first: 2) { nodes { title } pageInfo { hasNextPage } } }");
assertThat(SQL_LOG)
.as("no SELECT count statement should be issued when totalCount is not selected")
.noneMatch(s -> s.contains("select count"));
}
@Test
void totalCount_selected_doesIssueCountSql() {
SQL_LOG.clear();
execute("{ filmsConnection { totalCount } }");
assertThat(SQL_LOG)
.filteredOn(s -> s.contains("select count"))
.as("selecting totalCount should issue exactly one SELECT count statement")
.hasSize(1);
}
The pair is the regression-fence: one ratchets that the lazy-on-selection optimisation didn’t break, the other ratchets that selecting the field still produces the count query. Without both, a regression that always fires the count query (or never fires it) passes one test and fails the other.
Common patterns from the example:
-
DataLoader batching. Compare expected count to per-parent fan-out:
1 + 1(one root + one batched child) vs1 + N. -
Empty-input short-circuits.
actor_id: []on a@lookupKeyfield should not issue a child query;QUERY_COUNT == 1(parent only) is the assertion. -
Connection cursor round-trip. Page 1’s
endCursorfed back into afirst: N, after: <cursor>request should pull page 2 with the right rows; the test pins both the cursor encoding and the seek’s correctness.
Error-path tests
execute asserts no errors. For tests that exercise error paths (validation failures, mapped exceptions through the errors: channel, redacted unmatched exceptions), use a raw helper:
private ExecutionResult executeRaw(String query) {
var input = Graphitron.newExecutionInput(dsl).query(query).build();
return graphql.execute(input);
}
Then assert on the errors directly:
@Test
void connectionField_firstAndLast_areMutuallyExclusive() {
var result = executeRaw("{ filmsConnection(first: 2, last: 2) { totalCount } }");
assertThat(result.getErrors())
.as("Relay forbids mixing first and last on the same request")
.isNotEmpty();
}
For typed errors flowing through the errors channel, the test asserts on the response data (the errors: field on the payload) rather than result.getErrors(). The framework redaction path (unmatched exceptions) shows up in result.getErrors() with the "An error occurred. Reference: <UUID>." message; tests that exercise the redact path assert the message format and that no original exception details leak.
Context-argument tests
For @service or @condition paths that read context arguments, the helper takes typed values directly and passes them through the schema-driven factory slot:
private Map<String, Object> executeWithContext(String query, String userId) {
var input = Graphitron.newExecutionInput(dsl, userId).query(query).build();
var result = graphql.execute(input);
assertThat(result.getErrors()).isEmpty();
return result.getData();
}
@Test
void servicePath_seesContextArgument() {
Map<String, Object> data = executeWithContext("{ myFilms { title } }", "user-42");
// Assertions specific to the contextArgument path
}
The pattern is the same as the basic helper, just with a callable context builder. For full-on multi-tenant testing, also see How-to: Tenant scoping for the framework-side wiring contract.
Federation entity tests
If your schema declares federation @key directives, the _entities resolver dispatches representations through the runtime entity dispatcher. FederationEntitiesDispatchTest (in the example’s querydb package) is the canonical template: same Testcontainers + Graphitron.buildSchema + ExecuteListener setup, with the request shape adjusted for federation:
@Test
void entities_resolveSingleNodeIdRep_returnsHydratedRow() {
String customerId = NodeIdEncoder.encode("Customer", 1);
Map<String, Object> data = execute(
"query Q($reps: [_Any!]!) { _entities(representations: $reps) { ... on Customer { firstName lastName } } }",
Map.of("reps", List.of(Map.of("__typename", "Customer", "id", customerId))));
var entities = (List<Map<String, Object>>) data.get("_entities");
assertThat(entities).singleElement(as(MAP))
.containsEntry("firstName", "MARY")
.containsEntry("lastName", "SMITH");
}
The test passes representations via GraphQL variables, and asserts on the dispatched results. For multi-alternative dispatch, the same shape works with multiple representations in the input list and assertions on which alternative matched (typically by inference from the response shape, since the dispatcher is internal). How-to: Federation key fields covers the dispatch rules; the test confirms the rules behave as documented.
Pitfalls
-
Hand-rolling
ExecutionInput.newExecutionInput()outside the factory. The factory attaches theDataLoaderRegistryand stashes the singletonGraphitronContextImpl; bypassing it produces NPEs at first DataLoader lookup and "context value … was not supplied"IllegalStateException`s at first `contextArgumentread. Always go throughGraphitron.newExecutionInput(dsl, …). -
Per-test connection leak.
@AfterAllstops the Testcontainers Postgres; a test that holdsdslreferences past class scope leaks a connection. Use@BeforeAll/@AfterAll(class-scoped) rather than@BeforeEach/@AfterEachfor the database; per-test work happens inside the request. -
QUERY_COUNTandSQL_LOGare global. They are static fields on the test class, mutated by the listener that fires on every JDBC statement. Tests that read them must reset (e.g.QUERY_COUNT.set(0),SQL_LOG.clear()) immediately before executing the GraphQL query, and the test must run on its own (no parallel test in the class touching the same DB at the same time). The example uses JUnit 5’s default sequential execution; if your test class enables parallel execution, switch to per-test counters. -
Seed-data drift breaks tests. Hard-coded row counts (
hasSize(5)) and specific values (containsExactly("ACADEMY DINOSAUR", …)) tie tests to the seed. Either commit the seed alongside the tests (the example does this) or assert on shape-only (size > 0,every row has a `firstNamekey`); avoid the middle ground where the seed is shared but mutable. -
init.sqlruns once per Testcontainers start. The example’s container starts in@BeforeAlland stops in@AfterAll, so the whole test class shares the same database state. Tests that mutate the data (mutations, deletes) must order their assertions accordingly, or use a per-test transaction rollback. The example uses read-only queries in most tests; mutation tests assert on the post-mutation state. -
Generated facade name is
Graphitron. The class your tests import is the rewrite-generatedGraphitron(default class name from the Mojo’s<outputPackage>). Federation-bearing schemas produce a separateGraphitronin the federation output package; the example imports both via fully-qualified package names so the two facades coexist. -
Compilation tier covers "did the source compile"; execution tier covers "does it run". Don’t add execution tests for purely structural concerns (fields exist, types are reachable); the compilation tier already catches those, and execution tests are slower. Reach for execution-tier tests when the contract is "this request returns this row" or "this DataLoader batches these parents".
See also
-
GraphQLQueryTest(graphitron-rewrite/graphitron-sakila-example/src/test/java/no/sikt/graphitron/rewrite/test/querydb/GraphQLQueryTest.java) is the canonical template; copy its@BeforeAllsetup. -
FederationEntitiesDispatchTest(same package) covers the federation-side test pattern. -
How-to: Apollo Federation transport covers the framework-level wiring this recipe assumes is in place when testing federation entities.
-
How-to: Handle services covers the
@serviceargument-binding contract that consumer tests exercise. -
How-to: The errors channel covers asserting on typed errors that flow through a payload’s
errors:field vs the framework redaction path. -
How-to: Federation key fields covers the dispatch rules a federation-side test verifies.