Search code examples
neo4jjunit5junit5-extension-model

Is there a Neo4j test harness that uses the JUnit 5 Extension Model?


In writing test cases for Neo4j I would like to move onto using just the JUnit 5 Extension Model and not use org.junit.vintage or junit-jupiter-migrationsupport. Currently I can only find the Neo4j test-harness for JUnit 4 which uses TestRule and is dependent on org.junit.vintage and junit-jupiter-migrationsupport.

Is there a Neo4j test harness for JUnit 5 that uses the Extension Model?

References:
Neo4j: Home, GitHub
Neo4j test-harness: Maven, GitHub, pom.xml
JUnit 4: GitHub
JUnit 4 TestRule: JUnit 4 Guide, JUnit 4.12 API, Neo4jRule GitHub
JUnit 5: GitHub
JUnit 5 Extension Model: JUnit 5 User Guide, GitHub
JUnit 5 org.junit.vintage: JUnit 5 User Guide, Test-harness pom.xml
JUnit 5 junit-jupiter-migrationsupport: JUnit 5 User Guide, Test-harness pom.xml


I know it is possible to use JUnit 4 and JUnit 5 in a mixed environment, e.g. Mixing JUnit 4 and JUnit 5 tests.

I have started to write my own Neo4j JUnit 5 extensions with the help of A Guide to JUnit 5 Extensions but if a standard Neo4j test harness with the JUnit 5 Extension Model already exist why create my own.

It may be that I am just querying with the wrong keywords which are simply neo4j and JUnit 5 but the same results keep turning up, none of which lead to what I seek.

Checked the JUnit Jupiter Extensions and found none for Neo4j.

EDIT

Proof of concept

Since the code below is only proof of concept it is not posted as the accepted answer, but hopefully will be in a matter of days.

Turns out that adding JUnit 5 Jupiter Extensions to an existing JUnit TestRlue is not all that bad. There were a few rough spots along the way, and if you are like me and don't live and breath a single programming language or set of tools you have to take some time to understand the ethos; that should be an SO tag if you ask me.

Note: This code is a combination of some code from the Neo4j TestRule and A Guide to JUnit 5 Extensions

Starting with Neo4j TestRule just change the implements:
Remove TestRule
Add BeforeEachCallback and AfterEachCallback

Note: BeforeEach and AfterEach are used instead of BeforeAll and AfterAll with Neo4j because with each new test when creating nodes, if a new node is created the same as the previous test and the database is not a new database then checking the id of the node will be different because a new node is created for each test and gets a different id. So to avoid this problem and doing it the same way it is done with the Neo4j TestRule, a new database is created for each test instance. I did look into resetting the the database between test but it appears that the only way to do this is to delete all of the files that make up the database. :(

/*
 * Copyright (c) 2002-2018 "Neo4j,"
 * Neo4j Sweden AB [http://neo4j.com]
 *
 * This file is part of Neo4j.
 *
 * Neo4j is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
//package org.neo4j.harness.junit;
package org.egt.neo4j.harness.example_002.junit;

// References:
// GitHub - junit-team - junit5 - junit5/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine - https://github.com/junit-team/junit5/tree/releases/5.3.x/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension

// Notes:
// With JUnit 4 TestRule there was basically one rule that was called at multiple points and for multiple needs.
// With JUnit 5 Extensions the calls are specific to a lifecycle step, e.g. BeforeAll, AfterEach,
// or specific to a need, e.g. Exception handling, maintaining state across test,
// so in JUnit 4 where a single TestRule could be created in JUnit5 many Extensions need to be created.
// Another major change is that with JUnit 4 a rule would wrap around a test which would make
// implementing a try/catch easy, with JUnit 5 the process is broken down into a before and after callbacks
// that make this harder, however because the extensions can be combined for any test,
// adding the ability to handle exceptions does not require adding the code to every extension,
// but merely adding the extension to the test. (Verify this).

import java.io.File;
import java.io.PrintStream;
import java.util.function.Function;

import org.junit.jupiter.api.extension.*;
import org.neo4j.graphdb.GraphDatabaseService;
import org.neo4j.graphdb.config.Setting;

import org.egt.neo4j.harness.example_002.ServerControls;
import org.egt.neo4j.harness.example_002.TestServerBuilder;
import org.egt.neo4j.harness.example_002.TestServerBuilders;

/**
 * A convenience wrapper around {@link org.neo4j.harness.TestServerBuilder}, exposing it as a JUnit
 * {@link org.junit.Rule rule}.
 *
 * Note that it will try to start the web server on the standard 7474 port, but if that is not available
 * (typically because you already have an instance of Neo4j running) it will try other ports. Therefore it is necessary
 * for the test code to use {@link #httpURI()} and then {@link java.net.URI#resolve(String)} to create the URIs to be invoked.
 */
//public class Neo4jRule implements TestRule, TestServerBuilder
public class Neo4jDatabaseSetupExtension implements  BeforeEachCallback, AfterEachCallback, TestServerBuilder
{
    private TestServerBuilder builder;
    private ServerControls controls;
    private PrintStream dumpLogsOnFailureTarget;

    Neo4jDatabaseSetupExtension(TestServerBuilder builder )
    {
        this.builder = builder;
    }

    public Neo4jDatabaseSetupExtension( )
    {
        this( TestServerBuilders.newInProcessBuilder() );
    }

    public Neo4jDatabaseSetupExtension(File workingDirectory )
    {
        this( TestServerBuilders.newInProcessBuilder( workingDirectory ) );
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {

        if (controls != null)
        {
            controls.close();
        }
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        controls = builder.newServer();
    }

    @Override
    public ServerControls newServer() {
        throw new UnsupportedOperationException( "The server cannot be manually started via this class, it must be used as a JUnit 5 Extension." );
    }

    @Override
    public TestServerBuilder withConfig(Setting<?> key, String value) {
        builder = builder.withConfig( key, value );
        return this;
    }

    @Override
    public TestServerBuilder withConfig(String key, String value) {
        builder = builder.withConfig( key, value );
        return this;
    }

    @Override
    public TestServerBuilder withExtension(String mountPath, Class<?> extension) {
        builder = builder.withExtension( mountPath, extension );
        return this;
    }

    @Override
    public TestServerBuilder withExtension(String mountPath, String packageName) {
        builder = builder.withExtension( mountPath, packageName );
        return this;
    }

    @Override
    public TestServerBuilder withFixture(File cypherFileOrDirectory) {
        builder = builder.withFixture( cypherFileOrDirectory );
        return this;
    }

    @Override
    public TestServerBuilder withFixture(String fixtureStatement) {
        builder = builder.withFixture( fixtureStatement );
        return this;
    }

    @Override
    public TestServerBuilder withFixture(Function<GraphDatabaseService, Void> fixtureFunction) {
        builder = builder.withFixture( fixtureFunction );
        return this;
    }

    @Override
    public TestServerBuilder copyFrom(File sourceDirectory) {
        builder = builder.copyFrom( sourceDirectory );
        return this;
    }

    @Override
    public TestServerBuilder withProcedure(Class<?> procedureClass) {
        builder = builder.withProcedure( procedureClass );
        return this;
    }

    @Override
    public TestServerBuilder withFunction(Class<?> functionClass) {
        builder = builder.withFunction( functionClass );
        return this;
    }

    @Override
    public TestServerBuilder withAggregationFunction(Class<?> functionClass) {
        builder = builder.withAggregationFunction( functionClass );
        return this;
    }
}

Next, to allow each test instance to have a new GraphDatabaseService which is created with ServerControls implement a JUnit 5 ParameterResolver.

package org.egt.neo4j.harness.example_002.junit;

import org.egt.neo4j.harness.example_002.ServerControls;
import org.egt.neo4j.harness.example_002.TestServerBuilders;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;

public class Neo4jDatabaseParameterResolver implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        boolean result = parameterContext.getParameter()
                .getType()
                .equals(ServerControls.class);

        return result;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {

        Object result = (ServerControls)TestServerBuilders.newInProcessBuilder().newServer();

        return result;
    }
}

Finally all that is left is to use the Neo4j JUnit 5 Extension Model with @ExtendWith and @Test:

package org.egt.example_002;

import org.egt.neo4j.harness.example_002.ServerControls;
import org.egt.neo4j.harness.example_002.junit.Neo4jDatabaseParameterResolver;
import org.egt.neo4j.harness.example_002.junit.Neo4jDatabaseSetupExtension;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import org.neo4j.graphdb.GraphDatabaseService;
import org.neo4j.graphdb.Node;
import org.neo4j.graphdb.Transaction;

import static org.junit.jupiter.api.Assertions.assertEquals;

@ExtendWith({ Neo4jDatabaseSetupExtension.class, Neo4jDatabaseParameterResolver.class })
public class Neo4jUnitTests {

    private ServerControls sc;
    private GraphDatabaseService graphDb;

    public Neo4jUnitTests(ServerControls sc) {
        this.sc = sc;
        this.graphDb = sc.graph();
    }

    @Test
    public void shouldCreateNode()
    {
        // START SNIPPET: unitTest
        Node n;
        try ( Transaction tx = graphDb.beginTx() )
        {
            n = graphDb.createNode();
            n.setProperty( "name", "Nancy" );
            tx.success();
        }

        long id = n.getId();
        // The node should have a valid id
        assertEquals(0L, n.getId());

        // Retrieve a node by using the id of the created node. The id's and
        // property should match.
        try ( Transaction tx = graphDb.beginTx() )
        {
            Node foundNode = graphDb.getNodeById( n.getId() );
            assertEquals( foundNode.getId(),  n.getId() );
            assertEquals( "Nancy" , (String)foundNode.getProperty("name") );
        }
        // END SNIPPET: unitTest

    }
}

One import thing I learned along the way in doing this is that TestRule code seems to be a do everything in one class while the new Extension Model uses many extensions to do the same thing. Thus the logging, exception handling and other things the Neo4j TestRule have are not in this proof of concept. However because the Extension Model allows you to mix and match extensions, adding the logging and exception handling can be as easy as using an extension from another place and just adding the @ExtendWith which is why I haven't created them for this proof of concept.

Also you will have noticed that I change the package names which I did only to avoid clashes with other code in the same project that implement other parts of the code in a stand alone fashion so I could walk my way up to this working proof of concept.

Lastly, I would not be surprised if the JUnit 4 Neo4j TestRule class and a JUnit 5 Extension Model class could both inherit from a base class and then be made available in same test-harness; fingers crossed. Obviously most of the base class would be extracted from the Neo4j TestRule class.


Solution

  • The easiest way is probably not using the extension at all.

    Use the following dependencies for Neo4j 4.x:

    <dependency>
        <groupId>org.neo4j.test</groupId>
        <artifactId>neo4j-harness</artifactId>
        <version>4.0.8</version>
        <scope>test</scope>
    </dependency>
    

    And then structure your JUnit 5 test like this:

    import org.junit.jupiter.api.AfterAll;
    import org.junit.jupiter.api.Assertions;
    import org.junit.jupiter.api.BeforeAll;
    import org.junit.jupiter.api.Test;
    import org.neo4j.harness.Neo4j;
    import org.neo4j.harness.Neo4jBuilders;
    
    public class SimpleTest {
    
        private static Neo4j embeddedDatabaseServer;
    
        @BeforeAll
        static void initializeNeo4j() {
    
            embeddedDatabaseServer = Neo4jBuilders.newInProcessBuilder()
                .withDisabledServer() // Don't need Neos HTTP server
                .withFixture(""
                    + "CREATE (TheMatrix:Movie {title:'The Matrix', released:1999, tagline:'Welcome to the Real World'})"
                )
                .build();
        }
    
        @AfterAll
        static void stopNeo4j() {
    
            embeddedDatabaseServer.close();
        }
    
        @Test
        void testSomething() {
    
            try(var tx = embeddedDatabaseServer.databaseManagementService().database("neo4j").beginTx()) {
                var result = tx.execute("MATCH (m:Movie) WHERE m.title = 'The Matrix' RETURN m.released");
                Assertions.assertEquals(1999L, result.next().get("m.released"));
            }
        }
    }
    

    Of course you can alternatively open up a bolt URL to the embedded instance. embeddedDatabaseServer.boltURI() gives you a local socket address. Authentication is turned off.

    The test would look like this:

    @Test
    void testSomethingOverBolt() {
    
        try(var driver = GraphDatabase.driver(embeddedDatabaseServer.boltURI(), AuthTokens.none());
        var session = driver.session()) {
            var result = session.run("MATCH (m:Movie) WHERE m.title = 'The Matrix' RETURN m.released");
            Assertions.assertEquals(1999L, result.next().get("m.released").asLong());
        }
    }
    

    Of course you would need org.neo4j.driver:neo4j-java-driver for that.

    In case need a non-static instance of the embedded server, you could model the whole test class like this:

    import org.junit.jupiter.api.AfterAll;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.TestInstance;
    import org.neo4j.harness.Neo4j;
    import org.neo4j.harness.Neo4jBuilders;
    
    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
    public class SimpleTest {
    
        private final Neo4j embeddedDatabaseServer = Neo4jBuilders.newInProcessBuilder()
                .withDisabledServer() // Don't need Neos HTTP server
            .withFixture(""
                + "CREATE (TheMatrix:Movie {title:'The Matrix', released:1999, tagline:'Welcome to the Real World'})"
            )
            .build();
    
        @AfterAll
        void stopNeo4j() {
    
            embeddedDatabaseServer.close();
        }
    
        @Test
        void whatever() {
        }
    }
    

    Notice the @TestInstance(TestInstance.Lifecycle.PER_CLASS) on top of the test class and the non-static @AfterAll method.