pester / Pester

Pester is the ubiquitous test and mock framework for PowerShell.
https://pester.dev/
Other
3.08k stars 470 forks source link

Pester fails to import modules or files even with full path #2347

Closed merddyin closed 1 year ago

merddyin commented 1 year ago

Checklist

What is the issue?

Two behaviors, based on run approach, but essentially the same problem. I'm sure it's my fault somehow, but no idea what could be causing it.

Behavior 1: VSCode - Fails to import module from PSD1, even with full path (module not currently in Modules folder), in BeforeAll block, regardless of inside or outside of single Describe block; No error for import, but unable to retrieve command via Get-Command, and attempts to execute tests fail to call command citing not known

Behavior 2: Shell (Latest pwsh) - Throws error indicating unable to find part of path for a later command (probably from discovery, and despite having full valid path), then throws error regarding being unable to process PSM1 because no valid module was found in any directory

Note: Running Import-Module with the full path to the PSD1 works without issue, and all commands function as expected, it's just the Pester tests failing. I have also tried pointing to the PSM1 instead - same behaviors. The tests naturally all fail since the module fails to import.

Expected Behavior

Module should detected and imported using the relative path, much less the fully qualified path. Required data source files are detected and imported. Tests are able to call the single exported cmdlet within the module.

As indicated in issue description, a manual execution of the various steps directly in the shell works without any issues. The module imports, the sole exported function is available, and the command can be executed with each of the options used in the tests. There is no reason this shouldn't work that I can determine from a module perspective.

Steps To Reproduce

Module structure and location:

D:\Dev\GitHub\DataGen\TemplateTesting ____country.Generator country.Generator.psd1 country.Generator.psm1
---- data
continents.csv
countries.csv
---- tests
country.Generator.tests.ps1

The module has the following elements in the PSM1:

Tests structure:

Describe your environment

Windows 11 (insiders build - latest) PowerShell 7.3.4 Pester 5.4.1 VSCode Latest

Possible Solution?

No response

fflaten commented 1 year ago

Can you provide code sample? Have you tried $PSScriptRoot/../country.Generator/country.Generator.psd1 to have it relative to the test-file?

I'm guessing you're using ../country.Generator/country.Generator.psd1 now, which will be relative to the current location in the session ($pwd).

merddyin commented 1 year ago

Sure, though it is just a mostly ordinary module manifest. I have a few extra things in PrivateData\PSData, but in the shell Pester indicates it is having issues with locating the PSM1, which is in the same directory, so it doesn't even seem to be getting as far as the PSData node.

@{
    # If authoring a script module, the RootModule is the name of your .psm1 file
    RootModule = 'country.Generator.psm1'

    Author = 'Topher Whitfield'

    CompanyName = ''

    ModuleVersion = '0.1.0'

    # Use the New-Guid command to generate a GUID, and copy/paste into the next line
    GUID = '08f87443-d7c6-4e66-aabe-384e3bb680b2'

    Copyright = '(c) 2023 Topher Whitfield. All rights reserved.'

    Description = @"
This base generator is used to generate (select) a random country, with the option to limit the country 
results to a specific continent, if desired.
"@

    # Minimum PowerShell version supported by this module (optional, recommended)
    # PowerShellVersion = ''

    # Cmdlets to export from this module
    CmdletsToExport = '*' 

    # Which PowerShell functions are exported from your module? (eg. Get-CoolObject)
    FunctionsToExport = 'country'

    # Which PowerShell aliases are exported from your module? (eg. gco)
    AliasesToExport = @()

    # Which PowerShell variables are exported from your module? (eg. Fruits, Vegetables)
    VariablesToExport = @()

    # PowerShell Gallery: Define your module's metadata
    PrivateData = @{
        PSData = @{
            # What keywords represent your PowerShell module? (eg. cloud, tools, framework, vendor)
            Tags = @('generator')

            # What software license is your code being released under? (see https://opensource.org/licenses)
            LicenseUri = 'https://opensource.org/license/gpl-3-0/'

            # What is the URL to your project's website?
            ProjectUri = 'https://github.com/merddyin/DataGen'

            # What is the URI to a custom icon file for your project? (optional)
            #IconUri = ''

            # What new features, bug fixes, or deprecated features, are part of this release?
            ReleaseNotes = @'
2023-05-11 - v0.1.0 - Initial generator version
'@

            # What is the friendly name for the generator; This is generally the function name, which should be the first part of the generator module name, but without .Generator
            FriendlyName = 'country'

            # Local path to each data file included with this Generator as a comma separated list of strings
            LocalDataFiles = @('data\continents.csv','data\countries.csv')

            # Online URI for each online data source used by this Generator as a comma separated list of strings
            OnlineDataURIs = ''

            # Designation of the Generator type (base, mid, or parent)
            GeneratorType = 'base'

            # List of all Generator modules leveraged by this Generator; Should be a comma separated list of strings that provide the propper module name
            DependsOn = @()
        }
    }

    # If your module supports updatable help, what is the URI to the help archive? (optional)
    # HelpInfoURI = ''
}
merddyin commented 1 year ago

Also, as far as pathing goes, as mentioned I have updated the code in the test to use the full path. I was originally using a relative path, but switched in case that was the problem. The full path to the PSD1, which file and drive letter, is provided currently to the test PS1, though I'm not using that for the PSM1 reference within the PSD1, as that is not standard.

And sorry about the code formatting challenge...looks like it dislikes my herestrings.

fflaten commented 1 year ago

Sorry, I meant a complete repro, not just the manifest. I'm having trouble following along and Pester doesn't affect module imports in general so besides path-issues in psm1 I'm out of ideas.

And sorry about the code formatting challenge...looks like it dislikes my herestrings.

Fixed :) You only had a single backtick, it requires three before and after multi line code.

merddyin commented 1 year ago

I'm not certain what is unclear here...I have a BeforeAll block that exists before any other blocks in the test. The very first line in the block is the one below, and it is the only thing I'm stuck on.

Import-Module 'D:\Dev\GitHub\DataGen\TemplateTesting\country.Generator\country.Generator.psd1' -Force

As previously indicated, the module imports as expected if I run the exact same line above myself. When attempting to import via Pester using a relative path, it fails to find the path. When I provide the full file path, Pester pops an error stating that no valid module file was found and fails the import. I have also tried providing the full path to the file within the manifest, though the manifest is in the same directory as the Manifest file, as per standard config for a module.

The only thing that works is if I move my module folder into the module path (e.g. Documents\PowerShell\Modules). If I do that, then the import runs fine and the tests execute. Unless there is something that I am missing, my understanding is that Pester should be able to test the files in place within my dev folder structure.

The following steps should enable a repro:

In my opinion, the actual test doesn't matter, since the process is currently failing on the import step. The expectation is that the test will fail to run, both inside of VSCode, and when called directly from the shell. The errors thrown should indicate unable to find a valid module file and, if the single test is present, a failed test indicating that the command called is unknown (because module import didn't occur).

If the failure occurs as expected, copying the module folder from the dev path to your Modules path, and attempting to run the test again will work, but it is a false positive. What I believe is occurring is that the module is still failing to import for the dev copy from the BeforeAll block, but the test passes because the module is auto-imported from the path version when the command is called.

fflaten commented 1 year ago

The following steps should enable a repro:

Should? Have you tried the steps yourself and reproduced the issue as a fresh hello world sample-module? Did it also fail on another machine? If so, please push the sample as a repo and share a link.

Your instructions explain how 90%+ of modules are using Pester without issues.

Without a code-complete repro, I'm unable to assist you any further.

merddyin commented 1 year ago

The term 'should' is intended to account for differences in systems. I'm a consultant, so I tend to hedge my language out of habit. Unfortunately, I do not presently have access to multiple other systems with other OS versions to verify if the issue persists in every possible variation of system. I can only tell you that I presently have 5 separate modules that all behave in the same manner. I have also gone ahead and created the exact same dummy module

I really am not understanding the difficulty here, or your animosity...if this were other specific tests that were failing, I could see some skepticism on your part, but I am having challenges with something that should be very basic. I have repeatedly indicated that the module imports without any issues via the PSD1, or the PSM1, when using the Import-Module cmdlet directly. The command within it also functions without issues, as I am able to run all of the scenarios outlined in my tests without any problems, and everything works...across all of the five modules I am working on actually. When I tell Pester to perform the import on the other hand, it tells me it cannot find a valid module file in any folder, so clearly something is different when Pester does it, versus when I run the cmdlet directly.

I am perfectly willing to accept that this could be something I am doing wrong in how I structured my test, as the documentation isn't super clear to me. If that is the case, you should be able to tell me that using the information I provided. You insist you need to see the code, fine, here you go. It won't tell you anything I haven't already stated above...The VERY first line with the Import-Module is where it fails. If my BeforeAll is in the wrong place, just tell me where it goes...if I need to load the module differently, just tell me that. My understanding of the documentation was that I needed to put all code into a BeforeAll block, so that's what I did.

BeforeAll {
    # Set a variable to the exported function as a scriptblock entity
    Import-Module 'D:\Dev\GitHub\DataGen\TemplateTesting\country.Generator\country.Generator.psm1' -Force
    if(-not (Get-Command country)){
        #throw "Module import failed"
    }

    try {
        $continentNames = @((Import-Csv -Path 'D:\Dev\GitHub\DataGen\TemplateTesting\country.Generator\data\continents.csv').Name)
    }
    catch {
        throw "Failed to import continent data"
    }

    try {
        $countryData = Import-Csv -Path 'D:\Dev\GitHub\DataGen\TemplateTesting\country.Generator\data\countries.csv'
    }
    catch {
        throw "Failed to import country data"
    }
}

Describe 'Functional Generator tests' {
    Context 'Test basic return values' {
        It "Should return a valid value without errors when no arguments are provided" {
            $return = country
            $return | Should -Not -BeNullOrEmpty

        }

        It "Should return a valid PSCustomObject when '-AsObject' switch is specified" {
            $return = country -AsObject
            $return | Should -BeOfType PSCustomObject -Because "The AsObject switch should cause a PSCustomObject to be returned instead of a string"
        }
    }

    BeforeAll {
        try {
            $continentNames = @((Import-Csv -Path 'D:\Dev\GitHub\DataGen\TemplateTesting\country.Generator\data\continents.csv').Name)
        }
        catch {
            throw "Failed to import continent data"
        }

        try {
            $countryData = Import-Csv -Path 'D:\Dev\GitHub\DataGen\TemplateTesting\country.Generator\data\countries.csv'
        }
        catch {
            throw "Failed to import country data"
        }
    }
    Context 'Test return values when filtering parameters are specified' {
        It "Should return a Country from the specified continent (<_>)" -ForEach $continentNames {
                $return = country -Continent "$_" -AsObject
                $return.Continent | Should -BeExactly "$_" -Because "Value should be of type '$valuename', but can be any length"
        }

        It "Should return a Country for which Postal Code data is indicated as available" {
            $return = country -ForGeo
            $testValue = ($countryData).Where({$_.Name -eq $return})
            [int]$testValue.PostalCode | Should -BeExactly 1 -Because "Specifying ForGeo should only return Countries with postal code data"
        }
    }
}
merddyin commented 1 year ago

Here's one of the Generators PSM1 files

$Script:MyModulePath = $(
    Function Get-ScriptPath {
        $Invocation = (Get-Variable MyInvocation -Scope 1).Value
        if($Invocation.PSScriptRoot) {
            $Invocation.PSScriptRoot
        }
        Elseif($Invocation.MyCommand.Path) {
            Split-Path $Invocation.MyCommand.Path
        }
        elseif ($Invocation.InvocationName.Length -eq 0) {
            (Get-Location).Path
        }
        else {
            $Invocation.InvocationName.Substring(0,$Invocation.InvocationName.LastIndexOf("\"));
        }
    }

    Get-ScriptPath
)

### Define your functions below using the core template - Please do not modify items above this.
$continentNames = (Import-Csv (Join-Path -Path $Script:MyModulePath -ChildPath 'data\continents.csv')).Name
if(-not ($continentNames)){
    throw "Unable to import continent data from $(Join-Path -Path $Script:MyModulePath -ChildPath 'data\continents.csv')"
}

<# 
$enumDefinition = @"
enum ContinentName {
    $(($continentNames -join ";`n") -replace " ","");
}
"@

Write-Host $enumDefinition

$null = Invoke-Expression $enumDefinition
 #>
$Script:ContinentNameSet = {
    param($commandName,$parameterName,$stringMatch)
        @($continentNames)
}

Register-ArgumentCompleter -CommandName country -ParameterName Continent -ScriptBlock $Script:ContinentNameSet

function country {
    <#
        .SYNOPSIS
        The function is intended to generate a random country designation. Type: base

        .DESCRIPTION
        The function generates a random country, with the option to limit the available range to a
        specific continent if desired.

        .PARAMETER Continent
        Specify the name of a continent to limit the selectable countries to a specific land mass

        .PARAMETER ForGeo
        It is possible that the generated value will be leveraged as part of a larger data set that requires geo-location data. Specifying this
        switch indicates that the resulting value should be selected only from those countries for which Postal Code geo-data is published.

        .PARAMETER AsObject
        By default, this Generator returns only the name of the select Country as a simple string. If this switch is specified, then a custom
        object with extended data will be returned instead. 

        .NOTES
        KEYWORDS: PowerShell

        Author: Topher Whitfield

        VERSIONS HISTORY
        0.1.0 - 2023-05-11 - New base generator template

        Continent and country data from
        https://www.back4app.com/database/back4app/list-of-all-continents-countries-cities

        .EXAMPLE
        Show first example - 

            Examples for base generators should be in name format (e.g. Base generator named 'word' would show examples with just 'word' as the command)
            Examples for mid generators should have both a name format example, and an option with Start-DGNDataGen

        $item
    #>
    [CmdletBinding()]
    Param(
        [Parameter(Position=0)]
        [string]$Continent,
        [Parameter()]
        [switch]$ForGeo,
        [Parameter()]
        [switch]$AsObject
    )
    $FunctionName = $pscmdlet.MyInvocation.MyCommand.Name
    [version]$FunctionVersion = "0.1.0"
    $FuncInfo = "[$($FunctionName) - v$($FunctionVersion.ToString())]"
    Write-Verbose "------------------- $($FuncInfo): Start -------------------"

    #region DataImport
        ## Note: All data imported from flat files should be placed in this section. While it would reduce line counts to just perform selections
        ## in a single step here, this should be avoided. Always perform selection actions in the InternalGen section for code consistency when possible.

        $continentDataFile = (Join-Path -Path $Script:MyModulePath -ChildPath 'data\continents.csv')
        Write-Verbose "$($FunctionName): continentDataFile [$($continentDataFile)]"
        $countryDataFile = (Join-Path -Path $Script:MyModulePath -ChildPath 'data\countries.csv')
        Write-Verbose "$($FunctionName): countryDataFile [$($countryDataFile)]"

        $CachePolicy = New-PollyPolicy -SlidingExpiration (New-TimeSpan -Minutes 10)
        $continentData = Invoke-PollyCommand -Policy $CachePolicy -ScriptBlock { Import-Csv $continentDataFile } -OperationKey 'continentlist'
        $countryData = Invoke-PollyCommand -Policy $CachePolicy -ScriptBlock { Import-Csv $countryDataFile } -OperationKey 'countrylist'

    #endregion DataImport

    #region ExternalGen
    ## Note: Calls to external generators would go here, along with any supplemental processing needed that does not depend on internally generated
    ## data. If supplemental data must exist to apply filters, filtering should occur in InternalGen section.

    #endregion ExternalGen

    #region InternalGen
        ## All code specific to this generator that involves filtering, setting, or creating values, including manipulation of data from any external
        ## generator calls should be placed in this section. For simple generations, where you are just getting a random item from a single data set,
        ## the code for selection goes into the CreatePSObject section.

        if(-not ($Continent)){
            $Continent = ($continentData | Get-Random).Name
        }

        $initialCountryList = ($countryData).Where({$_.Continent -eq $Continent})

        if($ForGeo){
            $selectedCountry = ($initialCountryList).Where({$_.PostalCode -eq 1}) | Get-Random
        }else {
            $selectedCountry = $initialCountryList | Get-Random
        }

    #endregion InternalGen

    #region CreatePSObject
        ## Note: Data from external and internal generation processes is converted to a custom object here. For simple selections, where only a single
        ## text value is produced, this section can be skipped. Values may be formatted in this section, but they should not be set here.

        $obj = [PSCustomObject]@{
            Country = $selectedCountry.Name
            CountryCode = $selectedCountry.Code
            PhonePrefixes = @($selectedCountry.Phone -split ',')
            CapitalCity = $selectedCountry.Capital
            Continent = $selectedCountry.Continent
        }

    #endregion CreatePSObject

    #region DataSet
    ## Note: This section is for execlusively for defining which properties will be part of the final output being written to the pipeline. This
    ## section is comprised of only two elements. The first is any groupings of properties needed, based on the selected parameter values. The
    ## second is assignment of property sets to a single variable that is used in the Output section. For simple generators with no options, this
    ## section can be skipped. This section depends on the default required parameter FilterSet

    #endregion DataSet

    #region Output
        ## Note: This section should not be performing any other processing except filtering the properties being sent to the pipeline, formatted as
        ## as either an object or string using the propertyName variable defined in the DataSet section.

        if($AsObject){
            Write-Output $obj
        }else{
            Write-Output "$($obj.Country)"
        }

    #endregion Output
    Write-Verbose "------------------- $($FunctionName): End -------------------"
}

### Below line must be populated with your exported functions

Export-ModuleMember -Function 'country'

And here is the associated PSD1

@{
    # If authoring a script module, the RootModule is the name of your .psm1 file
    RootModule = 'country.Generator.psm1'

    Author = 'Topher Whitfield'

    CompanyName = ''

    ModuleVersion = '0.1.0'

    # Use the New-Guid command to generate a GUID, and copy/paste into the next line
    GUID = '08f87443-d7c6-4e66-aabe-384e3bb680b2'

    Copyright = '(c) 2023 Topher Whitfield. All rights reserved.'

    Description = @"
This base generator is used to generate (select) a random country, with the option to limit the country 
results to a specific continent, if desired.
"@

    # Minimum PowerShell version supported by this module (optional, recommended)
    # PowerShellVersion = ''

    # Cmdlets to export from this module
    CmdletsToExport = '*' 

    # Which PowerShell functions are exported from your module? (eg. Get-CoolObject)
    FunctionsToExport = 'country'

    # Which PowerShell aliases are exported from your module? (eg. gco)
    AliasesToExport = @()

    # Which PowerShell variables are exported from your module? (eg. Fruits, Vegetables)
    VariablesToExport = @()

    # PowerShell Gallery: Define your module's metadata
    PrivateData = @{
        PSData = @{
            # What keywords represent your PowerShell module? (eg. cloud, tools, framework, vendor)
            Tags = @('generator')

            # What software license is your code being released under? (see https://opensource.org/licenses)
            LicenseUri = 'https://opensource.org/license/gpl-3-0/'

            # What is the URL to your project's website?
            ProjectUri = 'https://github.com/merddyin/DataGen'

            # What is the URI to a custom icon file for your project? (optional)
            #IconUri = ''

            # What new features, bug fixes, or deprecated features, are part of this release?
            ReleaseNotes = @'
2023-05-11 - v0.1.0 - Initial generator version
'@

            # What is the friendly name for the generator; This is generally the function name, which should be the first part of the generator module name, but without .Generator
            FriendlyName = 'country'

            # Local path to each data file included with this Generator as a comma separated list of strings
            LocalDataFiles = @('data\continents.csv','data\countries.csv')

            # Online URI for each online data source used by this Generator as a comma separated list of strings
            OnlineDataURIs = ''

            # Designation of the Generator type (base, mid, or parent)
            GeneratorType = 'base'

            # List of all Generator modules leveraged by this Generator; Should be a comma separated list of strings that provide the propper module name
            DependsOn = @()
        }
    }

    # If your module supports updatable help, what is the URI to the help archive? (optional)
    # HelpInfoURI = ''
}
fflaten commented 1 year ago

I have also gone ahead and created the exact same dummy module

As did I, which failed to reproduce the issue:

# Create directories
$modulePath = 'C:\Dev\Module'
New-Item -ItemType Directory -Path "$modulePath\tests" -Force

# Create rootmodule
Set-Content -Path "$modulePath\country.Generator.psm1" -Value @"
function abc {
    "hello world"
}

Export-ModuleMember -Function 'abc'
"@

# Create manifest
New-ModuleManifest -Path "$modulePath\country.Generator.psd1" -RootModule 'country.Generator.psm1' -FunctionsToExport abc

# Create test
Set-Content -Path "$modulePath\tests\country.Generator.tests.ps1" -Value @"
BeforeAll {
    Import-Module '$modulePath\country.Generator.psd1' -Force
}

Describe 'd' {
    Context 'c' {
        It 'i' {
            abc | Should -Be 'hello world'
        }
    }
}
"@

# Test
Invoke-Pester -Path "$modulePath\tests\country.Generator.tests.ps1" -Output Detailed

# Output
Pester v5.4.1

Starting discovery in 1 files.
Discovery found 1 tests in 40ms.
Running tests.

Running tests from 'C:\Dev\Module\tests\country.Generator.tests.ps1'
Describing d
 Context c
   [+] i 9ms (4ms|5ms)
Tests completed in 82ms
Tests Passed: 1, Failed: 0, Skipped: 0 NotRun: 0

C:\Dev is not part of my $env:PSModulePath. If the same code fails on your end, then it's likely an issue local to your computer which we're unable to assist with.

I really am not understanding the difficulty here, or your animosity...

I've simply asked for a complete repro (minimal sample) to reproduce the issue. In response I got directions which, as shown above, did not reproduce it on my end.

When I tell Pester to perform the import on the other hand, it tells me it cannot find a valid module file in any folder, so clearly something is different when Pester does it, versus when I run the cmdlet directly.

If my BeforeAll is in the wrong place, just tell me where it goes...if I need to load the module differently, just tell me that. My understanding of the documentation was that I needed to put all code into a BeforeAll block, so that's what I did.

Yes, it looks like it should in the test, which is why a minimal module repro was necessary.

Try changing this:

$Script:MyModulePath = $(
    Function Get-ScriptPath {
        $Invocation = (Get-Variable MyInvocation -Scope 1).Value
        if($Invocation.PSScriptRoot) {
            $Invocation.PSScriptRoot
        }
        Elseif($Invocation.MyCommand.Path) {
            Split-Path $Invocation.MyCommand.Path
        }
        elseif ($Invocation.InvocationName.Length -eq 0) {
            (Get-Location).Path
        }
        else {
            $Invocation.InvocationName.Substring(0,$Invocation.InvocationName.LastIndexOf("\"));
        }
    }

    Get-ScriptPath
)

To:

$Script:MyModulePath = $PSScriptRoot

$Invocation.PSScriptRoot is the folder of the caller, not the module-file. So this would return C:\Dev\Module\tests\ in the sample. That would cause your CSV-import to fail = module import failure.

Split-Path $Invocation.MyCommand.Path and $PSScriptRoot will return C:\Dev\Module\ as expected.

merddyin commented 1 year ago

Thanks, changing the value sorts the import for the test....interesting that this is having a challenge, as I have had it as part of my module template for years without issue. That said, I've only just started using Pester in the last few months, and don't typicall call my modules from outside...learning Pester has been on my 'to-do' list for some time, but the examples in the docs are challenging to understand with my limited dev background.

fflaten commented 1 year ago

Glad it got sorted out. The issue would also manifest if you imported the module from a script in a different folder than the module itself. So unrelated to Pester specifically, but it's a typical scenario due to the common tests folder-structure

As for docs @ https://pester.dev, we're always looking for feedback. If you have any suggestions for improvement, feel free to submit an issue in the docs-repo