Cross-platform PowerShell, Unit Testing and Automatic Variables

on under powershell
8 minute read

Now that PowerShell Core is gaining traction in the community, it’s only a matter of time before cross-platform PowerShell Module development is the standard. Let’s face it, it’s a no-brainer, why would you exclude potential customers!? However we need to remember that PowerShell Core is not equal to or the same as Windows PowerShell. They are different products, with different cmdlets, namespaces and functionality, and I have no doubt you’ll end up with platform specific logic paths in your code (at least in the near term).

In addition to this, PowerShell developers are increasingly using CI/CD tooling to automate their unit tests on multiple Operating Systems; whether it’s with AppVeyor and TravisCI, or perhaps with AWS CodeBuild, which now supports Linux and Windows build containers.

This blog will provide insight and guidance into a common issue PowerShell developers are likely to face when they start their cross-platform development, specifically in regards to PowerShell automatic variables and Pester unit tests.

A little background

With the release of Nano Server and Windows IOT, ‘Windows PowerShell Core’ was released, running on .NET Core as those platforms didn’t have the full .NET stack available. To help developers identify which platform their code was executing on, the Constant variable ‘$PSEdition’ was introduced. It resolves to ‘Desktop’ on Windows Server and Windows client platforms, and ‘Core’ on Nano Server and Windows IOT.

When PowerShell Core was released cross-platform, a similar issue was introduced; PowerShell developers still needed a way to identify the underlying platform. The automatic variables ‘$IsCoreCLR’, ‘$IsLinux’, ‘$IsMacOS’ and ‘$IsWindows’ were introduced to resolve the issue. ‘$IsCoreCLR’ is true on all PowerShell Core platforms, and it’s pretty obvious when the other variables would evaluate to true or false.

PowerShell developers have five automatic variables to identify the underlying platform for cross-platform PowerShell development. However, if you’re in a mature environment or even just starting out writing Pester unit tests, rest assured that when you use these automatic variables directly in your code you will very likely run into unit testing troubles.

Note: The PowerShell automatic variables documentation does not include details about these recently introduced variables yet.

Automatic variables and unit testing don’t mix

I’m going to walk through what most PowerShell developers do when they start developing cross-platform; the goal is to write a function that returns ‘Windows’ or ‘Not Windows’. I am going to ignore both Nano Server and Windows IOT for this example as they aren’t really end-user, customer platforms in most cases.

I’ll start with a simple function and unit tests to demonstrate the logic works. I also note that ‘$IsWindows’ is now an option so I’ll use that.

# The sample code
function Invoke-Foo
{
    if ($IsWindows)
    {
        'Windows'
    }
    else
    {
        'Not Windows'
    }
}

Describe 'Invoke-Foo' {
    It 'Runs the Windows logic path' {
        $IsWindows = $true

        $assertion = Invoke-Foo
        $assertion | Should -BeExactly 'Windows'
    }

    It 'Runs the non-Windows logic path' {
        $IsWindows = $false

        $assertion = Invoke-Foo
        $assertion | Should -BeExactly 'Not Windows'
    }
}

Code written, lets run our tests:

Describing Invoke-Foo
  [+] Runs the Windows logic path 29ms
  [+] Runs the non-Windows logic path 18ms

Perfect! The tests passed; our code works. Now lets go try out our new function:

# Invocation of 'Invoke-Foo' on Windows PowerShell
PS>Invoke-Foo
Not Windows

# Invocation of 'Invoke-Foo' on PowerShell Core on Windows
PS>Invoke-Foo
Windows

Well, that didn’t work as planned did it. Clearly something needs to change. Now we remember that ‘$IsWindows’ is only valid for PowerShell Core, so we’ll add ‘$PSEdition’ to our evaluation to fix the function on Windows PowerShell:

# PSEdition check added
function Invoke-Foo
{
    if ($IsWindows -or $PSEdition -eq 'Desktop')
    {
        'Windows'
    }
    else
    {
        'Not Windows'
    }
}

# Setting PSEdition to 'Core' so we can test the non-Windows path
Describe 'Invoke-Foo' {
    It 'Runs the Windows logic path' {
        $IsWindows = $true

        $assertion = Invoke-Foo
        $assertion | Should -BeExactly 'Windows'
    }

    It 'Runs the non-Windows logic path' {
        $IsWindows = $false
        $PSEdition = 'Core'

        $assertion = Invoke-Foo
        $assertion | Should -BeExactly 'Not Windows'
    }
}

Let’s run our tests and invoke the function to validate:

# Pester Test
Describing Invoke-Foo
  [+] Runs the Windows logic path 32ms
  [-] Runs the non-Windows logic path 14ms
    SessionStateUnauthorizedAccessException: Cannot overwrite variable PSEdition because it is read-only or constant.

# Invocation of 'Invoke-Foo' on Windows PowerShell
PS>Invoke-Foo
Windows

# Invocation of 'Invoke-Foo' on PowerShell Core on Windows
PS>Invoke-Foo
Windows

We now have a working function, but our unit tests fail so we can’t prove it. Why is the test failing? Because we can’t modify the automatic variable ‘$PSEdition’, which is a Constant. A Constant variable as outlined in the ‘Option’ parameter of Set-Variable:

The acceptable values for this parameter are:

None. Sets no options. ("None" is the default.)
ReadOnly. Can be deleted. Cannot be not changed, except by using the Force parameter.
Constant. Cannot be deleted or changed. Constant is valid only when you are creating a variable. You cannot change the options of an existing variable to Constant.
Private. The variable is available only in the current scope.
AllScope. The variable is copied to any new scopes that are created.

That Constant ‘$PSEdition’ variable cannot be modified, which makes is essentially useless in our unit test.


Some of you are probably seeing the underlying problem and have already figured out the fix, however I’ll continue walking through the process as I believe this is a very common workflow when PowerShell developers start developing for cross-platform. Additionally, the next step for a lot of developers is to drop the unit tests because “the code works!”. This is very clearly the wrong answer.

So how is this resolved?

The answer is actually quite simple! Firstly, do not drop the unit tests. These are critical to operational maturity, your personal development and is simply good development practice. To resolve this, we are going to use another function instead of directly calling the automatic variables. The new function will perform the exact same logic we used initially, except we can mock the function with whatever we want, which means we can control the logic and validate it.

# A function to evaluate the platform
function IsWindows
{
    if ($IsWindows -or $PSEdition -eq 'Desktop')
    {
        $true
    }
    else
    {
        $false
    }
}

# Our user facing public function
function Invoke-Foo
{
    if (IsWindows)
    {
        'Windows'
    }
    else
    {
        'Not Windows'
    }
}

# Unit tests now mock the private function
Describe 'Invoke-Foo' {
    It 'Runs the Windows logic path' {
        Mock IsWindows {$true}

        $assertion = Invoke-Foo
        $assertion | Should -BeExactly 'Windows'
    }

    It 'Runs the non-Windows logic path' {
        Mock IsWindows {$false}

        $assertion = Invoke-Foo
        $assertion | Should -BeExactly 'Not Windows'
    }
}

This change makes the code just as easy to read, and if used in a PowerShell Module, the ‘IsWindows’ function would be made private. To demonstrate that this change has not broken the function while allowing us to perform our unit testing validation:

#############
# Pester
#############

# Unit tests on Windows PowerShell
Describing Invoke-Foo
  [+] Runs the Windows logic path 63ms
  [+] Runs the non-Windows logic path 48ms

# Unit tests on PowerShell Core on Windows
Describing Invoke-Foo
  [+] Runs the Windows logic path 136ms
  [+] Runs the non-Windows logic path 27ms

# Unit tests in PowerShell Core in Bash WSL
Describing Invoke-Foo
  [+] Runs the Windows logic path 606ms
  [+] Runs the non-Windows logic path 123ms

# Unit tests in PowerShell Core on MacOS
Describing Invoke-Foo
  [+] Runs the Windows logic path 653ms
  [+] Runs the non-Windows logic path 123ms

#############
# Invocation
#############

# Windows PowerShell
PS>Invoke-Foo
Windows

# PowerShell Core on Windows
PS>Invoke-Foo
Windows

# PowerShell Core in Bash WSL
PS /> Invoke-Foo
Not Windows

# PowerShell Core on MacOS
PS /> Invoke-Foo
Not Windows

Wrap up

Automatic variables are extremely useful and provide the required detail to identify the platform your PowerShell code is being executed on. However, remember that using them directly can cause you headaches. Simply swap them out for private helper functions and off you go.

Happy PowerShell Developing!

powershell
comments powered by Disqus