Skip to content

Instantly share code, notes, and snippets.

@richkuz
Created January 28, 2023 19:13
Show Gist options
  • Save richkuz/da16155ffc20076267bd5b2137332060 to your computer and use it in GitHub Desktop.
Save richkuz/da16155ffc20076267bd5b2137332060 to your computer and use it in GitHub Desktop.
Unit test Exalate sync scripts with Groovy and Spock

Unit test Exalate sync scripts with Groovy and Spock

Exalate lets you synchronize GitHub and Jira issues by writing sync scripts in Groovy. You can use Groovy to unit test sync scripts to give you quality assurances before applying changes to your production instance.

This article explains how to use Groovy and the Spock testing framework to test the sync scripts. The tests work by creating a GroovyShell with mocked variables for the Exalate environment. The GroovyShell then loads the sync script and runs it with Jira and GitHub data from the supplied test case.

This project uses the Spock framework for unit testing.

Create a project to hold your groovy sync scripts and test code.

src/GitHub Incoming Sync.groovy

// An issue is coming in from Jira. Update the corresponding GitHub issue with changes.
//
// "replica" means "incoming Jira issue with new changes"
// "issue" means "GitHub issue to update with new changes"

// This is a truncated example sync script; your sync script probably includes a lot more logic than this.

// Sync the summary field
def MAX_JIRA_SUMMARY_LIMIT = 254;
if (replica.summary) //if the inbound Jira issue has a summary
{
    // If Jira issue summary is at the max length, assume that
    // it's been truncated and the existing GH summary is better to keep
    // as the source of truth.
    // Don't update an existing GH summary with a potentially truncated Jira summary.
    if (replica.summary.length() >= 254) {
        // Don't update GH issue summary with the incoming Jira summary
    }
    else { // Incoming Jira issue summary is probably not truncated; update the GH issue.
        issue.summary  = replica.summary;
    }
}

src/Jira Incoming Sync.groovy

// An issue was updated in GitHub. Update Jira to match.
// "replica" is the incoming GitHub issue
// "issue" is the Jira issue to update with changes from GitHub

// This is a truncated example sync script; your sync script probably includes a lot more logic than this.

// Truncate long GitHub summaries in Jira
def MAX_JIRA_SUMMARY_LIMIT = 254;
if (replica.summary) {
    if (replica.summary.length() <= MAX_JIRA_SUMMARY_LIMIT) {
        issue.summary = replica.summary
    }
    else {
        // Truncate the Jira summary and include '...' at the end
        issue.summary  = replica.summary.take(251) + "..."
    }
}

Create a unit test for the GitHub incoming sync script:

src/test/groovy/GitHubIncomingSyncSpec.groovy

import spock.lang.Specification
import spock.lang.Shared
import Utils.*
import static Utils.charStringOfLength;
import spock.lang.*

// Verify that when an issue is updated in Jira ("replica"), the
// GitHubIncomingSync script updates the corresponding GitHub issue ("issue")
// with changes.
class GitHubIncomingSyncSpec extends Specification {

  def "don't update existing GH issue summary if incoming Jira summary is at the max Jira limit"() {
    expect:
    ghIssueOut.summary == charStringOfLength(300)

    where:
    jiraIssueIn = new JiraIssue(
      projectKey: "KBNA",
      summary: charStringOfLength(254) // Jira only supports 254 max length summary
    )
    existingGHIssue = new GitHubIssue(
      summary: charStringOfLength(300)
    )
    ghIssueOut = new Script(
      firstSync: false,
      jiraIssueIn: jiraIssueIn,
      ghIssueOut: existingGHIssue
    ).runScript()
  }

  def "do update GH issue summary if incoming Jira summary is UNDER the max Jira limit"() {
    expect:
    ghIssueOut.summary == "A new summary from Jira."

    where:
    jiraIssueIn = new JiraIssue(
      projectKey: "KBNA",
      summary: "A new summary from Jira."
    )
    existingGHIssue = new GitHubIssue(
      summary: "This is an existing GH summary."
    )
    ghIssueOut = new Script(
      firstSync: false,
      jiraIssueIn: jiraIssueIn,
      ghIssueOut: existingGHIssue
    ).runScript()
  }


  static class Script {
    JiraIssue jiraIssueIn
    // Optionally pre-load with "existing" synced GH issue to modify
    GitHubIssue ghIssueOut = new GitHubIssue()
    Boolean firstSync
    GitHubIssue runScript() {
      def bindings = new Binding()
      // Set local script variables with mocked test values
      bindings.setVariable("firstSync", this.firstSync)
      bindings.setVariable("issue", this.ghIssueOut)
      bindings.setVariable("replica", this.jiraIssueIn)
      bindings.setVariable("nodeHelper", new NodeHelper())
      bindings.setVariable("issueKey", "mockedIssueKey")
      bindings.setVariable("traces", "mockedTraces")
      bindings.setVariable("debug", new Debug())
      def shell = new GroovyShell(bindings)
      def Util = shell.parse(new File("./src/GitHub Incoming Sync.groovy"))
      Util.run()
      return this.ghIssueOut
    }
  }
}

Create a unit test for the Jira incoming sync script.

src/test/groovy/JiraIncomingSyncSpec.groovy

import spock.lang.Specification
import spock.lang.Shared
import spock.lang.*
import static Utils.charStringOfLength;
import Utils.*

// Verify that when an issue is updated in GitHub ("replica"), the
// JiraIncomingSync script updates the corresponding Jira issue ("issue")
// with changes.
class JiraIncomingSyncSpec extends Specification {

  def "always sync summaries less than the max Jira length"() {
    expect:
    jiraIssueOut.summary == ghSummary

    where:
    firstSync | ghRepo          | ghLabel     | ghSummary                          | existingJiraProjectKey
    true      | "elasticsearch" | "pancakes"  | "First sync ES issue summary"      | null
    false     | "elasticsearch" | "pancakes"  | "Second sync ES issue summary"     | "ES"
    true      | "kibana"        | "Team:Core" | "First sync Kibana issue summary"  | null
    false     | "kibana"        | "Team:Core" | "Second sync Kibana issue summary" | "KBNA"

    ghIssueIn = new GitHubIssue(
      repository: ghRepo,
      labels: [new Label(label: ghLabel)],
      summary: ghSummary
    )
    existingJiraIssue = new JiraIssue(
      projectKey: existingJiraProjectKey
    )
    jiraIssueOut = new Script(
      firstSync: firstSync,
      ghIssueIn: ghIssueIn,
      jiraIssueOut: existingJiraIssue
    ).runScript()
  }

  def "truncate Jira issue summary including '...' if GH summary is OVER the max Jira limit"() {
    expect:
    jiraIssueOut.summary == charStringOfLength(MAX_JIRA_SUMMARY_LIMIT - 3) + "..."

    where:
    firstSync | ghRepo
    true      | "elasticsearch"
    false     | "elasticsearch"

    ghIssueIn = new GitHubIssue(
      repository: ghRepo,
      summary: charStringOfLength(MAX_JIRA_SUMMARY_LIMIT + 1)
    )
    existingJiraIssue = new JiraIssue(
      projectKey: "ES"
    )
    jiraIssueOut = new Script(
      firstSync: firstSync,
      ghIssueIn: ghIssueIn,
      jiraIssueOut: existingJiraIssue
    ).runScript()
  }

  def "don't truncate Jira issue summary if GH summary is AT the max Jira limit"() {
    expect:
    jiraIssueOut.summary == charStringOfLength(MAX_JIRA_SUMMARY_LIMIT)

    where:
    firstSync | ghRepo
    true      | "elasticsearch"
    false     | "elasticsearch"

    ghIssueIn = new GitHubIssue(
      repository: ghRepo,
      summary: charStringOfLength(MAX_JIRA_SUMMARY_LIMIT)
    )
    existingJiraIssue = new JiraIssue(
      projectKey: "ES"
    )
    jiraIssueOut = new Script(
      firstSync: firstSync,
      ghIssueIn: ghIssueIn,
      jiraIssueOut: existingJiraIssue
    ).runScript()
  }

  static class Script {
    GitHubIssue ghIssueIn
    // Optionally pre-load with "existing" synced Jira issue to modify
    JiraIssue jiraIssueOut = new JiraIssue()
    Boolean firstSync
    JiraIssue runScript() {
      def bindings = new Binding()
      bindings.setVariable("firstSync", this.firstSync)
      bindings.setVariable("issue", this.jiraIssueOut)
      bindings.setVariable("replica", this.ghIssueIn)
      bindings.setVariable("nodeHelper", new NodeHelper())
      bindings.setVariable("remoteIssueUrl", this.ghIssueIn.getIssueUrl())
      bindings.setVariable("projectUnknownFlag", "ProjKeyUnknown")
      bindings.setVariable("debug", new Debug())
      def shell = new GroovyShell(bindings)
      def Util = shell.parse(new File("./src/Jira Incoming Sync.groovy"))
      Util.run()
      return this.jiraIssueOut
    }
  }

  static String A_LONG_DESCRIPTION = charStringOfLength(32768);
  static MAX_JIRA_SUMMARY_LIMIT = 254; // Jira enforces a max limit of 254 (inclusive)
}

Add some utility classes:

src/test/groovy/Utils.groovy

import groovy.transform.NullCheck

class Utils {
  static String trimStr(str, max) {
    if (str && str.length() > max) {
      return String.format('%1.' + max + 's...TRIMMED...', str);
    }
    else {
      return str;
    }
  }

  @NullCheck
  static class NodeHelper {
    def getLabel(String labelString) {
      return new Label(label: labelString)
    }
    def getPriority(String priority) {
      return new Priority(name: priority)
    }
  }

  @NullCheck
  static class Label {
    String label
    String toString() { return label }
    @Override boolean equals(that) {
      if(that instanceof String) {
        return that.equals(this.label)
      }
      else {
        return that.label.equals(this.label)
      }
    }
  }

  @NullCheck
  static class Status {
    String name
    String toString() { return name }
  }

  @NullCheck
  static class Priority {
    String name
    String toString() { return name }
  }

  static class CustomField {
    Object value
    String toString() { return value }
  }

  static class JiraIssue {
    String projectKey = "KS"
    String summary = "summary1"
    String description = "description1"
    Status status = new Status(name: "Done")
    def setStatus(String name) {
      this.status = new Status(name: name)
    }
    Priority priority = new Priority(name: "Highest")
    int originalEstimate = 3600*8 // "loe:hours"
    String typeName = "Bug"
    Map customFields = [:].withDefault { key ->
      new CustomField()
    }
    String toString() {
      return "{ projectKey: $projectKey, summary: $summary, " +
        "description: ${trimStr(this.description, 30)}, " +
        "status: $status, priority: $priority, originalEstimate: $originalEstimate, " +
        "typeName: $typeName, customFields: $customFields }"
    }
  }

  static class GitHubIssue {
    String repository // e.g. "kibana"
    String summary // issue title
    String description
    Status status = new Status(name: "Open")
    String key // GH Issue ID, e.g. "1234"
    def getIssueUrl() {
      return "https://github.com/elastic/${this.repository}/issues/${this.key}"
    }
    def setStatus(String name) {
      this.status = new Status(name: name)
    }
    ArrayList<Label> labels = []
    String toString() {
      return "{ repository: $repository, summary: $summary, " +
        "description: ${trimStr(this.description, 30)}, status: $status, labels: $labels, key: $key }"
    }
    // originalEstimate exists only on the replica; on the Hub issue it's just a label e.g. "loe:hours"
    int originalEstimate
  }

  static class Debug {
    def error(String err) {
      // no-op, mock Exalate's error printing method
    }
  }

  static String charStringOfLength(int len) {
    return String.format('%1$' + String.valueOf(len) + 's', ""); // String of len chars long
  }
}

In your groovy project, create a build.gradle file and add these dependencies:

apply plugin: "groovy"

version = "1.0"
description = "Spock Framework - Example Project"

// Spock works with Java 1.8 and above
sourceCompatibility = 1.8

repositories {
  // Spock releases are available from Maven Central
  mavenCentral()
  // Spock snapshots are available from the Sonatype OSS snapshot repository
  maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
}

dependencies {
  // mandatory dependencies for using Spock
  implementation 'org.codehaus.groovy:groovy:3.0.10'
  testImplementation platform("org.spockframework:spock-bom:2.1-groovy-3.0")
  testImplementation "org.spockframework:spock-core"
  testImplementation "org.spockframework:spock-junit4"  // you can remove this if your code does not rely on old JUnit 4 rules

  // optional dependencies for using Spock
  testImplementation "org.hamcrest:hamcrest-core:2.2"   // only necessary if Hamcrest matchers are used
  testRuntimeOnly 'net.bytebuddy:byte-buddy:1.12.8' // allows mocking of classes (in addition to interfaces)
  testRuntimeOnly "org.objenesis:objenesis:3.2"      // allows mocking of classes without default constructor (together with ByteBuddy or CGLIB)

  // dependencies used by examples in this project
  testRuntimeOnly "com.h2database:h2:2.1.210"
  implementation "org.codehaus.groovy:groovy-sql:3.0.9"
}

test {
  useJUnitPlatform()
  testLogging {
    exceptionFormat "full"
    events "passed", "skipped", "failed"
    showStandardStreams = true
  }
}

Run the tests using ./gradlew clean test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment