pester / Pester

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

Mocking Out-File in PowerShell 7.* Produces Error #1877

Open Bidthedog opened 3 years ago

Bidthedog commented 3 years ago

General summary of the issue

When mocking Out-File and invoking it in code using Out-File -FilePath $path -Encoding utf8NoBOM -Force;, Pester produces this error when executing a test:

[-] Out-WebConfig.outputs file to expected output path when input path exists 14ms (14ms|1ms)
 PSInvalidCastException: Cannot convert the "utf8NoBOM" value of type "System.String" to type "System.Text.Encoding".
 ArgumentTransformationMetadataException: Cannot convert the "utf8NoBOM" value of type "System.String" to type "System.Text.Encoding".
 ParameterBindingArgumentTransformationException: Cannot process argument transformation on parameter 'Encoding'. Cannot convert the "utf8NoBOM" value of type "System.String" to type "System.Text.Encoding".
 at Out-WebConfig, D:\Git\Clients\myclient\AzureRepos\myclient.BuildTemplates\scripts\WebConfigHelpers\WebConfigHelpers.psm1:61
 at <ScriptBlock>, D:\Git\Clients\myclient\AzureRepos\myclient.BuildTemplates\test\WebConfigHelpers.Tests.ps1:48

'utf8NoBom' is a new value available when using Out-File with PowerShell Core. They updated it to make Out-File (and various other cmdlets) actually useful to Windows users (previously BOMs would always be output when using UTF8, causing no end of encoding issues). It seems that the Encoding parameter now accepts more than just an enum / string value, which is where I expect this issue is coming from - possible that Pester's mocking functions don't cater for this?

I have been trying to find the specification of this column in PowerShell Core (OSS github code and MS's documentation) but all I've found are these links (which aren't all that useful as I assume that Out-File cmdlet is doing some input param management before it calls any .Net functions):

https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/out-file?view=powershell-7.1#parameters

https://docs.microsoft.com/en-us/dotnet/api/system.text.encoding?view=net-5.0

https://docs.microsoft.com/en-us/dotnet/api/system.text.encoding.codepage?view=netcore-2.2

Describe your environment

Pester version : 5.0.2 C:\Users\****\Documents\PowerShell\Modules\Pester\5.0.2\Pester.psm1 PowerShell version : 7.1.3 OS version : Microsoft Windows NT 10.0.18363.0

Steps to reproduce

All code for my specific implementation here (it's in a PS module):

#requires -Version 7;
Set-PSDebug -Strict;
$ErrorActionPreference = 'Stop';

function Format-XML ([xml]$xml, $indent = 2) {
    $StringWriter = New-Object System.IO.StringWriter;
    $XmlWriter = New-Object System.XMl.XmlTextWriter $StringWriter;
    $xmlWriter.Formatting = "indented"
    $xmlWriter.Indentation = $Indent;
    $xml.WriteContentTo($XmlWriter);
    $XmlWriter.Flush();
    $StringWriter.Flush();
    Write-Output $StringWriter.ToString();
}

function Out-WebConfig {
    Param(
        [Parameter(Mandatory = $True)]
        [string]$inputWebConfigPath,

        [Parameter(Mandatory = $True)]
        [string]$outputWebConfigPath,

        [Parameter(Mandatory = $True)]
        [string]$environment
    )
    Set-PSDebug -Strict;
    $ErrorActionPreference = 'Stop';

    $isVerbose = ($PSBoundParameters['Verbose'] -eq $true);

    # Read the XML content
    if (!(Test-Path -Path $inputWebConfigPath)) {
        # Generally not OK, but we don't want this task to fail in this instance.
        return;
    }

    $xml = [xml](Get-Content -Path $inputWebConfigPath -Raw);
    # Assume that system.webServer already exists
    $sws = $xml.SelectSingleNode("//system.webServer");

    # Create the environment elements
    $evs = $xml.CreateElement("environmentVariables");
    $ev = $xml.CreateElement("environmentVariable");
    $ev.SetAttribute("name", "ASPNETCORE_ENVIRONMENT");
    $ev.SetAttribute("value", $environment);
    $evs.AppendChild($ev) | Out-Null;

    # Add the new environment elements
    $anc = $sws.SelectSingleNode("aspNetCore");
    if(!$anc) {
        $anc = $xml.CreateElement("aspNetCore");
        $sws.AppendChild($anc) | Out-Null;
    }
    $anc.AppendChild($evs) | Out-Null;

    # Output a formatted XML document
    Format-XML $xml.OuterXml | Out-File -FilePath $outputWebConfigPath -Encoding utf8NoBOM -Force;
}

Export-ModuleMember -Function Out-WebConfig;

Pester test code here. First test passes (does not hit the code in question - mocks aren't required but included for completeness), second test fails regardless of the assertion params specified:

#requires -Version 7;
Set-PSDebug -Strict;

$solutionRoot = Resolve-Path "$PSScriptRoot\..";

Import-Module $solutionRoot\scripts\WebConfigHelpers -Force;

InModuleScope WebConfigHelpers {
    BeforeAll {
        $inputXmlWithAspNetCoreEntry = @'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <system.webServer>
      <aspNetCore />
    </system.webServer>
</configuration>
'@;
    }

    Describe "Out-WebConfig" {
        It "does not attempt to read our output XML when input path does not exist" {
            # Arrange
            $expectedInputWebConfigPath = "C:\a\path.config";
            $expectedOutputWebConfigPath = "C:\b\path.config";
            $expectedEnvironment = "MYENV-QA1";
            Mock Test-Path { return $False; };
            Mock Get-Content { return $null; };
            Mock Out-File { return $null; };

            # Act
            Out-WebConfig -inputWebConfigPath $expectedInputWebConfigPath -outputWebConfigPath $expectedOutputWebConfigPath -environment $expectedEnvironment;

            # Assert
            Should -Invoke Get-Content -Times 0;
            Should -Invoke Out-File -Times 0;
        }
        It "outputs file to expected output path when input path exists" {
            # Arrange
            $expectedInputWebConfigPath = "C:\a\path.config";
            $expectedOutputWebConfigPath = "C:\b\path.config";
            $expectedEnvironment = "MYENV-QA1";
            $inputXml = $inputXmlWithAspNetCoreEntry;
            Mock Test-Path { return $True; };
            Mock Get-Content { return $inputXml; };
            Mock Out-File { return $null; };

            # Act
            Out-WebConfig -inputWebConfigPath $expectedInputWebConfigPath -outputWebConfigPath $expectedOutputWebConfigPath -environment $expectedEnvironment;

            # Assert
            Should -Invoke Out-File -Times 1 -ExclusiveFilter {
                $FilePath -eq $expectedOutputWebConfigPath -and
                $Encoding -eq 'utf8NoBOM'
            };
        }
    }
}

Expected Behavior

I expect to be able to arbitrarily mock Out-File and "ignore" its underlying operation so I can test various cmdlet inputs without writing files to disk. At present the only way I can really run these tests are by outputting the files into the temp dir and cleaning them up afterwards.

DarkLite1 commented 3 years ago

We don't have PowerShell 7 on our systems yet but here are some small tips I can give you regarding the code you posted:

Should -Invoke Get-Content -Times 0

The code above simply means: I want the CmdLet Get-Content to be invoked at least 0 times or more. What I think you are trying to asses is that Get-Content is not called at all. A better, more correct, way would be:

Should -Invoke Get-Content -Times 0 -Exactly
Should -Not -Invoke Get-Content

Then in your module you use this code:

Format-XML $xml.OuterXml | 
Out-File -FilePath $outputWebConfigPath -Encoding utf8NoBOM -Force

The default value for -Encoding in PowerShell 7.1 is already UTF8NoBOM. So better would be to simply not define it at all:

Format-XML $xml.OuterXml | 
Out-File -FilePath $outputWebConfigPath -Force

Can you give this a try? It might just resolve your error:

PSInvalidCastException: Cannot convert the "utf8NoBOM" value of type "System.String" to type "System.Text.Encoding".

One last tip: it's faster to use $null = Get-Stuff than Get-Stuff | Out-Null. But that's just me nit picking ;)

Bidthedog commented 3 years ago

Thanks for your reply - and thanks for clearing up the use of -Invoke, I've just migrated from Assert-MockCalled and the documentation is lacking somewhat.

I know it's not been long since I posted, but I've already rewritten this to use the TestDrive:\ feature as I couldn't wait for a fix, so I don't need to mock any longer; instead I'm asserting that the file exists and the content is what I expect it to be. The issue still stands, though - there is a problem mocking Out-File for this use case. I could leave it off, but I prefer to be explicit in my code (it did not used to be the default [it didn't used to exist] - this is a breaking change moving between PS and PS Core). I expect the same behaviour will occur if I used utf8BOM or any of the other new options.

I was not aware of the $null = Get-Stuff; syntax either - thanks, though I think I'll leave that as is as it's not causing any issues, and it's much easier to read :)

DarkLite1 commented 3 years ago

Awesome! Whatever floats your boot :)

If you would like us still to have a deeper look at the Out-File issue, would you be able to produce a small preproduction so we don't need to look though a bunch of irrelevant code? That would help us narrowing it down to either Pester or somethig else.

If you want to leave at this feel free to close the ticket.

Bidthedog commented 3 years ago

No problem - I'll add it to my "todo" list and get on to it as soon as I can.

nohwnd commented 3 years ago

The encoding parameter does not work because powershell is using this trick to convert the string value you provided, into the actual System.Text.Encoding value based on a table of values. This allows you to provide a value that is not a correct System.Text.Encoding, and the engine will convert it when it parses the code.

If you look at the signature of the cmdlet you can see that there is no parameter set that expects a string to be provided to the Encoding parameter, and if you look at System.Text.Encoding you won't find utf8NoBOM there.

We don't have access to this trick because it is internal api. So you see the exception.

[System.Text.Encoding]"utf8nobom"
InvalidArgument: Cannot convert the "utf8nobom" value of type "System.String" to type "System.Text.Encoding".

The only way you can solve this now is by removing the parameter type, using this Mock Out-File -RemoveParameterType Encoding.

As for the assertion, I would use Should -Not -Invoke Get-Content.

Bidthedog commented 3 years ago

Thank you @nohwnd - I knew it must be doing some magic, but I just couldn't figure out where the code that did it was! That's good to know.

Thanks for the Mock Out-File -RemoveParameterType Encoding trick too, I'll give that a go!

nohwnd commented 3 years ago

To fix this issue implementer of the fix would need to detect that we are on PowerShell 7 or newer, and Encoding parameter is emitted and rewrite it to take string with parameter set coming from the internal encoding table.

We have an example of a similar operation here: https://github.com/pester/Pester/blob/f7e2067ccfbf75e423037e6d32024b6e2870d875/src/functions/Mock.ps1#L1541-L1551

Where we rename known conflicting parameters with their non-conflicting name.

And in the code below that https://github.com/pester/Pester/blob/f7e2067ccfbf75e423037e6d32024b6e2870d875/src/functions/Mock.ps1#L1555-L1574 where parameter types are changed and validation is removed.

The fix for the issue above would be similar, it would also replace one metadata with other, using string type, and adding validation instead of removing it.

DarqueWarrior commented 3 years ago

I was able to work around the parameter issue like this:

$encoding = [System.Text.Encoding]::UTF8
Write-Output "Hello World" | Out-File -Encoding $encoding -FilePath pathToFile

The other problem I ran into was Out-File never reported being called when I mocked it. I tried with and without -Verifiable and different attempts with Should -Invoke and Assert-VerifiableMock. No matter what it reported Out-File was not being called.

When I commented out my mock I saw the file get written to disk so I know the mock was getting called when the code was in place. I might have to use the testdrive as well as I am not sure why I can't mock Out-File.

nohwnd commented 3 years ago

Did you get any errors when setting up that mock? It seems that the mock was not set, or maybe you created a mock in different module than from where it was called? Can you share a repro if you have one?

DarqueWarrior commented 3 years ago

Ugh! I must have done something wrong. I went to put the code back to mock Out-File and it is working. I wish I knew what I did wrong before. Sorry for distraction.

nohwnd commented 3 years ago

If anyone is running into this there are two possible solutions: https://github.com/pester/Pester/issues/1877#issuecomment-802135815 - Ignore parameter type in mock https://github.com/pester/Pester/issues/1877#issuecomment-826127321 - change your code to use the encoding directly instead of the shortcut string