Public/New-PesterCustomTests.ps1

<#
    .SYNOPSIS
        Creates Pester 5 test files for one or more PowerShell function scripts.

    .DESCRIPTION
        New-PesterCustomTests generates a <Name>.Tests.ps1 file for each function script in Source. Source can
        be a single .ps1 file or a folder (its *.ps1 files, excluding *.Tests.ps1). Test files are written to
        Destination, which defaults to a 'Tests' folder next to the source files.

        Each generated test imports the module that contains it (it walks up from the test file to the folder
        that holds <ModuleName>.psd1), keeps tcs.core settings, update checks and telemetry away from the real
        profile and network, and checks that the function exists and has help. Add behaviour tests to the
        generated Describe block. PSScriptAnalyzer checks belong in a module-level test or in CI, so no
        per-file analyzer tests or settings files are generated.

        Existing test files are left alone unless -Force is used.

    .PARAMETER Source
        Path to a single .ps1 file or a folder containing .ps1 files to generate tests for. Defaults to the
        current location. Accepts pipeline input by property name (Path, FullName).

    .PARAMETER Destination
        Folder where the test file(s) will be created. Created if it does not exist. Defaults to a folder named
        'Tests' in the source folder (or next to the source file).

    .PARAMETER Force
        Overwrite test files that already exist.

    .EXAMPLE
        New-PesterCustomTests -Source './MyModule/Public'

        Creates ./MyModule/Public/Tests/<Name>.Tests.ps1 for each .ps1 file in ./MyModule/Public.

    .EXAMPLE
        Get-ChildItem -Path './MyModule/Private' -Filter '*.ps1' | New-PesterCustomTests -Destination './MyModule/Private/Tests'

        Creates tests for each piped private function script.

    .INPUTS
        System.String or System.IO.FileInfo. Path to a file or folder (Source), by property name.

    .OUTPUTS
        None. Writes .Tests.ps1 files to the destination folder.

    .NOTES
        Author: TheCodeSaiyan
#>

function New-PesterCustomTests {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
        Justification = 'Public command name kept for backward compatibility.')]
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([void])]
    param
    (
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('Path', 'FullName')]
        [PSDefaultValue(Help = 'The current location')]
        [ValidateScript({
                if (-not (Test-Path -Path $_)) {
                    throw "File or folder '$_' does not exist."
                }
                return $true
            })]
        [string]
        $Source = $PWD.Path,

        [ValidateNotNullOrEmpty()]
        [PSDefaultValue(Help = 'A Tests folder in the source folder')]
        [string]
        $Destination,

        [switch]
        $Force
    )

    begin {
        $TelemetryArgs = @{
            ModuleName    = $MyInvocation.MyCommand.Module.Name
            ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version
            CommandName   = $MyInvocation.MyCommand.Name
            ExecutionID   = [guid]::NewGuid().ToString()
        }
        Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer
        $Failures = 0

        $PesterPublicTemplate = @'
BeforeAll {
    # Keep tcs.core settings, update checks and telemetry away from the real profile and network
    $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config'
    $env:TCS_SKIP_UPDATE_CHECK = '1'
    $env:TCS_TELEMETRY_OPTOUT = '1'

    # Import the module that contains this test (the nearest folder holding <FolderName>.psd1)
    $ModuleRoot = $PSScriptRoot
    while ($ModuleRoot -and -not (Test-Path -Path (Join-Path -Path $ModuleRoot -ChildPath ((Split-Path -Path $ModuleRoot -Leaf) + '.psd1')))) {
        $ModuleRoot = Split-Path -Path $ModuleRoot -Parent
    }
    if (-not $ModuleRoot) {
        throw "No module manifest was found in or above '$PSScriptRoot'."
    }
    $ModuleName = Split-Path -Path $ModuleRoot -Leaf
    Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath "$ModuleName.psd1") -Force
}

AfterAll {
    Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue
}

Describe '###TEMPLATE_FUNCTION_NAME' {
    It 'Is exported by the module' {
        Get-Command -Name '###TEMPLATE_FUNCTION_NAME' -Module $ModuleName -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
    }

    It 'Has help with a synopsis and an example' {
        $help = Get-Help -Name '###TEMPLATE_FUNCTION_NAME' -Full
        $help.Synopsis | Should -Not -BeNullOrEmpty
        @($help.Examples.Example).Count | Should -BeGreaterThan 0
    }

    # Add behaviour tests here. Mock commands the function calls with: Mock -ModuleName $ModuleName <Command> { }
}
'@


        $PesterPrivateTemplate = @'
BeforeAll {
    # Keep tcs.core settings, update checks and telemetry away from the real profile and network
    $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config'
    $env:TCS_SKIP_UPDATE_CHECK = '1'
    $env:TCS_TELEMETRY_OPTOUT = '1'

    # Import the module that contains this test (the nearest folder holding <FolderName>.psd1)
    $ModuleRoot = $PSScriptRoot
    while ($ModuleRoot -and -not (Test-Path -Path (Join-Path -Path $ModuleRoot -ChildPath ((Split-Path -Path $ModuleRoot -Leaf) + '.psd1')))) {
        $ModuleRoot = Split-Path -Path $ModuleRoot -Parent
    }
    if (-not $ModuleRoot) {
        throw "No module manifest was found in or above '$PSScriptRoot'."
    }
    $ModuleName = Split-Path -Path $ModuleRoot -Leaf
    Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath "$ModuleName.psd1") -Force
}

AfterAll {
    Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue
}

Describe '###TEMPLATE_FUNCTION_NAME' {
    It 'Is defined in the module and not exported' {
        InModuleScope -ModuleName $ModuleName {
            Get-Command -Name '###TEMPLATE_FUNCTION_NAME' -CommandType Function -ErrorAction SilentlyContinue
        } | Should -Not -BeNullOrEmpty
        Get-Command -Name '###TEMPLATE_FUNCTION_NAME' -Module $ModuleName -ErrorAction SilentlyContinue | Should -BeNullOrEmpty
    }

    # Add behaviour tests here, calling the function inside InModuleScope -ModuleName $ModuleName { }
}
'@

    }
    process {
        try {
            $SourceItem = Get-Item -Path $Source -ErrorAction Stop
            if ($SourceItem.PSIsContainer) {
                $Files = @(Get-ChildItem -Path $SourceItem.FullName -Filter '*.ps1' -File -ErrorAction Stop |
                        Where-Object { $_.Name -notlike '*.Tests.ps1' })
                $TargetFolder = if ($Destination) { $Destination } else { Join-Path -Path $SourceItem.FullName -ChildPath 'Tests' }
                Write-Verbose "Found $($Files.Count) function file(s) in '$($SourceItem.FullName)'."
            }
            elseif ($SourceItem.Extension -eq '.ps1') {
                $Files = @($SourceItem)
                $TargetFolder = if ($Destination) { $Destination } else { Join-Path -Path $SourceItem.DirectoryName -ChildPath 'Tests' }
            }
            else {
                throw "Source '$($SourceItem.FullName)' is not a PowerShell script (.ps1) or a folder."
            }

            if ($Files.Count -eq 0) {
                Write-Warning "No .ps1 files were found in '$($SourceItem.FullName)'."
                return
            }

            if (-not (Test-Path -Path $TargetFolder -PathType Container)) {
                if ($PSCmdlet.ShouldProcess($TargetFolder, 'Create test folder')) {
                    $null = New-Item -Path $TargetFolder -ItemType Directory -Force -ErrorAction Stop
                }
            }

            foreach ($File in $Files) {
                $TestPath = Join-Path -Path $TargetFolder -ChildPath "$($File.BaseName).Tests.ps1"
                if ((Test-Path -Path $TestPath) -and -not $Force) {
                    Write-Warning "'$TestPath' already exists and was not changed. Use -Force to overwrite it."
                    continue
                }
                if (-not $PSCmdlet.ShouldProcess($TestPath, 'Create Pester test file')) {
                    continue
                }
                $Template = if ($File.Directory.Name -eq 'Private') { $PesterPrivateTemplate } else { $PesterPublicTemplate }
                $Content = $Template.Replace('###TEMPLATE_FUNCTION_NAME', $File.BaseName)
                Set-Content -Path $TestPath -Value $Content -Encoding UTF8 -Force -ErrorAction Stop
                Write-Verbose "Created '$TestPath'."
            }
        }
        catch {
            $Failures++
            Write-Error "Failed to create Pester tests for '$Source': $($_.Exception.Message)"
        }
    }
    end {
        Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed ($Failures -gt 0)
    }
}