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:
- As this code utilises the Abstract Syntax Tree (AST) it does require Powershell 3.0;
- If there are any dependencies on variables in the function’s parent scope these will need to be mocked/accounted for;
- 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' } } .LINKTesting 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