JenkinsPipelineUnit

repository·master·Indexed 23 days ago

https://github.com/jenkinsci/jenkinspipelineunit

A testing framework for unit testing Jenkins pipelines written in Groovy Pipeline DSL. It provides a mock execution environment to test pipeline configuration and conditional logic, supporting both standard and Declarative pipelines. Key features include mocking Jenkins variables, parameters, environment variables, shell steps, and file system interactions, as well as support for testing Jenkins Shared Libraries and parallel test execution. Requires Java 21 and is currently incompatible with Groovy 4.

Tokens
5.9K
Snippets
17
Records
18
Agent score
32%

What's inside JenkinsPipelineUnit

  1. Use ProjectSource and LocalSource retrievers

    master

    When testing shared libraries, you can use different SourceRetriever implementations depending on your goal:

    1. ProjectSource: Best for testing the library itself. It loads files directly from the project root (where src and vars folders reside). Use projectSource() with no arguments to point to the current project root.
    2. LocalSource: Best for verifying integration with existing pipelines. It allows you to use pre-copied library files from a specific directory. The retriever assumes files are located at {path}/{libraryName}@{version}.

    Example of projectSource usage:

    import static com.lesfurets.jenkins.unit.global.lib.ProjectSource.projectSource
    
    // ... inside setUp ...
    Object library = library()
        .name('commons')
        .defaultVersion('<notNeeded>')
        .allowOverride(true)
        .implicit(true)
        .targetPath('<notNeeded>')
        .retriever(projectSource())
        .build()
    helper.registerSharedLibrary(library)
    import static com.lesfurets.jenkins.unit.global.lib.ProjectSource.projectSource
    
    class TestCase extends BasePipelineTest {
        @Override
        @BeforeEach
        void setUp() {
            super.setUp()
            Object library = library()
                .name('commons')
                .defaultVersion('<notNeeded>')
                .allowOverride(true)
                .implicit(true)
                .targetPath('<notNeeded>')
                .retriever(projectSource())
                .build()
            helper.registerSharedLibrary(library)
        }
    }
  2. Best practices for writing testable Pipeline Libraries

    master

    To make your Jenkins pipelines and shared libraries easier to test with JenkinsPipelineUnit, follow these architectural patterns:

    1. Minimize Jenkinsfile complexity: Move complex logic into external scripts or shared libraries. Avoid heavy Groovy logic directly in the Jenkinsfile.
    2. Organize logic in classes: In shared libraries, place the bulk of your logic in classes under the src directory. Use the vars singletons only as thin wrappers to instantiate these classes.
    3. Inject the script context: When writing library classes, pass the Jenkins script context (the this object from the pipeline) into the class constructor. This allows your classes to call pipeline steps like echo while remaining testable.
    4. Test classes directly: Instead of testing the entire pipeline, use JenkinsPipelineUnit to test the logic within your src classes by providing a mock script context.
    // src/com/example/HardMath.groovy
    package com.example
    
    class HardMath implements Serializable {
      Object script = null
    
      int complexOperation(int a, int b) {
        script.echo "Adding ${a} to ${b}"
        return a + b
      }
    }
    
    // vars/hardmath.groovy
    import com.example.HardMath
    
    int complexOperation(int a, int b) {
      return new HardMath(script: this).complexOperation(a, b)
    }
  3. Simulate Jenkins CPS (Continuation Passing Style) in tests

    master

    Jenkins pipelines run using Continuation Passing Style (CPS) to allow jobs to be paused and resumed, which requires the script context to be serializable. To simulate this behavior and catch potential serialization errors or sandboxing issues in your tests, use the BasePipelineTestCPS abstract class instead of the standard BasePipelineTest.

    Note: This is an experimental feature. The serialization used for testing may differ slightly from the actual serialization used by Jenkins, which might lead to some inconsistencies. You may also notice changes in the callstacks registered by the helper.

  4. Start writing Jenkins Pipeline tests

    master

    The easiest way to start testing is by extending the BasePipelineTest abstract class. This class initializes the framework with JUnit and provides a helper instance for mocking and verification.

    Always call super.setUp() within your @BeforeEach method to ensure the framework is properly initialized before using any features.

    import com.lesfurets.jenkins.unit.BasePipelineTest
    
    class TestExampleJob extends BasePipelineTest {
        @Override
        @BeforeEach
        void setUp() {
            super.setUp()
            // Setup mocks or variables here
        }
    
        @Test
        void shouldExecuteWithoutErrors() {
            loadScript('job/exampleJob.jenkins').execute()
            printCallStack()
        }
    }
  5. Load shared libraries dynamically in tests

    master

    If your pipeline uses the library 'name' step to load libraries dynamically at runtime, you must register a custom handler for the library method in your test setup. This is done by registering an allowed method with the helper that uses helper.getLibLoader().loadLibrary(name).

    Note: This registration must happen after registering the shared library and cannot be moved to a superclass if it relies on the specific helper instance.

    @Test
    void testDynamicLibrary() {
        Object library = library()
            .name('commons')
            .retriever(gitSource('git@example.com:libs/commons.git'))
            .targetPath('path/to/clone')
            .defaultVersion('master')
            .allowOverride(true)
            .implicit(false)
            .build()
        helper.registerSharedLibrary(library)
    
        // Register the 'library' method to handle dynamic loading
        helper.registerAllowedMethod('library', [String], { String name ->
            helper.getLibLoader().loadLibrary(name)
            return new LibClassLoader(helper, null)
        })
    
        loadScript('job/library/exampleJob.jenkins')
        printCallStack()
    }
  6. Test pipelines that use Shared Libraries

    master

    To test pipelines that depend on Jenkins Shared Libraries, use the library() fluent API to define and register the library with the test helper. This allows the framework to fetch library sources (via gitSource or localSource) and load the scripts, classes, and global variables into the test environment.

    Key configuration options for library():

    • name(String): The name of the library (e.g., 'commons').
    • retriever(SourceRetriever): Defines how to fetch the library. Common implementations are gitSource(String) and localSource(String).
    • targetPath(String): The local path where the library should be cloned/placed.
    • defaultVersion(String): The version to use (defaults to master).
    • allowOverride(boolean): Whether to allow overriding (defaults to true).
    • implicit(boolean): Whether the library is implicit (defaults to false).

    After building the library object, register it using helper.registerSharedLibrary(library).

    import static com.lesfurets.jenkins.unit.global.lib.LibraryConfiguration.library
    import static com.lesfurets.jenkins.unit.global.lib.SourceRetriever.gitSource
    
    class TestCase extends BasePipelineTest {
        @Test
        void testLibrary() {
            Object library = library()
                .name('commons')
                .retriever(gitSource('git@example.com:libs/commons.git'))
                .targetPath('path/to/clone')
                .defaultVersion("master")
                .allowOverride(true)
                .implicit(false)
                .build()
            helper.registerSharedLibrary(library)
    
            runScript('job/library/exampleJob.jenkins')
            printCallStack()
        }
    }
  7. Add JenkinsPipelineUnit to your project

    master

    JenkinsPipelineUnit requires Java 21 and is currently not compatible with Groovy 4.

    Starting from version 1.2, artifacts are published to https://repo.jenkins-ci.org/releases.

    #### Maven
    
    ```xml
    <repositories>
        <repository>
        <id>jenkins-ci-releases</id>
        <url>https://repo.jenkins-ci.org/releases/</url>
        </repository>
        ...
    </repositories>
    
    <dependencies>
        <dependency>
            <groupId>com.lesfurets</groupId>
            <artifactId>jenkins-pipeline-unit</artifactId>
            <version>1.9</version>
            <scope>test</scope>
        </dependency>
        ...
    </dependencies>

    Gradle

    repositories {
        maven { url 'https://repo.jenkins-ci.org/releases/' }
        ...
    }
    
    dependencies {
        testImplementation "com.lesfurets:jenkins-pipeline-unit:1.9"
        ...
    }
  8. Test Declarative Pipelines using DeclarativePipelineTest

    master

    To test a Jenkins Declarative Pipeline (e.g., a Jenkinsfile), you must subclass DeclarativePipelineTest instead of BasePipelineTest.

    Because DeclarativePipelineTest extends BasePipelineTest, you can use all the standard verification methods (like assertJobStatusSuccess()) to validate your declarative jobs.

    import com.lesfurets.jenkins.unit.declarative.*
    
    class TestExampleDeclarativeJob extends DeclarativePipelineTest {
        @Test
        void shouldExecuteWithoutErrors() {
            runScript("Jenkinsfile")
    
            assertJobStatusSuccess()
            printCallStack()
        }
    }
  9. Run JenkinsPipelineUnit tests in parallel

    master

    JenkinsPipelineUnit is thread-safe and supports parallel execution within a single JVM. Each test must use its own PipelineTestHelper instance (which is the default behavior when subclassing BasePipelineTest or DeclarativePipelineTest without sharing a helper) to ensure isolation of script classes, shared libraries, mocks, and the call stack.

    To enable parallel execution with JUnit 5/6, create a junit-platform.properties file in your test resources with the following configuration:

    Important Caveats:

    • One helper per test: Do not share a single PipelineTestHelper (e.g., via a static field) across concurrent tests.
    • Test code thread-safety: The framework does not protect against thread-safety issues in your own test code, such as static Mockito mocks, static maps, or metaClass mutations on global objects.
    • Avoid @Grab in pipeline/library code: Groovy's @Grab annotation uses a non-thread-safe Ivy instance. Concurrent compilation of @Grab-annotated classes can cause resolution failures. Instead, pre-resolve these dependencies on your test classpath.
    junit.jupiter.execution.parallel.enabled = true
    junit.jupiter.execution.parallel.mode.default = concurrent
    junit.jupiter.execution.parallel.mode.classes.default = concurrent
    junit.jupiter.execution.parallel.config.strategy = dynamic
  10. Configure pipeline script locations and extensions

    master

    The BasePipelineTest class provides configuration properties to define where your Jenkins pipeline scripts are located and what file extension they use. By default, the helper looks in the project root (./) and src/main/jenkins for scripts with a .jenkins extension.

    To override these defaults, override the setUp() method in your test class and set the following properties before calling super.setUp():

    • baseScriptRoot: The root directory where pipeline scripts are located (defaults to .).
    • scriptRoots: A list of additional directories to search for scripts (use += to append).
    • scriptExtension: The file extension to match (defaults to jenkins).
    class TestExampleJob extends BasePipelineTest {
        @Override
        @BeforeEach
        void setUp() {
            baseScriptRoot = 'jenkinsJobs'
            scriptRoots += 'src/main/groovy'
            scriptExtension = 'pipeline'
            super.setUp()
        }
    }
  11. Fix MissingMethodException when using Library Global Variables

    master

    If your library defines global variables that take library class instances as arguments (e.g., monster(vampire) where vampire is a class from the library), you might encounter a MissingMethodException. This happens because of class type mismatches during preloading.

    To resolve this, disable library class preloading in your test setup:

    helper.libLoader.preloadLibraryClasses = false

    Warning: Use this sparingly, as disabling preloading can cause issues in other test cases, such as library classes that require access to the env global.

  12. Mock Jenkins commands and pipeline steps

    master

    To mock pipeline methods or Jenkins commands, use helper.registerAllowedMethod. You must provide a method signature (as an array of types) and a callback (closure or lambda). Any method call not explicitly registered will throw an exception.

    Note: If you need to override complex methods like load or parallel, you should extend PipelineTestHelper instead of BasePipelineTest.

    import com.lesfurets.jenkins.unit.BasePipelineTest
    
    class TestExampleJob extends BasePipelineTest {
        @Override
        @BeforeEach
        void setUp() {
            super.setUp()
            helper.registerAllowedMethod('sh', [Map]) { args -> return 'bcc19744' }
            helper.registerAllowedMethod('timeout', [Map, Closure], null)
            helper.registerAllowedMethod('timestamps', []) { println 'Printing timestamp' }
            helper.registerAllowedMethod('myMethod', [String, int]) { String s, int i ->
                println "Executing myMethod mock with args: '${s}', '${i}'"
            }
        }
    }