Pester Tests

July 30, 2026 · View on GitHub

Unit tests for PowerShell scripts in this repo use Pester 5, the standard PowerShell testing framework. Tests live alongside the scripts they cover under Tests/ and exercise pure functions in isolation — no Azure connectivity, no live workspaces, no side effects on the repo working tree.

WhatWhere
Test filesTests/ — 29 suites total: 26 root-level <ScriptName>.Tests.ps1 (one per source script, plus content-validation suites) and 3 under Tests/Documenter/
ConventionPester 5+ discovery model (Describe / Context / It / BeforeAll)
Isolation$TestDrive for temp files; AST extraction so source scripts never run their Main
PR-gate entrypointTools/Invoke-PRValidation.ps1 — runs every suite, emits NUnit XML, exits non-zero on any failure
GitHub Actions.github/workflows/pr-validation.yml — triggers on pull_request to main
ADO pipelinePipelines/Sentinel-PR-Validation.yml — wired as a build-validation policy on main

Prerequisites

ComponentMinimumInstall
PowerShell7.2+pwsh download
Pester5.0+Install-Module -Name Pester -Force -SkipPublisherCheck
powershell-yamlanyAuto-installed by tests when needed

CI does not rely on "latest" for either module. The GitHub validate job installs both through the composite action .github/actions/setup-pwsh-modules, which pins the exact versions the workflow declares in its top-level env block: PESTER_VERSION: 5.7.1 and YAML_VERSION: 0.4.12. Match those pins locally if you are chasing a "passes locally, fails in CI" discrepancy.

Verify Pester is available:

Get-Module -ListAvailable Pester | Select-Object Name, Version

Running tests

As the PR gate runs them

./Tools/Invoke-PRValidation.ps1

Invoke-PRValidation.ps1 is the single entrypoint both pipelines call. It installs Pester + powershell-yaml if missing, runs every suite under Tests/, writes a NUnit-2.5 XML report to test-results/pester-results.xml, and exits non-zero on any failure. Use this locally to mirror exactly what the PR check does.

Pass -InstallModules:$false to skip the auto-install when you have the modules already pinned via your profile, and -TestNameFilter '<pattern>' to scope down to a specific Describe / Context.

All tests directly via Pester

Invoke-Pester -Path Tests -CI

-CI exits non-zero on any failure and is the right flag for pipelines and local pre-commit hooks. Equivalent to Invoke-PRValidation.ps1 but without the NUnit XML output.

A specific test file

Invoke-Pester -Path Tests/Test-SentinelRuleDrift.Tests.ps1 -CI

A specific Describe block

Invoke-Pester -Path Tests/Test-SentinelRuleDrift.Tests.ps1 -FullName '*Update-RuleYamlFile*'

Verbose / detailed output

Invoke-Pester -Path Tests/Test-SentinelRuleDrift.Tests.ps1 -Output Detailed

-Output Detailed shows every individual It block as it runs. The default (-Output Normal) shows just per-file pass/fail and any failure details.

Code coverage

Invoke-Pester -Path Tests/Test-SentinelRuleDrift.Tests.ps1 `
    -CodeCoverage Tools/Test-SentinelRuleDrift.ps1 `
    -Output Detailed

Reports which lines of the source script each test exercised.

PR-validation gate

Every pull request to main must pass the Pester suite before it can merge. The gate is enforced on both platforms:

PlatformWorkflow / pipelineTriggered by
GitHub Actions.github/workflows/pr-validation.ymlpull_request events on main, push to main (CI baseline), and workflow_dispatch. There is no paths / paths-ignore filter, so every job runs on every trigger
Azure DevOpsPipelines/Sentinel-PR-Validation.ymlThe pr: trigger inside the YAML; required as a build-validation policy on main

Both call the same Tools/Invoke-PRValidation.ps1 entrypoint, so the validation logic stays in one place. The pipelines just handle environment setup, NUnit-XML publishing, and merge gating.

What gets validated

The GitHub workflow (.github/workflows/pr-validation.yml) runs five parallel jobs, each surfacing as its own status check the ruleset can require independently:

JobWhatAuthSetup
validateEvery Pester suite under Tests/ (~6,000 assertions; grows with content)NoneAlready wired
bicep-buildaz bicep build against every Infra/**/*.bicepNoneAlready wired
arm-validateTest-AzResourceGroupDeployment (a template-validation call, not a What-If) against every Content/Playbooks/**/*.jsonOIDCOne-off — see PR-Validation-Setup.md
kql-validateKQL syntax check via the Microsoft.Azure.Kusto.Language parser across all rule queriesNoneAlready wired
dependency-manifestBuild-DependencyManifest -Mode Verify — fails if dependencies.json drifts from discoveryNoneAlready wired. See Dependency Manifest

Pester suites covered by validate

The validate job points Pester at the whole Tests/ tree (Run.Path is set to the folder, not an explicit file list), so every suite below runs in the gate, including the Tests/Documenter/ suites and the Copilot / workbook-export suites.

SuiteFileCoverage
Drift detectorTests/Test-SentinelRuleDrift.Tests.ps1Compare-SentinelRule, Update-RuleYamlFile, Get-LineDiff, Resolve-RuleSource, Save-AbsorbedRule, New-AbsorbedRuleYaml, ConvertTo-FileSlug
Analytical rule YAML schemaTests/Test-AnalyticalRuleYaml.Tests.ps1193 analytical rules + 51 hunting queries × per-file schema; cross-file id uniqueness
Dependency manifestTests/Test-DependencyManifest.Tests.ps1dependencies.json shape; per-entry path resolution; watchlist + function alias resolution
Defender custom detectionsTests/Test-DefenderDetectionYaml.Tests.ps133 Defender YAMLs × required + alertTemplate fields; response-action enum validation (the -ForEach count tracks the file tree under Content/DefenderCustomDetections/)
WatchlistsTests/Test-WatchlistJson.Tests.ps1JSON schema + sibling CSV header invariants; cross-directory alias uniqueness
Automation rulesTests/Test-AutomationRuleJson.Tests.ps1Action types, trigger logic, propertyValues array shape; cross-file id uniqueness
Summary rulesTests/Test-SummaryRuleJson.Tests.ps1binSize enum, destinationTable suffix, KQL restriction patterns
ParsersTests/Test-ParserYaml.Tests.ps1Required fields + KQL-identifier validation; cross-file functionAlias uniqueness
WorkbooksTests/Test-WorkbookJson.Tests.ps1ARM-vs-gallery format detection; cross-directory GUID uniqueness for ARM workbooks
Playbooks (structural)Tests/Test-PlaybookArm.Tests.ps1ARM template structure + workflow trigger/action presence
Helper module self-testTests/Test-ImportScriptFunctions.Tests.ps1AST extractor synthetic + real-repo round-trip
PR template validatorTests/Test-PullRequestTemplate.Tests.ps1Remove-MarkdownComment, Get-PullRequestSection, Test-PullRequestTemplateBody (required-section + tick-box rules for the PR description)
Sentinel.Common moduleTests/Test-SentinelCommon.Tests.ps1Write-PipelineMessage ADO/local branching · Invoke-SentinelApi failure handling · Connect-AzureEnvironment state-shape contract + government-cloud branching · KQL extractors (Remove-KqlComments, Get-KqlWatchlistReferences, Get-KqlExternalDataReferences, Get-KqlBareIdentifiers incl. materialize()/table('X') patterns) · Get-ContentDependencies orchestrator
Deploy-CustomContentTests/Test-DeployCustomContent.Tests.ps1Get-PrioritizedFiles, Test-ContentDependencies, Initialize-DependencyGraph
Deploy-SentinelContentHubTests/Test-DeploySentinelContentHub.Tests.ps1Compare-SemanticVersion, Test-RuleIsCustomised
Deploy-DefenderDetectionsTests/Test-DeployDefenderDetections.Tests.ps1ConvertTo-GraphDetectionBody (YAML → Graph API)
Set-PlaybookPermissionsTests/Test-SetPlaybookPermissions.Tests.ps1Get-PlaybookRequiredRoles, Resolve-Scope
Import-CommunityRulesTests/Test-ImportCommunityRules.Tests.ps1The full normalisation pipeline (6 functions)
Copilot customisationsTests/Test-CopilotCustomisations.Tests.ps1Frontmatter parses, required keys present, display-name prefix, applyTo glob hygiene, and a cross-reference link checker across .github/agents/, .github/instructions/, .github/prompts/, .github/copilot-instructions.md, and AGENTS.md
Export-SentinelWorkbooksTests/Test-ExportSentinelWorkbooks.Tests.ps1ConvertTo-FolderName PascalCase derivation, parity against existing Content/Workbooks/<Folder>/ names, Format-WorkbookJson round-trip
New-DcrFromSchemaTests/Test-NewDcrFromSchema.Tests.ps1The full pure surface of the DCR-from-schema wizard: Import-TableSpec (three schema shapes, plus the refusal when handed a data sample), Remove-JsonNoise / ConvertTo-StraightPunctuation / ConvertTo-SafeText (paste repair that respects string state), Get-ColumnIssue and Test-TableName (the documented Azure Monitor limits), Resolve-TableSchema / Repair-TableSchema (duplicate and case handling, what is safe to auto-fix), ConvertTo-StreamColumn and Get-DefaultTransform (the guid-to-string reconciliation), Test-TransformKql, Compare-TableSchema, Format-DeploymentError (nested ARM preflight errors), both ARM template builders, and an end-to-end pass over all five shipped example schemas
Documenter rendererTests/Documenter/Convert-SentinelInventoryToMarkdown.Tests.ps1Renders the Documenter Markdown from the Tests/Documenter/Fixtures/sample/_raw JSON corpus and asserts expected output plus empty-state safety
Documenter gap engineTests/Documenter/Get-SentinelGap.Tests.ps1Drives the gap-analysis engine against a deliberately-broken fixture and asserts each gap rule fires
Documenter REST wrapperTests/Documenter/Invoke-SentinelRest.Tests.ps1URL construction inside Invoke-SentinelRest (api-version appending, existing query-string handling)

The YAML / JSON schema suites use -ForEach to generate one It block per file, so per-file pass/fail surfaces directly in the PR check UI rather than collapsing into a single combined assertion.

Wiring the merge gate

The pipelines exit non-zero on test failure, but a non-zero pipeline only blocks the merge button when explicitly required by branch protection / build policy. Configure the gate once per platform:

GitHub — Repo Settings → Branches → Branch protection rules → Add rule:

  • Branch name pattern: main
  • Require status checks to pass before merging: ON
  • Require branches to be up to date before merging: ON
  • Required checks (add each as it lands):
    • validate (Pester suites)
    • bicep-build (Bicep build)
    • kql-validate (KQL syntax)
    • dependency-manifest (dependencies.json drift gate)
    • arm-validate (ARM template validation — only after PR-Validation-Setup.md is complete)

Azure DevOps — Project Settings → Repos → Repositories → <repo> → Policies → Branch policies for main:

  • Build validation → + Add build policy
  • Build pipeline: Sentinel-PR-Validation
  • Path filter: Content/AnalyticalRules/*;Content/HuntingQueries/*;Modules/*;Deploy/*;Tools/*;Tests/*;dependencies.json
  • Trigger: Automatic
  • Policy requirement: Required
  • Build expiration: Immediately when the source branch is updated
  • Display name: PR Validation

Once the policy is required, the merge button stays disabled until the pipeline reports success against the latest commit.

Community-rule relaxations

Two schema rules are intentionally relaxed for files under Content/AnalyticalRules/Community/:

  1. GUID-format id: — David Alonso's upstream repo uses deliberately-non-GUID identifiers (e.g. a1b2c3d4-0011-4a5b-8c9d-dns011certutil). We can't change upstream content, and community rules are opt-in (-SkipCommunityDetections defaults to true) and force-disabled at deploy.
  2. Cross-file id: uniqueness — David's upstream reuses ids across categories (e.g. b2c3d4e5... is used for both SigninLogs and CSL rules). The uniqueness check applies only to in-house rules.

Both relaxations are documented in the test file and in Community Rules.

Test file layout

Each test file follows this skeleton (see Tests/Test-SentinelRuleDrift.Tests.ps1 for the full real example):

#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }

<# .SYNOPSIS / .DESCRIPTION / .NOTES #>

BeforeAll {
    # 1. Resolve the source script path. Scripts do NOT live directly under
    #    Deploy/ — they are foldered by concern: Deploy/content/, Deploy/permissions/,
    #    Deploy/setup/, and the drift/documenter tooling under Tools/. Point at
    #    the real location, e.g. 'Deploy/content/Deploy-CustomContent.ps1'.
    $scriptPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Deploy/content/<ScriptName>.ps1'

    # 2. AST-extract just the function definitions — see "AST extraction" below
    $tokens = $null; $errors = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseFile(
        $scriptPath, [ref]$tokens, [ref]$errors
    )
    $funcs = $ast.FindAll(
        { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] },
        $false
    )
    $src = ($funcs | ForEach-Object { $_.Extent.Text }) -join "`n`n"

    # 3. Stub any script-scoped constants the functions reference
    $script:Constant1 = 'value'

    # 4. Dot-source the function bodies into the test scope
    . ([ScriptBlock]::Create($src))
}

Describe 'FunctionName' {
    Context 'Some scenario' {
        It 'has the expected behaviour' {
            $result = FunctionName -Input 'x'
            $result | Should -Be 'expected'
        }
    }
}

The AST-extraction pattern

Most production scripts in this repo end with a top-level call like Invoke-Main or have a param() block at the top. Naively dot-sourcing them in a test file would:

  • Trigger Invoke-Main, which tries to authenticate to Azure
  • Choke on mandatory param(...) values
  • Pollute the test scope with side effects

The AST-extraction pattern walks the parsed script tree, collects only the top-level FunctionDefinitionAst nodes, joins their text, and dot-sources just that. The param block, the constants, and any final invocation are left behind.

$ast = [System.Management.Automation.Language.Parser]::ParseFile(
    $scriptPath, [ref]$tokens, [ref]$errors
)
$funcs = $ast.FindAll(
    { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] },
    $false   # IMPORTANT: $false = top-level only, not nested helpers
)
. ([ScriptBlock]::Create(($funcs.Extent.Text -join "`n`n")))

Critically the $false second argument to FindAll means "top-level functions only, not nested ones". Without it you'd duplicate every nested function definition.

If a function references script-scoped constants ($script:Foo), declare them in BeforeAll after the dot-source so the functions can see them:

$script:DiffSnippetLength  = 0
$script:SentinelApiVersion = '2025-09-01'
$script:ManagedRuleKinds   = @('Fusion', 'MicrosoftSecurityIncidentCreation')

Module-imports the AST extractor doesn't pull in

The AST extractor pulls only function definitions out of the source script. Top-level statements — param(), #Requires, Set-StrictMode, and Import-Module — are deliberately skipped to avoid running the script's entry-point machinery. Most of the time that's exactly what you want.

The exception is when an extracted function depends on a function defined in a module the source script imports at top level. The four deployer scripts do this for Sentinel.Common:

# Top of Deploy/content/Deploy-CustomContent.ps1 (skipped by AST extractor)
Import-Module (Join-Path $PSScriptRoot '../Modules/Sentinel.Common/Sentinel.Common.psd1') -Force

# An extracted function calls Write-PipelineMessage from that module.
function Write-DeploymentSummary {
    Write-PipelineMessage 'Done' -Level Section
}

Two equivalent options for the test scope:

  1. Re-import the module after the AST dot-source. Mirrors the runtime contract; the extracted functions call the real implementation.

    BeforeAll {
        $repoRoot = Split-Path -Parent $PSScriptRoot
        Import-Module (Join-Path $PSScriptRoot '_helpers/Import-ScriptFunctions.psm1') -Force
        Import-ScriptFunctions -Path "$repoRoot/Deploy/content/Deploy-CustomContent.ps1"
    
        # Pull in the same module the source script imports at runtime.
        Import-Module "$repoRoot/Modules/Sentinel.Common/Sentinel.Common.psd1" -Force
    }
    
  2. Stub the dependency as a no-op function. Useful when you want to isolate the test from any logging/output side effects.

    function Write-PipelineMessage {
        param([string]$Message, [string]$Level = 'Info')
        # no-op
    }
    

The Phase B suites use option 1 (real module). The new Tests/Test-SentinelCommon.Tests.ps1 covers Sentinel.Common itself using Pester Mock to stub Az PowerShell calls.

The Documenter suites source differently

The three suites under Tests/Documenter/ do not go through the AST-extraction helper. They dot-source specific Documenter files directly (including files under Tools/Documenter/Private/) and drive them against a fixed JSON fixture corpus rather than mocking Azure:

Because Invoke-PRValidation.ps1 sets Run.Path to the whole Tests/ folder, these run in the PR gate alongside the root suites.

The repo's link-checking mechanism lives inside Tests/Test-CopilotCustomisations.Tests.ps1. Its "Cross-references resolve" Describe block walks the Copilot customisation files under .github/ (agents, instructions, prompts, copilot-instructions.md) plus AGENTS.md, extracts each relative link, and asserts the target resolves to a real on-disk path. A renamed or deleted file that leaves a dangling reference in any of those files fails the validate job.

Mock builders

For functions that take complex parameter objects, define small builders in BeforeAll that produce baseline values with optional overrides. This keeps individual It blocks short — they only declare the fields the test actually cares about.

function New-DeployedScheduled {
    param([hashtable]$Override = @{})
    $base = @{
        kind             = 'Scheduled'
        displayName      = 'Mock rule'
        severity         = 'Medium'
        # ...full default shape...
    }
    foreach ($k in $Override.Keys) { $base[$k] = $Override[$k] }
    return $base
}

Use them like:

It 'detects severity change' {
    $deployed = New-DeployedScheduled @{ severity = 'High' }
    $expected = New-DeployedScheduled
    $diff = Compare-SentinelRule -Deployed $deployed -Expected $expected
    $diff.HasDrift | Should -BeTrue
}

Each test stays focused on the field it's asserting against.

$TestDrive for file-touching tests

When a function reads or writes files (e.g. Update-RuleYamlFile), use Pester's built-in $TestDrive variable as the target directory. Pester creates a fresh temp folder per test container and removes it automatically when the run ends.

It 'rewrites severity in place' {
    $tmp = Join-Path $TestDrive "rule-$([Guid]::NewGuid()).yaml"
    Copy-Item -Path $script:fixturePath -Destination $tmp

    $mods = @(@{ Field = 'severity'; Deployed = 'High'; Expected = 'Medium' })
    Update-RuleYamlFile -FilePath $tmp -Modifications $mods | Should -BeTrue
    Get-Content -Raw $tmp | Should -Match '(?m)^severity:\s+High\s*$'
}

Never write into $repoRoot/Content/AnalyticalRules/ from a test — that would mutate real repo files. Always copy the fixture into $TestDrive first.

Adding tests for a new script

  1. Create the test file: Tests/<SourceScriptName>.Tests.ps1. Match the source filename so the relationship is unambiguous.

  2. Use the AST-extraction skeleton above. Replace <ScriptName>.ps1 with the actual source script name and stub any $script:* constants the functions reference.

  3. Group tests by function, not by feature: one top-level Describe block per public function. Inside each, use Context blocks to group related scenarios (Context 'Single-field drift', Context 'NRT rules', Context 'Empty inputs').

  4. Aim for one assertion per It block where practical. Multi-assertion Its are fine when they're testing the same behaviour from different angles, but split them when they're testing distinct contracts.

  5. Run the suite locally with -CI before committing:

    Invoke-Pester -Path Tests -CI
    

Pester 5 syntax cheat sheet

ConstructPurpose
Describe 'Name' { ... }Top-level group, typically per function
Context 'Scenario' { ... }Sub-group for related cases within a function
It 'does X' { ... }A single test case
BeforeAll { ... }Runs once before any tests in the enclosing block
BeforeEach { ... }Runs before every It in the enclosing block
AfterAll / AfterEachCleanup counterparts
Should -Be 'x'Strict equality
Should -BeTrue / -BeFalseBoolean assertions
Should -Match 'regex'Regex match
Should -Contain 'x'Collection containment
Should -BeNullOrEmptyNull or empty string/collection
Should -ThrowAsserts the script-block throws
$TestDrivePer-container temp folder, auto-cleaned

Full reference: pester.dev.

CI integration (optional)

To gate PRs on the test suite, add a stage to your pipeline:

- stage: RunTests
  jobs:
    - job: Pester
      pool:
        vmImage: 'ubuntu-latest'
      steps:
        - checkout: self
        - task: PowerShell@2
          displayName: 'Install Pester'
          inputs:
            targetType: inline
            pwsh: true
            script: Install-Module -Name Pester -Force -SkipPublisherCheck
        - task: PowerShell@2
          displayName: 'Run Pester suite'
          inputs:
            targetType: inline
            pwsh: true
            script: |
              $result = Invoke-Pester -Path Tests -CI -PassThru
              if ($result.FailedCount -gt 0) {
                  Write-Host "##[error]$($result.FailedCount) Pester test(s) failed."
                  exit 1
              }

This is a separate concern from the deploy pipeline and the drift pipeline, so a dedicated Pipelines/Run-Tests.yml is the cleanest home for it. Trigger it on every PR via pr: { branches: { include: [ main ] } }.

Troubleshooting

SymptomCauseFix
The term 'FunctionName' is not recognizedAST extraction didn't include the function (e.g. nested in another function, or you passed $true to FindAll and got duplicates that misbehaved)Verify with $ast.FindAll(...).Name -join ',' that the expected function is in the list; ensure the FindAll second arg is $false
Cannot find path '<TestDrive>' outside an It block$TestDrive is only valid inside test blocksMove the file write into It or BeforeEach, not BeforeAll at the file scope
Tests pass locally but fail in CIPester version mismatch — CI on Pester 3.x, local on 5.x (or vice versa)Pin Pester version in CI: Install-Module Pester -RequiredVersion 5.7.1 -Force
Method invocation failed because [Object[]] does not contain a method named 'op_Subtraction'PowerShell $arr[i, j] indexing is array slicing, not 2-D accessUse jagged arrays (int[][]) or .GetValue(i, j) instead
AST extraction succeeds but functions reference undefined $script:* variablesConstants from the source script's prelude weren't stubbed in BeforeAllDeclare every $script:* the functions need before the dot-source

Test inventory

All 22 suites are listed below: 19 root-level Tests/*.Tests.ps1 plus the 3 under Tests/Documenter/. Counts are approximate (some suites use -ForEach to generate per-file It blocks at discovery time, so the count grows with the content tree). Run Invoke-Pester -Path Tests for a current total.

FileCoverageApprox. tests
Tests/Test-AnalyticalRuleYaml.Tests.ps1Per-rule schema (193 analytical + 51 hunting) + cross-file id uniqueness~24 + per-file
Tests/Test-AutomationRuleJson.Tests.ps1Action / trigger / propertyValues shape; cross-file id uniqueness14
Tests/Test-DefenderDetectionYaml.Tests.ps1Defender XDR YAML schema + alertTemplate + response-action enums15
Tests/Test-DependencyManifest.Tests.ps1dependencies.json shape + per-entry path / watchlist / function alias resolution~1000 (per-entry)
Tests/Test-DeployCustomContent.Tests.ps1Get-PrioritizedFiles, Test-ContentDependencies, Initialize-DependencyGraph15
Tests/Test-DeployDefenderDetections.Tests.ps1ConvertTo-GraphDetectionBody (YAML → Graph API)14
Tests/Test-DeploySentinelContentHub.Tests.ps1Compare-SemanticVersion, Test-RuleIsCustomised17
Tests/Test-ImportCommunityRules.Tests.ps1The full normalisation pipeline (6 functions)25
Tests/Test-ImportScriptFunctions.Tests.ps1AST extractor synthetic + real-repo round-trip6
Tests/Test-ParserYaml.Tests.ps1Required fields + KQL identifier validation; functionAlias uniqueness4
Tests/Test-PlaybookArm.Tests.ps1ARM template structure + workflow trigger/action presence10
Tests/Test-PullRequestTemplate.Tests.ps1PR-description parsing + required-section / tick-box validation16
Tests/Test-SentinelCommon.Tests.ps1Pipeline logging + REST wrapper + Az context bootstrap + KQL discovery extractors57
Tests/Test-SentinelRuleDrift.Tests.ps1Compare-SentinelRule, Update-RuleYamlFile, Get-LineDiff, Resolve-RuleSource, Save-AbsorbedRule, New-AbsorbedRuleYaml, ConvertTo-FileSlug58
Tests/Test-SetPlaybookPermissions.Tests.ps1Get-PlaybookRequiredRoles, Resolve-Scope14
Tests/Test-SummaryRuleJson.Tests.ps1binSize enum + destinationTable suffix + KQL restriction patterns10
Tests/Test-WatchlistJson.Tests.ps1JSON schema + sibling CSV header invariants; alias uniqueness9
Tests/Test-WorkbookJson.Tests.ps1ARM-vs-gallery format detection + GUID uniqueness for ARM workbooks11
Tests/Test-CopilotCustomisations.Tests.ps1Frontmatter parses + required keys present + display-name prefix + applyTo glob hygiene + cross-reference link checker for .github/agents/, .github/instructions/, .github/prompts/, .github/copilot-instructions.md, AGENTS.md~106 (per-file)
Tests/Test-ExportSentinelWorkbooks.Tests.ps1ConvertTo-FolderName PascalCase derivation + parity check against existing Content/Workbooks/<Folder>/ names; Format-WorkbookJson round-trip11
Tests/Documenter/Convert-SentinelInventoryToMarkdown.Tests.ps1Documenter Markdown render from the Fixtures/sample/_raw corpus + empty-state safety~117
Tests/Documenter/Get-SentinelGap.Tests.ps1Gap-analysis engine (Get-SentinelGap) against a deliberately-broken fixture~36
Tests/Documenter/Invoke-SentinelRest.Tests.ps1Invoke-SentinelRest URL construction (api-version / query-string handling)5

Add new entries to this table as you cover more scripts.

Authoring with GitHub Copilot

When editing files under Tests/**, Copilot automatically loads .github/instructions/pester-tests.instructions.md. The path-scoped instructions cover the two test patterns this repo uses (schema validation -ForEach and AST-extraction unit tests), the mocking conventions (especially Mock -ModuleName Sentinel.Common), and the foot-gun list (single-element array indexing, $TestDrive scoping).

Copilot tooling for tests:

  • Slash command /new-pester-test (VS Code) — bootstrap a fresh test file using the AST-extraction pattern
  • Agent Sentinel-As-Code: Test Engineer — adds coverage for untested scripts, refactors slow / fragile suites, designs mocking strategies for tricky dependencies (Az SDK, time-of-day, Invoke-WebRequest failures)

See GitHub Copilot setup for the full layout.