This guide explains how to write, run, and debug tests for JiraPS.
JiraPS uses Pester 6.2.0 through 6.x for unit testing. All test files follow one of two standardized templates based on the function type being tested.
Tests are organized to mirror the module structure:
-
Tests/Functions/Public/- Tests for public (exported) functions that make API calls- CRUD pattern for Get/Set/Remove/New/Add functions
- See
Public/README.mdfor details - Example:
Add-JiraFilterPermission.Unit.Tests.ps1
-
Tests/Functions/Private/- Tests for private (internal) converter/formatter functions- Converter pattern for ConvertTo-/ConvertFrom- functions
- See
Private/README.mdfor details - Example:
ConvertTo-JiraAttachment.Unit.Tests.ps1
- BeforeDiscovery: Loads the module before test discovery. Variables need
$script:prefix. - InModuleScope: Required wrapper to access module internals. Must be top-level.
- Describe Blocks: Organize tests by category (Signature, Behavior, Input Validation).
- Context Blocks: Group related tests within a Describe block.
- BeforeAll: Set up test data and mocks. Variables need
$script:prefix.
JiraPS uses two primary test templates based on function type:
Location: Tests/Functions/Public/
Template File: Tests/Functions/Public/.template.ps1
Reference File: Tests/Functions/Public/Add-JiraFilterPermission.Unit.Tests.ps1
Use For: Functions that make API calls - Get-, Set-, Remove-, New-, Add-* functions
Structure:
#requires -modules @{ ModuleName = "Pester"; ModuleVersion = "6.2.0"; MaximumVersion = "6.999" }
BeforeDiscovery {
. "$PSScriptRoot/../../Helpers/TestTools.ps1"
$script:moduleToTest = Initialize-TestEnvironment
}
InModuleScope JiraPS {
Describe "Add-JiraFilterPermission" -Tag 'Unit' {
BeforeAll {
. "$PSScriptRoot/../../Helpers/TestTools.ps1"
# $VerbosePreference = 'Continue' # Uncomment for mock debugging
#region Definitions
$script:jiraServer = "https://jira.example.com"
# Test data and variables
#endregion
#region Mocks
Mock Get-JiraConfigServer -ModuleName JiraPS {
Write-MockDebugInfo 'Get-JiraConfigServer'
$jiraServer
}
Mock Invoke-JiraMethod -ModuleName JiraPS {
Write-MockDebugInfo 'Invoke-JiraMethod' 'Method', 'Uri', 'Body'
# Mock implementation
}
#endregion
}
Describe "Signature" {
BeforeAll {
$script:command = Get-Command -Name $ThisTest
}
Context "Parameter Types" {
It "has a parameter '<parameter>' of type '<type>'" -TestCases @(...) {
$command | Should -HaveParameter $parameter
}
}
Context "Mandatory Parameters" {
It "parameter '<parameter>' is mandatory" -TestCases @(...) {
$command | Should -HaveParameter $parameter -Mandatory
}
}
}
Describe "Behavior" {
Context "Feature Description" {
It "performs expected action" {
# Test implementation
}
}
}
Describe "Input Validation" {
Context "Type Validation - Negative Cases" {
It "rejects invalid type '<description>'" -TestCases @(...) {
{ ... } | Should -Throw -ExpectedMessage "*Filter*"
}
}
Context "Type Validation - Positive Cases" {
It "accepts valid input" {
{ ... } | Should -Not -Throw
}
}
}
}
}Key Features:
- Mock API calls with
Write-MockDebugInfofor debugging - Separate Describe blocks for Signature, Behavior, and Input Validation
- Positive/Negative test separation
- Error message validation with
-ExpectedMessage
Location: Tests/Functions/Private/
Template File: Tests/Functions/Private/.template.ps1
Reference File: Tests/Functions/Private/ConvertTo-JiraAttachment.Unit.Tests.ps1
Use For: Functions that transform JSON to PowerShell objects - ConvertTo-, ConvertFrom- functions
Structure:
#requires -modules @{ ModuleName = "Pester"; ModuleVersion = "6.2.0"; MaximumVersion = "6.999" }
BeforeDiscovery {
. "$PSScriptRoot/../../Helpers/TestTools.ps1"
$script:moduleToTest = Initialize-TestEnvironment
}
InModuleScope JiraPS {
Describe "ConvertTo-JiraAttachment" -Tag 'Unit' {
BeforeAll {
. "$PSScriptRoot/../../Helpers/TestTools.ps1"
#region Definitions
$script:jiraServer = 'http://jiraserver.example.com'
# Sample JSON fixture (can be large - keep it organized)
$script:sampleJson = @"
{
"id": "123",
"name": "Example",
"created": "2025-01-01T00:00:00.000Z"
}
"@
$script:sampleObject = ConvertFrom-Json -InputObject $sampleJson
#endregion
#region Mocks
# Converter functions typically don't need mocks
#endregion
}
Describe "Behavior" {
Context "Object Conversion" {
BeforeAll {
$script:result = ConvertTo-YourType -InputObject $sampleObject
}
It "creates a PSObject out of JSON input" {
$result | Should -Not -BeNullOrEmpty
}
It "adds the custom type name 'JiraPS.YourType'" {
$result.PSObject.TypeNames[0] | Should -Be 'JiraPS.YourType'
}
}
Context "Property Mapping" {
BeforeAll {
$script:result = ConvertTo-YourType -InputObject $sampleObject
}
It "defines the 'Id' property with correct value" {
$result.Id | Should -Be "123"
}
It "defines required properties" {
$result.Name | Should -Not -BeNullOrEmpty
}
}
Context "Type Conversion" {
BeforeAll {
$script:result = ConvertTo-YourType -InputObject $sampleObject
}
It "converts 'created' field to DateTime object" {
$result.created | Should -Not -BeNullOrEmpty
$result.created | Should -BeOfType [System.DateTime]
}
It "converts nested objects to proper types" {
$result.author.PSObject.TypeNames[0] | Should -Be 'AtlassianPS.JiraPS.User'
}
}
Context "Pipeline Support" {
It "accepts input from pipeline" {
$result = $sampleObject | ConvertTo-YourType
$result | Should -Not -BeNullOrEmpty
}
It "handles array input" {
$multipleObjects = @($sampleObject, $sampleObject)
$result = $multipleObjects | ConvertTo-YourType
@($result).Count | Should -Be 2
}
}
}
}
}Key Features:
- No mocks needed typically (pure transformation functions)
- Large JSON fixtures kept organized in
#region Definitions - Focus on type conversion and property mapping
- Organized Context blocks:
- Object Conversion: Basic transformation and type name tests
- Property Mapping: Field presence and values
- Type Conversion: DateTime, nested objects, type checking
- Pipeline Support: Pipeline behavior and array handling
- Use
BeforeAllin contexts to avoid redundant conversions
| Aspect | Public CRUD Functions | Private Converter Functions |
|---|---|---|
| Mocks | Required (API calls) | Usually not needed |
| Focus | API interaction | Data transformation |
| Contexts | Signature, Behavior, Input Validation | Object Conversion, Property Mapping, Type Conversion, Pipeline Support |
| Test Data | Minimal (IDs, strings) | Large JSON fixtures |
| Complexity | Error handling, edge cases | Type checking, nested objects |
The Tests/Helpers/TestTools.ps1 module provides reusable functions for test setup:
Initialize-TestEnvironment
- Ensures the JiraPS module is loaded at the current on-disk version and returns the manifest path used.
- Must be called in
BeforeDiscovery; assign its return value to$script:moduleToTest. - Idempotent: short-circuits when the loaded module already matches the on-disk source. The next test file in a Pester run skips the Remove-Module / Import-Module churn entirely; the cache invalidates automatically when a source file (or
Release/JiraPS/JiraPS.psm1) is touched.
Resolve-ModuleSource
- Returns the path to the JiraPS module manifest (
.psd1). - Automatically detects source vs. Release build.
- You normally don't call this directly β
Initialize-TestEnvironmentalready returns it.
Resolve-ProjectRoot
- Returns the path to the project root directory
- Used by
Resolve-ModuleSourceinternally - Can be used in project-level tests (Build.Tests.ps1, etc.)
Write-MockDebugInfo
- Displays mock call information when
$VerbosePreference = 'Continue' - Call at the start of mock script blocks
- Parameters:
FunctionName(string),Params(string array of parameter names)
BeforeDiscovery {
. "$PSScriptRoot/../../Helpers/TestTools.ps1"
$script:moduleToTest = Initialize-TestEnvironment # imports + returns manifest path
}
InModuleScope JiraPS {
Describe "YourFunction" -Tag 'Unit' {
BeforeAll {
. "$PSScriptRoot/../../Helpers/TestTools.ps1"
# $VerbosePreference = 'Continue' # Uncomment to debug mocks
Mock Get-JiraFilter -ModuleName JiraPS {
Write-MockDebugInfo 'Get-JiraFilter' 'Id' # Shows: Id = 123
# mock logic
}
}
}
}# From repository root
Invoke-Build -Task TestInvoke-Pester ./Tests/Functions/Get-JiraIssue.Unit.Tests.ps1Invoke-Pester ./Tests/Functions/Get-JiraIssue.Unit.Tests.ps1 -Output DetailedInvoke-Pester -Path ./Tests -Tag Unit- Test files:
FunctionName.Unit.Tests.ps1 - Describe blocks: Use function name
- It blocks: Descriptive, starting with verb (e.g., "accepts valid filter object")
Use -TestCases for testing multiple scenarios:
It "rejects invalid type '<description>'" -TestCases @(
@{ description = "string"; value = "invalid" }
@{ description = "number"; value = 123 }
@{ description = "null"; value = $null }
) -Test {
param($value)
{ Your-Function -Parameter $value } | Should -Throw -ExpectedMessage "*expected*"
}Always validate error messages for robust testing:
Context "Type Validation - Negative Cases" {
It "rejects invalid input with meaningful error" {
{ Your-Function -Parameter "bad" } | Should -Throw -ExpectedMessage "*Filter*"
}
}Mock external dependencies in BeforeAll:
Mock Invoke-JiraMethod -ModuleName JiraPS {
Write-MockDebugInfo 'Invoke-JiraMethod' 'Method', 'Uri', 'Body'
ConvertFrom-Json @'
{
"id": "123",
"name": "Test"
}
'@
}The Write-MockDebugInfo.ps1 helper provides easy mock debugging through verbose output.
In your test file's BeforeAll block, uncomment the debug line:
BeforeAll {
. "$PSScriptRoot/../Helpers/Write-MockDebugInfo.ps1"
# Uncomment to enable mock debug output:
$VerbosePreference = 'Continue' # <-- Uncomment this line
# ... rest of BeforeAll
}In your mock definitions, call Write-MockDebugInfo with the function name and parameter names:
Mock Invoke-JiraMethod -ModuleName JiraPS {
Write-MockDebugInfo 'Invoke-JiraMethod' 'Method', 'Uri', 'Body'
# ... mock implementation
}
Mock Get-JiraFilter -ModuleName JiraPS {
Write-MockDebugInfo 'Get-JiraFilter' 'Id', 'Name'
# ... mock implementation
}Invoke-Pester ./Tests/Functions/Your-Test.Unit.Tests.ps1 -Output DetailedImportant: The -Verbose parameter on Invoke-Pester does NOT enable mock debug output. It only shows Pester's internal verbose messages. The debug output will appear automatically once you set $VerbosePreference = 'Continue' in the test file's BeforeAll block.
You'll see output like:
π· Mock: Invoke-JiraMethod
[Method] = "GET"
[Uri] = "https://jira.example.com/rest/api/2/issue/TEST-123"
[Body] = <null>
π· Mock: Get-JiraFilter
[Id] = 12345
[Name] = <null>- Color coding: Function names in Cyan, values in Yellow, nulls in DarkGray
- Type awareness: Arrays show item count and contents (up to 5 items)
- Hashtable display: Shows key/value pairs
- String truncation: Long strings truncated to 97 characters with "..."
- Null indication: Shows
<null>for null/empty values
When finished debugging, re-comment the line:
# $VerbosePreference = 'Continue'The -Verbose parameter on Invoke-Pester only affects Pester's own internal logging, not the test script's $VerbosePreference. Due to how Pester v6 isolates test execution contexts, command-line parameters don't propagate into the test script scope. This is why you must set $VerbosePreference directly inside the test file.
If you need interactive debugging, use breakpoints:
Mock Invoke-JiraMethod -ModuleName JiraPS {
$Method | Set-PSBreakpoint # Set breakpoint
# ... mock implementation
}Then run in VS Code or with pwsh -Debug.
- Group by purpose: Use Context blocks to separate positive/negative tests
- Clear naming: Describe what's being tested, not how
- One assertion per test: Makes failures easy to identify
- Test data in regions: Use
#region Definitionsand#region Mocks
- Use validation attributes:
[ValidateNotNullOrEmpty()],[ValidatePattern()] - Test pipeline support: Use
-TestCaseswith pipeline input - Verify mock calls: Use
Should -Invoketo verify mock behavior - Error messages: Always include
-ExpectedMessagein error tests
- Mock external calls: Never make real API calls in unit tests
- Minimize setup: Use
BeforeAllinstead ofBeforeEachwhere possible - Parameterized tests: Reduce duplicate test code
- Follow template: Use
Add-JiraFilterPermission.Unit.Tests.ps1as reference - American English: Use "Behavior" not "Behaviour"
- Consistent styling: Follow existing patterns in the codebase
- Document complex tests: Add comments for non-obvious test logic
See Tests/Functions/Add-JiraFilterPermission.Unit.Tests.ps1 for a complete, well-structured test file that demonstrates all best practices.
- Ensure Pester 6.2.0 through 6.x is installed:
Install-Module Pester -MinimumVersion 6.2.0 -MaximumVersion 6.999 -Force - Check for syntax errors in test file
- Verify module imports correctly in BeforeDiscovery
- Confirm
InModuleScope JiraPSwraps Describe block - Check
-ModuleName JiraPSis specified in Mock - Verify function is exported from module (check
JiraPS.psd1)
- Verify
$VerbosePreference = 'Continue'is uncommented in BeforeAll - Check
Write-MockDebugInfo.ps1is loaded before mocks are defined - Ensure
Write-MockDebugInfois called inside mock definition
- Use
$script:prefix in BeforeDiscovery and BeforeAll - Use plain variable names in Describe blocks
- Reference
$script:variableNamein It blocks if needed
In addition to unit tests, JiraPS includes integration tests that run against a live Jira Cloud instance.
- Location:
Tests/Integration/ - Tags:
Integration,Smoke - Documentation: See
Integration/README.mdfor full setup instructions
- Copy
.env.exampleto.envin the project root - Configure your Jira Cloud credentials and test fixtures
- Run integration tests (no build required):
# Run all integration tests in parallel (recommended)
Invoke-Build -Task TestIntegration
# Run smoke tests only (fast, critical tests)
Invoke-Build -Task TestIntegration -Tag 'Smoke'
# With custom parallelism
Invoke-Build -Task TestIntegration -ThrottleLimit 8
# Or use the script directly for more control
./Tests/Invoke-ParallelPester.ps1 -Tag 'Integration' -Output DetailedIntegration tests are excluded by default from Invoke-Build -Task Test to prevent failures when credentials aren't configured.
# Run only unit tests (default, excludes Integration)
Invoke-Build -Task Build, Test
# Run integration tests (no build needed)
Invoke-Build -Task TestIntegrationCloud integration coverage is split across two workflows:
- Smoke tests (
ci.yml,smoke_testsjob): Run on every first-party PR + every push tomaster. Skipped on fork / Dependabot PRs (no secrets). Gates continuous delivery via theCI Resultsentinel. - Full integration tests (
integration_tests.yml): two parallel jobs βcloud_integration_testsandserver_integration_tests(Dockerized Jira DC). Run on:- Weekly schedule (Sunday at 5 AM UTC) β both jobs.
- Manual trigger (
workflow_dispatch) β use this to run the full suite against an in-flight PR branch; passtrack=cloud,track=server, ortrack=both(defaultboth).
Required GitHub Secrets:
JIRA_CLOUD_URLJIRA_CLOUD_USERNAMEJIRA_CLOUD_PASSWORDJIRA_TEST_PROJECTJIRA_TEST_ISSUE
When the workflow runs, the TestIntegration task in JiraPS.build.ps1 validates that all required environment variables are set and fails early with a clear error if any are missing β so a misconfigured secret surfaces as a red CI run rather than a green run with all tests skipped.