Testing Private Functions with Pester

We’ve been busy beavering away on a new Powershell module that is comprised of many .ps1 files, that are loaded by a master/control .psm1 file when the module is imported. And, like all good Powershell citizens, we have many Pester unit tests for this code in accompanying .Tests.ps1 files.

As Powershell script modules permit us to control which functions should be exported with Export-ModuleMember and Pester allows us to test non-exported functions with the InModuleScope option, you might be wondering why we would ever need to be able to test private/internal functions?

Script Bundles

Whilst coding the new Powershell module, we have always had the desire to ensure that it could also be used as a ‘bundled’ .ps1 file. By bundle, we mean a single combined .ps1 file that can be included verbatim at the beginning of an existing script or by dot-sourcing it as required. Unfortunately – in this particular scenario – the internal module functions would be exposed and could potentially cause unnecessary confusion.

Here’s an example where both the ‘PublicFunction’ and ‘InnerPrivate’ functions would be exposed when bundled or dot-sourced into an existing .ps1 file.

function InnerPrivate {
    param ()
    Write-Output ‘InnerPrivate’
}

function PublicFunction {
    param()
    Write-Output (InnerPrivate)
}

If we only want the ‘PublicFunction’ visible then the simple solution to this is to nest the private function(s) inside the public function(s) like so:

function PublicFunction {
    param()

    function InnerPrivate {
        param ()
        Write-Output ‘InnerPrivate’
    }

   Write-Output (InnerPrivate)
}

Now only the ‘PublicFunction’ will be available. End of the story?

Internal Functions

Not quite; by hiding the functions we now cannot test them with Pester. I had a very brief conversation with Dave Wyatt on GitHub about this and it was agreed that this functionality should not be a part of the official Pester release.

To solve this particular issue, we have a simple function that will locate and return a function’s definition from within a .ps1 file as a script block. The function definition can then be dot-sourced into the current Pester scope to enable testing. Here’s an example Pester test file that will test our ‘InnerPrivate’ function:

$here = Split-Path –Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path –Leaf $MyInvocation.MyCommand.Path).Replace(“.Tests.”, “.”)
# If only testing an internal function, there is no need to dot-source the entire contents..

Describe “InnerPrivate” {
    # Import the ‘InnerPrivate’ function into the current scope
    . (Get-FunctionDefinition –Path “$here\$sut” –Function InnerPrivate)

    It “tests the private, inner function” {
        InnerPrivate | Should Be ‘InnerPrivate’
    }
}

If this code is of interest you can simply save the following code as a .ps1 file in Pester’s \Function directory, for example \Functions\FunctionDefinition.ps1, and Pester will automatically load it.

Notes:

  1. As this code utilises the Abstract Syntax Tree (AST) it does require Powershell 3.0;
  2. If there are any dependencies on variables in the function’s parent scope these will need to be mocked/accounted for;
  3. Depending on how you install/update the Pester module, this might get overwritten when Pester is updated.

When we have more time we’ll put this up on Github so people can collaborate on changes. In the meantime, here’s a copy of the function.

#Requires -Version 3

<#
.SYNOPSIS
    Retrieves a function's definition from a .ps1 file or ScriptBlock.
.DESCRIPTION
    Returns a function's source definition as a Powershell ScriptBlock from an
    external .ps1 file or existing ScriptBlock. This module is primarily
    intended to be used to test private/nested/internal functions with Pester
    by dot-sourcsing the internal function into Pester's scope.
.PARAMETER Function
    The source function's name to return as a [ScriptBlock].
.PARAMETER Path
    Path to a Powershell script file that contains the source function's
    definition.
.PARAMETER LiteralPath
    Literal path to a Powershell script file that contains the source
    function's definition.
.PARAMETER ScriptBlock
    A Powershell [ScriptBlock] that contains the function's definition.
.EXAMPLE
    If the following functions are defined in a file named 'PrivateFunction.ps1'

    function PublicFunction {
        param ()

        function PrivateFunction {
            param ()
            Write-Output 'InnerPrivate'
        }

        Write-Output (PrivateFunction)
    }

    The 'PrivateFunction' function can be tested with Pester by dot-sourcing
    the required function in the either the 'Describe', 'Context' or 'It'
    scopes.

    Describe "PrivateFunction" {
        It "tests private function" {
            ## Import the 'PrivateFunction' definition into the current scope.
            . (Get-FunctionDefinition -Path "$here\$sut" -Function PrivateFunction)
            PrivateFunction | Should BeExactly 'InnerPrivate'
        }
    }
.LINK
    
Testing Private Functions with Pester
#> function Get-FunctionDefinition { [CmdletBinding(DefaultParameterSetName='Path')] [OutputType([System.Management.Automation.ScriptBlock])] param ( [Parameter(Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName='Path')] [ValidateNotNullOrEmpty()] [Alias('PSPath','FullName')] [System.String] $Path = (Get-Location -PSProvider FileSystem), [Parameter(Position = 0, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'LiteralPath')] [ValidateNotNullOrEmpty()] [System.String] $LiteralPath, [Parameter(Position = 0, ValueFromPipeline = $true, ParameterSetName = 'ScriptBlock')] [ValidateNotNullOrEmpty()] [System.Management.Automation.ScriptBlock] $ScriptBlock, [Parameter(Mandatory = $true, Position =1, ValueFromPipelineByPropertyName = $true)] [Alias('Name')] [System.String] $Function ) begin { if ($PSCmdlet.ParameterSetName -eq 'Path') { $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path); } elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { ## Set $Path reference to the literal path(s) $Path = $LiteralPath; } } # end begin process { $errors = @(); $tokens = @(); if ($PSCmdlet.ParameterSetName -eq 'ScriptBlock') { $ast = [System.Management.Automation.Language.Parser]::ParseInput($ScriptBlock.ToString(), [ref] $tokens, [ref] $errors); } else { $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $tokens, [ref] $errors); } [System.Boolean] $isFunctionFound = $false; $functions = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true); foreach ($f in $functions) { if ($f.Name -eq $Function) { Write-Output ([System.Management.Automation.ScriptBlock]::Create($f.Extent.Text)); $isFunctionFound = $true; } } # end foreach function if (-not $isFunctionFound) { if ($PSCmdlet.ParameterSetName -eq 'ScriptBlock') { $errorMessage = 'Function "{0}" not defined in script block.' -f $Function; } else { $errorMessage = 'Function "{0}" not defined in "{1}".' -f $Function, $Path; } Write-Error -Message $errorMessage; } } # end process } #end function Get-Function