Testing Graph Use Cases
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
Graph services are hard to test with mocks: the behaviour that matters is a Cypher pattern, a constraint or a Graph Data Science (GDS) procedure, and none of these exists in a mocked repository. The use-case pages of this section therefore test against a real Neo4j started by Testcontainers, and each of them ends with an integration test that follows the same recipe. This page collects that recipe in one place: which kind of test to write, how to start the server, how to load and clean up data, and how to assert on algorithm output that is not deterministic. The general testing pyramid and the slice annotations are covered in Unit and Integration Testing; the container mechanics in Testcontainers Fundamentals and Spring Boot Integration Tests with Testcontainers. Neither is repeated here.
Choosing the test level
Three levels are useful, and a project normally has all three:
| Level | What it proves | Needs |
|---|---|---|
Unit test with mocks |
Service logic around the graph: mapping of results, branching, error handling. The repository or |
No server. |
|
Entity mapping, derived queries and |
A Neo4j server. Only the Spring Data Neo4j beans are created. |
|
Whole use cases: services, schema runners, scheduled jobs, REST endpoints and GDS procedures working together. |
A Neo4j server with the GDS and APOC plugins. |
@DataNeo4jTest starts a context that contains only the Neo4j infrastructure, the @Node entities and the Spring Data
repositories. @Service and @RestController beans are not scanned, so a service under test has to be added with
@Import. The annotation is part of the Neo4j test slice of Spring Boot and belongs to the data slices described in
@DataMongoTest and other data slices.
On Spring Boot 4 the annotation lives in the spring-boot-data-neo4j-test module, which the
shared project setup adds in test scope.
The example tests the Account entity of Fraud
Detection (its four-argument constructor, with no analytics scores yet) through a derived finder:
import java.util.List;
import org.springframework.data.neo4j.repository.Neo4jRepository;
public interface AccountRepository extends Neo4jRepository<Account, String> {
List<Account> findByFlaggedTrue();
}
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.neo4j.test.autoconfigure.DataNeo4jTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.neo4j.Neo4jContainer;
@Testcontainers
@DataNeo4jTest
class AccountRepositoryTest {
@Container
@ServiceConnection
static final Neo4jContainer NEO4J = new Neo4jContainer("neo4j:2026.10.1");
@Autowired
AccountRepository accounts;
@Test
void findsFlaggedAccounts() {
accounts.save(new Account("A1", true, null, null));
accounts.save(new Account("A2", false, null, null));
assertThat(accounts.findByFlaggedTrue()).extracting(Account::getAccountNumber).containsExactly("A1");
}
}
The slice does not need the GDS or APOC plugins, so its container starts faster than the one used for whole use cases. The slice test is transactional and rolls back after each test method, which keeps repository tests isolated without any cleanup code. Derived from Spring Boot — Auto-configured Data Neo4j Tests.
Whole use cases need @SpringBootTest, because the jobs, the schema runner and the GDS calls are separate beans, and
because a GDS projection is built from committed data, not from the uncommitted state of a test transaction. Those
tests commit their fixtures and clean up explicitly, as described below.
A Neo4j container with GDS and APOC
The container class is org.testcontainers.neo4j.Neo4jContainer, from the org.testcontainers:testcontainers-neo4j
artifact. It is not generic, and @ServiceConnection turns it into a Neo4jConnectionDetails bean, so no
spring.neo4j.* property is set by hand. The plugins are installed by the Neo4j Docker image from the NEO4J_PLUGINS
variable:
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.neo4j.Neo4jContainer;
@Testcontainers
@SpringBootTest
class FraudDetectionIT {
@Container
@ServiceConnection
static final Neo4jContainer NEO4J = new Neo4jContainer("neo4j:2026.10.1")
.withEnv("NEO4J_PLUGINS", "[\"graph-data-science\", \"apoc\"]");
// tests autowire the application beans
}
Derived from Testcontainers — Neo4j module, Spring Boot — Testcontainers and GDS — Docker installation.
Four details save debugging time:
-
Versions must pair. GDS 2026.10 runs on Neo4j 2026.10.x, and APOC Core must match the Neo4j year and month. Pin the image tag exactly, as in the listing, instead of using
latest. -
Procedures may need to be unrestricted. The plugin installer is enough for GDS in the tests of most pages. The pages that call
apoc.*write procedures, such as the identity merge in Identity Resolution, also setNEO4J_dbms_security_procedures_unrestrictedtogds.*,apoc.*. -
Start-up downloads the plugins. The first start of the container needs network access, and it is noticeably slower than a plain Neo4j. Keep plugin containers in the integration-test phase only, as described in Spring Boot Integration Tests with Testcontainers.
-
The GDS Community edition limits concurrency. A test that passes with the default settings should not depend on parallel speed-ups that a production edition provides.
Fixtures and cleanup
A fixture is a small, fully known graph. The use-case pages reuse the datasets of the Neo4j use cases reference, so the asserted results match the ones printed there. There are two ways to load it:
-
Inline Cypher in the test, as in the integration tests of Fraud Detection and Logistics and Supply Chain. The data sits next to the assertions, which makes the expected result easy to verify by reading.
-
A Cypher script under
src/test/resources, read once and run statement by statement. This suits fixtures that are shared by several test classes or that are too long to read inline.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.neo4j.core.Neo4jClient;
import org.springframework.stereotype.Component;
@Component
class GraphFixtures {
private final Neo4jClient client;
GraphFixtures(Neo4jClient client) {
this.client = client;
}
void reset() {
dropAllProjections();
client.query("MATCH (n) DETACH DELETE n").run();
}
void load(String script) throws IOException {
var text = new ClassPathResource(script).getContentAsString(StandardCharsets.UTF_8);
Arrays.stream(text.split(";"))
.map(String::strip)
.filter(statement -> !statement.isEmpty())
.forEach(statement -> client.query(statement).run());
}
private void dropAllProjections() {
client.query("""
CALL gds.graph.list() YIELD graphName
CALL gds.graph.drop(graphName, false) YIELD graphName AS dropped
RETURN count(dropped) AS dropped
""")
.fetch()
.all();
}
}
The splitting on the semicolon is deliberately naive and works for scripts that contain only statements, with no
semicolon inside a string. Derived from
Spring Data Neo4j — Neo4j Client and
GDS — Graph catalog. The Neo4jClient itself is
introduced in Spring Data Neo4j.
Cleanup follows three rules:
-
Reset before each test, not after. A
@BeforeEachthat deletes everything and reloads the fixture leaves a failed test’s data in place for inspection, and it makes each test independent of the order of the others. -
Drop the GDS projections too. A named projection lives in memory on the server and survives the deletion of the nodes it was built from. A stale projection is the usual reason a second test sees the first test’s graph. The helper of the foundation page drops its own graphs in
close(), and the reset above is a safety net for tests that fail half way. -
Recreate the schema when the reset removes it.
DETACH DELETEleaves constraints and indexes in place, so the schema runner only needs to run once per container. A test that drops constraints has to restore them.
For very large fixtures, load them with UNWIND batches instead of one statement per node, as described in
Production Considerations.
Deterministic algorithms and ranking-based assertions
The structural tests of the use cases pass reliably because they assert on what the algorithm decided, not on the labels and floats it used to say so. The facts that matter, checked against the procedure documentation:
| Procedure | What is deterministic |
|---|---|
Weakly Connected Components ( |
The membership of a component is. The component id is not stable, and no |
Louvain ( |
The partition of a clearly separated graph is, but the community ids are not, and no |
Betweenness ( |
Exact when no sampling is used. With sampling, |
K-Nearest Neighbors ( |
|
Dijkstra, A* and Yen’s ( |
Deterministic for a fixed graph and weights, except for ties between equally short paths. |
Derived from the WCC, Louvain, Betweenness Centrality and K-Nearest Neighbors pages of the GDS manual.
Three assertion styles follow from that table:
-
Assert memberships, not ids. Group the nodes by community id in the test, then compare the groups.
-
Assert rankings, not scores. The top-ranked node of Betweenness in the fraud dataset is
A4whatever the floating-point value is, and that is what the test checks. -
Assert with sets and tolerances. Use
containsExactlyInAnyOrderfor results without a defined order, and anwithin(offset)tolerance for any decimal that cannot be avoided.
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class CommunityAssertionsTest {
@Test
void ringsAreSeparatedWhateverTheCommunityIds() {
Map<String, Long> communityByAccount = loadCommunities();
// only what the reference states: the two rings are separate communities;
// where A7 lands and how many communities there are is not asserted
assertThat(communityByAccount.get("A1"))
.isEqualTo(communityByAccount.get("A2"))
.isEqualTo(communityByAccount.get("A3"));
assertThat(communityByAccount.get("A4"))
.isEqualTo(communityByAccount.get("A5"))
.isEqualTo(communityByAccount.get("A6"));
assertThat(communityByAccount.get("A1")).isNotEqualTo(communityByAccount.get("A4"));
}
@Test
void highestBetweennessIsTheBridgeAccount() {
List<String> ranking = accountsByScoreDescending();
assertThat(ranking.get(0)).isEqualTo("A4");
}
private Map<String, Long> loadCommunities() {
return Map.of(); // reads the property written by the job
}
private List<String> accountsByScoreDescending() {
return List.of(); // reads the scores written by the job, ordered in Cypher
}
}
The assertions above are the pattern, and the two private methods stand for the Cypher read that each use-case page shows. Derived from AssertJ — Core assertions.
When an algorithm exposes a seed, set it in the job’s configuration through a property so that tests can pin it without
changing production behaviour: a randomSeed and a concurrency of 1 for K-Nearest Neighbors, a samplingSeed for
sampled Betweenness. When it does not, restrict the test to a graph whose answer is unambiguous, such as the two
well-separated rings of the fraud dataset, so that any valid run produces the same partition.
Reusing the container
Starting Neo4j with plugins takes tens of seconds, and a suite with one container per test class repeats that cost. There are two safe ways to share:
-
A shared container bean, declared once in a
@TestConfiguration(proxyBeanMethods = false)and imported by every test class. Spring then caches the application context across those classes, and the container starts once for the JVM. This is the recommended style, described in Containers as beans. -
A static singleton in a base class, started once in a
staticblock. It suits suites that do not use a@TestConfiguration.
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.neo4j.Neo4jContainer;
@TestConfiguration(proxyBeanMethods = false)
public class Neo4jTestContainerConfig {
@Bean
@ServiceConnection
Neo4jContainer neo4j() {
return new Neo4jContainer("neo4j:2026.10.1")
.withEnv("NEO4J_PLUGINS", "[\"graph-data-science\", \"apoc\"]");
}
}
Test classes then declare @SpringBootTest and @Import(Neo4jTestContainerConfig.class). Derived from
Spring Boot — Service connections.
Sharing has a price: every class sees the data left by the previous one, so the reset described above becomes mandatory rather than optional, and test classes cannot run in parallel against the same container. The Testcontainers reuse mode goes further and keeps the container alive between builds; it is a local-development feature only and is never enabled on CI, see Reuse mode.
Runtime notes
-
Memory. GDS holds projections in the heap of the server. A container with a small memory limit fails a projection with an out-of-memory error, so give the Docker engine enough memory and keep fixtures small. The estimate procedures described in Calling GDS from Spring Boot also work in tests.
-
Naming and cleanup of the Docker side. Testcontainers removes the container when the JVM ends, so no test needs a
@AfterAllto stop it. -
Naming the tests. Use the
*ITsuffix for container-backed tests and run them with Failsafe, so a developer without Docker can still run the unit tests, as explained in Spring Boot Integration Tests with Testcontainers. -
Time-dependent data. Fixtures that use
datetime()produce different values on each run. Assert on relative windows, as the cycle test of Fraud Detection does, and never on a fixed timestamp. -
Scheduled jobs. Call the job method directly from the test instead of waiting for the scheduler, which keeps tests fast and removes timing flakiness. The scheduling side is described in Scheduling and ShedLock.
Where each use case tests what
Each use-case page ends with an integration test that applies this recipe to its own dataset:
-
Fraud Detection — community membership, the top Betweenness account, a shared-device ring and a transfer cycle.
-
Opportunity Detection — plain-Cypher recommendations, persisted similarity and bounded paging.
-
Logistics and Supply Chain — Dijkstra, Yen’s and A* routes, nearest warehouse, supplier outage and bottleneck.
-
Identity Resolution — blocking, clustering, the merge and stale confirmations.
-
Graph-Based Access Control — inherited permissions, path explanation, paging, cache invalidation and method security.
-
Dependency and Impact Analysis — blast radius, depth bound, common cause, single point of failure and cycle detection.
Running the same suite against a cluster or with production security is a deployment concern and is covered in Production Considerations.