Neo4j Testing Guide: Embedded Instances, Testcontainers, Cypher Query Testing & Relationship Assertions

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 Count

This 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.

Read more