Neo4j Testing Guide: Embedded Instances, Testcontainers, Cypher Query Testing & Relationship Assertions
Graph databases require testing strategies that go beyond simple CRUD assertions. Relationships, path traversals, and graph algorithms are first-class citizens in Neo4j—and your tests need to validate them. This guide covers the full Neo4j testing stack, from embedded instances for fast unit tests to Testcontainers for integration tests against real database behavior.
Why Graph Database Testing Is Different
When you test a relational database, you verify rows and columns. When you test Neo4j, you verify:
- Node existence and properties — does the node exist with the right labels and attributes?
- Relationship types and directions — is the relationship correct? Does direction matter here?
- Path lengths and traversal results — does the shortest path return the right nodes?
- Graph algorithms — does PageRank, community detection, or centrality return expected scores?
Mocking these semantics is practically impossible—you need a real graph engine.
Approach 1: Neo4j Embedded Test Instance (Neo4j 5.x)
Neo4j provides a test harness for JVM-based tests:
<dependency>
<groupId>org.neo4j</groupId>
<artifactId>neo4j-community</artifactId>
<version>5.15.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.neo4j.test</groupId>
<artifactId>neo4j-harness</artifactId>
<version>5.15.0</version>
<scope>test</scope>
</dependency>class EmbeddedNeo4jTest {
private static Neo4j embeddedDb;
private static Driver driver;
@BeforeAll
static void startDb() {
embeddedDb = Neo4jBuilders.newInProcessBuilder()
.withDisabledServer()
.build();
driver = GraphDatabase.driver(embeddedDb.boltURI());
}
@AfterAll
static void stopDb() {
driver.close();
embeddedDb.close();
}
@BeforeEach
void clearDatabase() {
try (Session session = driver.session()) {
session.run("MATCH (n) DETACH DELETE n");
}
}
@Test
void shouldCreateNodeWithCorrectLabelsAndProperties() {
try (Session session = driver.session()) {
session.run(
"CREATE (u:User {id: $id, name: $name, email: $email, createdAt: datetime()})",
Map.of("id", "user-1", "name", "Alice", "email", "alice@example.com")
);
Result result = session.run(
"MATCH (u:User {id: $id}) RETURN u",
Map.of("id", "user-1")
);
assertTrue(result.hasNext());
Node user = result.single().get("u").asNode();
assertTrue(user.hasLabel("User"));
assertEquals("Alice", user.get("name").asString());
assertEquals("alice@example.com", user.get("email").asString());
}
}
}Approach 2: Testcontainers for Real Neo4j
For tests that need the full Neo4j feature set including APOC plugins and GDS (Graph Data Science):
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>neo4j</artifactId>
<scope>test</scope>
</dependency>@Testcontainers
class Neo4jIntegrationTest {
@Container
static Neo4jContainer<?> neo4j = new Neo4jContainer<>(
DockerImageName.parse("neo4j:5.15")
)
.withAdminPassword("test-password")
.withNeo4jConfig("dbms.security.procedures.unrestricted", "apoc.*,gds.*")
.withPlugins(Neo4jLabsPlugin.APOC, Neo4jLabsPlugin.GRAPH_DATA_SCIENCE);
static Driver driver;
@BeforeAll
static void connect() {
driver = GraphDatabase.driver(
neo4j.getBoltUrl(),
AuthTokens.basic("neo4j", "test-password")
);
}
@AfterAll
static void disconnect() {
driver.close();
}
@BeforeEach
void resetGraph() {
try (Session session = driver.session()) {
session.run("MATCH (n) DETACH DELETE n");
}
}
}Relationship Assertions
Testing Relationship Creation and Direction
@Test
void shouldCreateBidirectionalFriendship() {
try (Session session = driver.session()) {
// Create nodes
session.run("CREATE (:User {id: 'alice'}), (:User {id: 'bob'})");
// Create friendship (undirected in semantic meaning but stored with direction)
session.run(
"MATCH (a:User {id: 'alice'}), (b:User {id: 'bob'}) " +
"CREATE (a)-[:FRIENDS_WITH {since: date()}]->(b)"
);
// Test that the relationship exists in either direction
Result result = session.run(
"MATCH (a:User {id: 'alice'})-[r:FRIENDS_WITH]-(b:User {id: 'bob'}) " +
"RETURN r, type(r) as relType, a.id as from, b.id as to"
);
assertTrue(result.hasNext());
Record record = result.single();
assertEquals("FRIENDS_WITH", record.get("relType").asString());
}
}
@Test
void shouldNotAllowSelfFriendship() {
try (Session session = driver.session()) {
session.run("CREATE (:User {id: 'alice'})");
// Create constraint or test business logic that prevents self-relationships
session.run(
"MATCH (a:User {id: 'alice'}) " +
"WHERE a.id <> 'alice' " + // This condition prevents self-relationship
"CREATE (a)-[:FRIENDS_WITH]->(a)"
);
// Alice should have no self-referential FRIENDS_WITH relationship
Result result = session.run(
"MATCH (u:User {id: 'alice'})-[:FRIENDS_WITH]->(u) RETURN u"
);
assertFalse(result.hasNext());
}
}Testing Relationship Properties
@Test
void shouldStoreAndRetrieveRelationshipProperties() {
try (Session session = driver.session()) {
LocalDate since = LocalDate.of(2024, 1, 15);
session.run(
"CREATE (u:User {id: 'alice'})-[:FOLLOWS {since: date($since), notifyAll: true}]->" +
"(:User {id: 'bob'})",
Map.of("since", since.toString())
);
Result result = session.run(
"MATCH (:User {id: 'alice'})-[r:FOLLOWS]->(:User {id: 'bob'}) " +
"RETURN r.since as since, r.notifyAll as notify"
);
assertTrue(result.hasNext());
Record record = result.single();
assertEquals("2024-01-15", record.get("since").asLocalDate().toString());
assertTrue(record.get("notify").asBoolean());
}
}Testing Relationship Counts
@Test
void shouldCountFollowers() {
try (Session session = driver.session()) {
// Create influencer and followers
session.run("CREATE (:User {id: 'influencer'})");
for (int i = 0; i < 5; i++) {
session.run(
"MATCH (target:User {id: 'influencer'}) " +
"CREATE (:User {id: $followerId})-[:FOLLOWS]->(target)",
Map.of("followerId", "follower-" + i)
);
}
Result result = session.run(
"MATCH (u:User {id: 'influencer'})<-[:FOLLOWS]-(f) " +
"RETURN count(f) as followerCount"
);
assertEquals(5L, result.single().get("followerCount").asLong());
}
}Cypher Query Testing: Path Traversals
Shortest Path
@Test
void shouldFindShortestPathBetweenUsers() {
try (Session session = driver.session()) {
// Create a network: alice -> bob -> charlie -> diana
session.run(
"CREATE (a:User {id: 'alice'})-[:KNOWS]->(b:User {id: 'bob'})" +
"-[:KNOWS]->(c:User {id: 'charlie'})-[:KNOWS]->(d:User {id: 'diana'})"
);
// Also create a longer path: alice -> eve -> diana
session.run(
"MATCH (a:User {id: 'alice'}), (d:User {id: 'diana'}) " +
"CREATE (a)-[:KNOWS]->(:User {id: 'eve'})-[:KNOWS]->(:User {id: 'frank'})" +
"-[:KNOWS]->(d)"
);
Result result = session.run(
"MATCH path = shortestPath(" +
" (start:User {id: 'alice'})-[:KNOWS*]-(end:User {id: 'diana'})" +
") RETURN length(path) as pathLength, " +
"[n in nodes(path) | n.id] as nodeIds"
);
assertTrue(result.hasNext());
Record record = result.single();
// Shortest path: alice -> bob -> charlie -> diana = length 3
assertEquals(3L, record.get("pathLength").asLong());
List<Object> nodeIds = record.get("nodeIds").asList();
assertEquals("alice", nodeIds.get(0));
assertEquals("diana", nodeIds.get(nodeIds.size() - 1));
}
}Variable-Length Path Queries
@Test
void shouldFindSecondDegreeConnections() {
try (Session session = driver.session()) {
// Setup network
session.run(
"CREATE (alice:User {id: 'alice'})" +
"-[:FRIENDS_WITH]->(bob:User {id: 'bob'})" +
"-[:FRIENDS_WITH]->(charlie:User {id: 'charlie'})," +
"(alice)-[:FRIENDS_WITH]->(diana:User {id: 'diana'})" +
"-[:FRIENDS_WITH]->(eve:User {id: 'eve'})"
);
// Find people exactly 2 hops away from alice
Result result = session.run(
"MATCH (alice:User {id: 'alice'})-[:FRIENDS_WITH*2]-(twoHop) " +
"WHERE NOT (alice)-[:FRIENDS_WITH]-(twoHop) AND twoHop <> alice " +
"RETURN DISTINCT twoHop.id as userId ORDER BY userId"
);
List<String> twoHopUsers = result.stream()
.map(r -> r.get("userId").asString())
.collect(Collectors.toList());
// Charlie and Eve are 2 hops away
assertThat(twoHopUsers).containsExactlyInAnyOrder("charlie", "eve");
}
}Testing Graph Algorithms (with GDS)
@Test
void shouldCalculatePageRankForInfluencers() {
try (Session session = driver.session()) {
// Create a directed social network
session.run(
"CREATE (a:User {id: 'alice'}), (b:User {id: 'bob'}), " +
"(c:User {id: 'charlie'}), (d:User {id: 'diana'}) " +
"CREATE (a)-[:FOLLOWS]->(c), (b)-[:FOLLOWS]->(c), " +
"(d)-[:FOLLOWS]->(c), (c)-[:FOLLOWS]->(b)"
);
// Project graph for GDS
session.run(
"CALL gds.graph.project('socialGraph', 'User', 'FOLLOWS')"
);
// Run PageRank
Result result = session.run(
"CALL gds.pageRank.stream('socialGraph') " +
"YIELD nodeId, score " +
"RETURN gds.util.asNode(nodeId).id AS userId, score " +
"ORDER BY score DESC LIMIT 3"
);
List<Record> rankings = result.list();
assertFalse(rankings.isEmpty());
// Charlie should have the highest PageRank (3 people follow her)
assertEquals("charlie", rankings.get(0).get("userId").asString());
// Cleanup projection
session.run("CALL gds.graph.drop('socialGraph')");
}
}Spring Data Neo4j Testing
@DataNeo4jTest
@Testcontainers
class UserGraphRepositoryTest {
@Container
@ServiceConnection
static Neo4jContainer<?> neo4j = new Neo4jContainer<>(
DockerImageName.parse("neo4j:5.15")
).withAdminPassword("test-password");
@Autowired
UserGraphRepository repository;
@Autowired
Neo4jTemplate template;
@Test
void shouldSaveUserWithRelationships() {
UserNode alice = new UserNode("alice", "alice@example.com");
UserNode bob = new UserNode("bob", "bob@example.com");
alice.addFriend(bob);
repository.save(alice);
Optional<UserNode> found = repository.findById("alice");
assertTrue(found.isPresent());
assertFalse(found.get().getFriends().isEmpty());
assertEquals("bob", found.get().getFriends().get(0).getId());
}
@Test
void shouldFindUsersByDerivedQuery() {
repository.saveAll(List.of(
new UserNode("user1", "alice@corp.com"),
new UserNode("user2", "bob@corp.com"),
new UserNode("user3", "charlie@personal.com")
));
List<UserNode> corpUsers = repository.findByEmailEndingWith("@corp.com");
assertEquals(2, corpUsers.size());
}
}The Domain Model
@Node("User")
public class UserNode {
@Id
private String id;
private String email;
@Relationship(type = "FRIENDS_WITH", direction = OUTGOING)
private List<UserNode> friends = new ArrayList<>();
// Constructors, getters, setters
}Constraint Testing
@Test
void shouldEnforceUniqueEmailConstraint() {
try (Session session = driver.session()) {
// Create constraint
session.run("CREATE CONSTRAINT user_email_unique IF NOT EXISTS " +
"FOR (u:User) REQUIRE u.email IS UNIQUE");
session.run("CREATE (:User {email: 'alice@example.com', id: '1'})");
// Attempt duplicate - should throw
assertThrows(Neo4jException.class, () ->
session.run("CREATE (:User {email: 'alice@example.com', id: '2'})")
);
// Verify only one user exists
long count = session.run(
"MATCH (u:User {email: 'alice@example.com'}) RETURN count(u) as c"
).single().get("c").asLong();
assertEquals(1L, count);
}
}
@Test
void shouldRequireMandatoryProperties() {
try (Session session = driver.session()) {
// Existence constraint (Neo4j Enterprise or APOC for Community)
session.run("CREATE CONSTRAINT user_id_exists IF NOT EXISTS " +
"FOR (u:User) REQUIRE u.id IS NOT NULL");
assertThrows(Neo4jException.class, () ->
session.run("CREATE (:User {email: 'no-id@example.com'})")
);
}
}Testing with Transaction Rollback
@Test
void shouldRollbackOnFailure() {
try (Session session = driver.session()) {
// Start a transaction
try (Transaction tx = session.beginTransaction()) {
tx.run("CREATE (:User {id: 'temp', email: 'temp@example.com'})");
// Verify node exists within transaction
Result inTx = tx.run("MATCH (u:User {id: 'temp'}) RETURN u");
assertTrue(inTx.hasNext());
tx.rollback(); // Explicitly rollback
}
// Node should not exist after rollback
Result afterRollback = session.run("MATCH (u:User {id: 'temp'}) RETURN u");
assertFalse(afterRollback.hasNext());
}
}Graph Database Testing with HelpMeTest
For applications with graph-powered features—recommendation engines, social graphs, knowledge bases—HelpMeTest validates the user-visible behavior:
*** Test Cases ***
Friend Recommendations Are Relevant
As LoggedInUser
Go To https://app.example.com/discover
Wait Until Element Is Visible [data-testid=recommendations]
Element Count Should Be Greater Than [data-testid=friend-card] 2
Each Recommendation Should Show Mutual Friends CountThis catches issues like graph queries timing out, relationship traversals returning wrong results, or recommendation algorithms surfacing irrelevant users—without needing to understand the underlying Cypher.
Common Mistakes
1. Not clearing the graph between tests Always run MATCH (n) DETACH DELETE n in @BeforeEach. Graph data accumulates quickly and causes test pollution.
2. Asserting on relationship direction incorrectly (a)-[:KNOWS]->(b) and (a)-[:KNOWS]-(b) behave differently. Know which you want and test explicitly.
3. Forgetting that Neo4j is case-sensitive for labels User and user are different labels. Standardize on PascalCase for labels and SCREAMING_SNAKE_CASE for relationship types.
4. Not testing path traversal depth limits Variable-length patterns like [:KNOWS*] can traverse arbitrarily deep. Always test with [:KNOWS*..5] bounds in production queries.
Summary
Neo4j testing requires validating both structure and semantics:
- Embedded instances for fast, isolated unit tests
- Testcontainers for full-featured integration tests with plugins
- Explicit relationship direction testing — don't leave direction to chance
- Path query validation for shortest paths and traversals
- Constraint testing to verify data integrity rules
- Graph algorithm tests for recommendation and scoring features
The graph model is the application's core—test it as carefully as you'd test any business-critical code.