Icon for gfsd IntelliJ IDEA

Isolating IntelliJ plugin tests using temporary project directories

Symbolic header image that depicts a component "plugging into" IntelliJ with a plug

When testing a plugin for IntelliJ-based IDEs using a UI test, the plugin gets to operate within a fully-featured instance of the IDE. Most tests will therefore require a project to perform their actions on. This opens a question: How do you deal with modifications to test projects made by tests? Add undo logic at the end of every test? Revert using a version control system? Those options sound like easy ways to make new mistakes.

We faced the same problem when writing tests for our own plugin, Symflower for IntelliJ IDEA and GoLand, but we’ve opted for a simpler solution. We don’t run tests on the canonical source of our test projects but rather copy the entire project directory and use that copy instead. This avoids relying on repetitive cleanup logic or VCS-based reverting that could undo legitimate changes to the source files.

Setting up temporary project directories to isolate IntelliJ plugin tests

We’ve written a class which encapsulates implementation details such as the base directory for temporary project copies and handles translation of relative paths.

package com.symflower.testing.ui;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.commons.io.FileUtils;

/** TestProject is a utility class to provide a temporary project copy and a cleanup method to be used after a test run. */
public class TestProject {
    private File projectPath;

    public TestProject(String name) {
        var sourcePath = "test-projects/" + name; // TODO Replace with your own path for test projects.
        // Copy the source path to a temporary directory where all the tests are run.
        try {
            projectPath = Files.createTempDirectory(Path.of("tmp-project-copies"), name).toFile(); // TODO Replace with your own base directory for temporary copies of projects.
            projectPath.deleteOnExit();
            FileUtils.copyDirectory(new File(sourcePath), projectPath);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /** getProjectName returns the project's name. */
    public String getProjectName() {
        return projectPath.getName();
    }

    /** getProjectPath returns the path to the project's temporary directory. */
    public String getProjectPath() {
        return projectPath.getPath();
    }

    /** getFilePath returns the given relative file path prefixed with the project's directory path. */
    public String getFilePath(String relativeFilePath) {
        return Path.of(projectPath.getPath(), relativeFilePath).toString();
    }

    /** cleanup deletes the temporary directory of the project. */
    public void cleanup() {
        try {
            FileUtils.deleteDirectory(projectPath);
        } catch (IOException e) {}
    }
}

The following example shows you how you can use the class in your test code:

@Test
void testSomething() {
    // REMARK "editor" and "system" are helpers defined in the test suite.
    var project = new TestProject("test-plain");
    editor.openProject(project.getProjectPath());
    system.assertFileExists(project.getFilePath("plain.java"));
}

After the test, call the cleanup method on instances of TestProject to get rid of the temporary project directory. We’ve automated that further by defining a base class for all of our UI tests that lets tests provide the instance of TestProject that represents the currently active project and cleans up the project when the test exits regardless of its outcome.

package com.symflower.testing.ui;

import static com.intellij.remoterobot.stepsProcessing.StepWorkerKt.step;

import com.intellij.remoterobot.RemoteRobot;
import com.intellij.remoterobot.utils.Keyboard;
import com.symflower.testing.ui.helpers.EditorHelper;
import com.symflower.testing.ui.helpers.SymflowerHelper;
import com.symflower.testing.ui.helpers.SystemHelper;
import com.symflower.testing.ui.utils.RemoteRobotExtension;
import com.symflower.testing.ui.utils.StepsLogger;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.extension.ExtendWith;

/** BaseUITest is a base class for UI test suites that operate on a project. */
@ExtendWith(RemoteRobotExtension.class)
public abstract class BaseUITest {
   // Provide basic tools to interact with the testing instance.
   protected final RemoteRobot remoteRobot = new RemoteRobot("http://127.0.0.1:8082");
   protected final Keyboard keyboard = new Keyboard(remoteRobot);

   // Provide higher-level helpers.
   protected final EditorHelper editor = new EditorHelper(remoteRobot);
   protected final SymflowerHelper symflower = new SymflowerHelper(remoteRobot);
   protected final SystemHelper system = new SystemHelper();

   /** cleanup is a function which is run after each test to reset settings and perform special cleanups which is also called if a test failed. */
   protected Runnable cleanup;

   /** project holds the "TestProject" instance for a test. */
   protected TestProject project;

   @BeforeAll
   public static void testSetup() throws IOException {
       StepsLogger.init();
   }

   @AfterEach
   public void cleanUp(final RemoteRobot remoteRobot) {
       // The try/finally block ensures that project-level cleanups always run even if the test-level cleanup function throws.
       try {
           step("Run cleanup task", () -> {
               if (cleanup != null) {
                   cleanup.run();
               }
           });
       } finally {
           cleanup = null; // Reset the cleanup function to avoid leaking in tests which do not define one.

           editor.closeProject();

           if (project != null) {
               project.cleanup();
               project = null;
           }
       }
   }
}

Feel free to use these in your own projects using the MIT license. If you need help writing your tests, try the Symflower plugin and if you want to stay up-to-date on the latest software testing trends, subscribe to our newsletter.

Technical | 2023-01-09