Last active
November 2, 2021 21:16
-
-
Save JustinGrote/79c0ed9635b7e88d727fcf58cf0d7212 to your computer and use it in GitHub Desktop.
AutoMock: Record Powershell Command Output to Replay in Pester Tests Offline
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#requires -module Indented.StubCommand | |
function Enable-AutoMockRecord ([Switch]$Append) { | |
$env:AUTOMOCK_RECORD = $true | |
if ($Append) {$env:AUTOMOCK_APPEND = $true} | |
} | |
function Disable-AutoMockRecord { | |
Remove-Item env:AUTOMOCK_RECORD -ErrorAction SilentlyContinue | |
Remove-Item env:AUTOMOCK_APPEND -ErrorAction SilentlyContinue | |
} | |
function AutoMock { | |
<# | |
.SYNOPSIS | |
Mock a command and record the output stream to cache files, then replay on request | |
.DESCRIPTION | |
By default this runs in "replay mode", meaning that it will error if a matching mock for the command is not found. | |
Set $DebugPreference='continue' to view when mocks have been recorded or replayed. | |
.OUTPUTS | |
None | |
.EXAMPLE | |
AutoMock Invoke-RestMethod -Record | |
PS>Invoke-RestMethod -Uri 'https://ipinfo.io/json' | |
Record a command for later playback | |
PS>AutoMock Invoke-Restmethod | |
#This command will return the cached result | |
PS>Invoke-RestMethod -Uri 'https://ipinfo.io/json' | |
#This command will error with a "mock not found" error | |
PS>Invoke-RestMethod -Uri 'https://ipinfo.io/1.1.1.1/json' | |
#> | |
[CmdletBinding(DefaultParameterSetName='Replay')] | |
param ( | |
#The name of the command or commands to record for AutoMock | |
[Parameter(Mandatory,ValueFromPipeline,Position=0)]$CommandName, | |
#Location to store and replay. Uses your default temporary directory by default. | |
[String]$Path = [IO.Path]::GetTempPath(), | |
#Record all outputs, overwriting any existing outputs | |
[Parameter(ParameterSetName='Record')][Switch]$Record = ([bool]$ENV:AUTOMOCK_RECORD), | |
#Only record items that have not been recorded before, and replay otherwise. Not recommended. | |
[Parameter(ParameterSetName='Record')][Switch]$Append = ([bool]$ENV:AUTOMOCK_APPEND), | |
#Specify this if you want to disable automock functionality. Useful for "live" testing or acceptance testing without having to write separate code | |
[Parameter(ParameterSetName='Disable')][Switch]$Disable = ([bool]$ENV:AUTOMOCK_DISABLE), | |
#Clear any previously stored cache items first. This will clear ALL cache, not just for the command you specified. | |
#To clear cache for specific entries, watch the debug output and remove those specific files. | |
[Parameter(ParameterSetName='Record')][Switch]$Reset | |
) | |
begin { | |
#Some environment variables for use when running with pester or in an environment | |
if ($ENV:AUTOMOCK_PATH) {$Path = $ENV:AUTOMOCK_PATH} | |
#ParameterSet doesn't change even if variable default is set via environment variable | |
if ($Record -or $Append -or $Reset) { | |
$AutoMockRecordMode = $true | |
write-debug "AutoMock: Running in Record mode" | |
} elseif ($Disable) { | |
write-debug "AutoMock: Running in Disabled mode" | |
} else { | |
write-debug "AutoMock: Running in Replay mode" | |
} | |
if (-not $Disable -and -not $AutoMockRecordMode -and -not (Get-ChildItem -Path (Join-Path $Path '*.clixml'))) { | |
throw "No AutoMocks were found in $Path. Did you run this command with -Record first to record outputs?" | |
} | |
} | |
process { | |
if (-not $CommandName) {continue} | |
$AutoMockBaseName = 'AutoMock' | |
$AutoMockFunctionName = "Invoke-$AutoMockBaseName-$(New-Guid)" | |
$AutoMockBaseFileName = "$AutoMockBaseName-$CommandName".Split([IO.Path]::GetInvalidFileNameChars()) -join '_' | |
$ReplayStubPath = (Join-Path $Path "$AutoMockBaseFileName.ps1") | |
#Replace any invalid filename characters | |
$ReplayStubPath = $ReplayStubPath | |
#Detect if automocked already | |
$ExistingAlias = Get-Alias $CommandName -ErrorAction SilentlyContinue | |
if ($ExistingAlias) { | |
if ($ExistingAlias.Definition.StartsWith('Invoke-AutoMock')) { | |
if ($Disable) { | |
write-debug "AutoMock: DISABLING $CommandName" | |
} else { | |
write-warning "$CommandName has already been AutoMocked, replacing the existing mock" | |
} | |
Remove-Item "Alias:/$CommandName" | |
Remove-Item "Function:/$($ExistingAlias.Definition)" | |
} else { | |
throw "It appears you are attempting to automock $CommandName which is currently an alias. This is not supported by AutoMock. Please mock $($ExistingAlias.Definition) instead" | |
} | |
} | |
#If disabled, move on without action | |
if ($Disable) {continue} | |
#Replay Mode: Fetch the command and import it | |
if (-not $AutoMockRecordMode) { | |
if (-not (Test-Path $ReplayStubPath)) { | |
throw "AutoMock: Mock for $CommandName not found at $ReplayStubPath. HINT: Did you specify the correct -Path argument, and did you run -Record first?" | |
} | |
Write-Debug "AutoMock: Replay Mock Definition found for $CommandName at $ReplayStubPath. Loading..." | |
. $ReplayStubPath | |
return | |
} else { | |
if ($Reset) { | |
try { | |
Write-Debug "AutoMock: resetting $CommandName cache at $Path" | |
Remove-Item "$Path/$AutoMockBaseName-$CommandName-*.clixml" -ErrorAction Stop | |
Remove-Item $ReplayStubPath -ErrorAction Stop | |
} catch [Management.Automation.ItemNotFoundException] {} | |
} | |
} | |
$stubDefinition = New-StubCommand -CommandName $CommandName -FunctionBody { | |
begin { | |
$SCRIPT:AutoMockPipelineInput = [Collections.Generic.List[Object]]::new() | |
$Path = ${PATH} | |
$record = ${RECORD} | |
$append = ${APPEND} | |
$CommandName = ${COMMANDNAME} | |
} | |
process { | |
#Collect the pipeline input to be used by steppable pipeline later | |
$AutoMockPipelineInput.Add($PSItem) | |
} | |
end { | |
function Get-ObjectHash ($InputObject, $Depth = 5) { | |
<# | |
.SYNOPSIS | |
Get a JSON representation of an object and take the hash of it | |
This is meant to be like GetObjectHash() but cross-platform and cross-version | |
#> | |
$ObjectSerialization = ConvertTo-Json -InputObject $InputObject -Depth $Depth -Compress | |
write-debug "AutoMock: Hash JSON - $ObjectSerialization" | |
$InputStream = [IO.MemoryStream]::new( | |
[text.encoding]::UTF8.GetBytes( | |
$ObjectSerialization | |
) | |
) | |
(Get-FileHash -Algorithm SHA1 -InputStream $InputStream).hash | |
} | |
$commandString = $CommandName + [Environment]::NewLine + ($PSBoundParameters | Out-String) | |
write-debug "AutoMock: Attempting to mock $CommandString" | |
$commandHash = Get-ObjectHash @( | |
$CommandName | |
$PSBoundParameters | |
$AutoMockPipelineInput | |
) | |
$MockFileName = "AutoMock-$CommandName-$commandHash.clixml".Split([IO.Path]::GetInvalidFileNameChars()) -join '_' | |
$MockPath = Join-Path $Path $MockFileName | |
if (Test-Path $MockPath) { | |
if (-not $Record -or $Append) { | |
Write-Debug "AutoMock: FOUND at $MockPath" | |
return (Import-Clixml $MockPath) | |
} | |
} else { | |
if (-not $Record) { | |
throw ("AutoMock: NOT FOUND at $MockPath for the following command:" + [Environment]::NewLine + $CommandString) | |
} | |
} | |
#RECORD: If we get this far, record the output | |
function Invoke-SteppablePipeline { | |
<# | |
.SYNOPSIS | |
We have to wrap the steppable pipeline in a function in order to handle the return output, since it hands off to the parent function to handle by default | |
#> | |
[CmdletBinding()] | |
param( | |
[Parameter(Mandatory)] | |
$Command, | |
$BoundParameters, | |
$AutoMockPipelineInput | |
) | |
#Fetch the command we are mocking for invocation | |
[String[]]$commandTypeFilter = [Management.Automation.CommandTypes]::GetNames([Management.Automation.CommandTypes]).where{$_ -notmatch 'All|Alias|Application|Workflow|Configuration'} | |
$MockedCommand = Get-Command $Command -CommandType $commandTypeFilter -ErrorAction stop | |
$scriptCmd = { &($MockedCommand) @BoundParameters } | |
$steppablePipeline = $scriptCmd.GetSteppablePipeline() | |
if ($AutoMockPipelineInput) { $ExpectingPipelineInput = $true } | |
$steppablePipeline.Begin($ExpectingPipelineInput, $ExecutionContext) | |
Foreach ($AutoMockPipelineItem in $AutoMockPipelineInput) { | |
$steppablePipeline.Process($AutoMockPipelineItem) | |
} | |
$steppablePipeline.End() | |
} | |
try { | |
Invoke-SteppablePipeline -Command $CommandName -BoundParameters $PSBoundParameters -AutoMockPipelineInput $AutoMockPipelineInput -OutVariable AutoMockResult | |
} catch { | |
Write-Debug "AutoMock: ERROR Captured - $PSItem" | |
Write-Debug "Saving Error in $CommandName to $MockPath" | |
$PSItem | Export-Clixml $MockPath | |
throw | |
} | |
Write-Debug "AutoMock: RECORD $CommandName to $MockPath" | |
$AutoMockResult | Export-Clixml $MockPath | |
} | |
} | |
#TODO: Fix this hacky pipeline fix for Implicitly remoted commands | |
if ($stubDefinition -match 'ValueFromPipeline') { | |
$LineRemoveRegex = [Regex]::Escape('if ($AutoMockPipelineInput) { $ExpectingPipelineInput = $true }') | |
$stubdefinition = $stubDefinition -replace $LineRemoveRegex,'$AutoMockPipeLineInput=$null;if ($AutoMockPipelineInput) { $ExpectingPipelineInput = $true }' | |
} | |
#Set the redirection alias for the command | |
$AliasDefinition = "New-Alias -Name '$CommandName' -Value '$AutoMockFunctionName' -Force -Scope SCRIPT" | |
# Import the 'Record' function into the global scope and substitute common settings | |
$CommandNameRegexMatch = [Regex]::Escape("function $CommandName {") | |
$stubDefinition = $stubDefinition -replace "^$CommandNameRegexMatch","function SCRIPT:$AutoMockFunctionName {" | |
$stubdefinition = $stubdefinition -replace '\${COMMANDNAME}', "'$CommandName'" | |
#Write the "Replay" function to the automock folder | |
if ($Record) { | |
$replayStubDefinition = $stubDefinition | |
$replayStubDefinition = $replayStubDefinition -replace '\${PATH}', '$PSScriptRoot' | |
$replayStubDefinition = $replayStubDefinition -replace '{RECORD}', "$false" | |
$replayStubDefinition = $replayStubDefinition -replace '{APPEND}', "$false" | |
Write-Debug "AutoMock: Saving Replay AutoMock for $CommandName to $ReplayStubPath" | |
($replayStubDefinition + [Environment]::NewLine + $AliasDefinition) > $ReplayStubPath | |
} | |
$stubdefinition = $stubdefinition -replace '\${PATH}', "'$Path'" | |
$stubdefinition = $stubdefinition -replace '{RECORD}', "$Record" | |
$stubdefinition = $stubdefinition -replace '{APPEND}', "$Append" | |
($stubDefinition + [Environment]::NewLine + $AliasDefinition) | Invoke-Expression | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#Run this to clear cache: | |
#remove-item "$([io.path]::GetTempPath())\PesterMock-*.clixml" | |
Describe "AutoMock Examples" { | |
. ./AutoMock.ps1 | |
Context "Clear" { | |
It "Should Clear Existing Mocks when -Reset is set" { | |
$item = New-Item 'TestDrive:/AutoMock-Invoke-Restmethod-PesterGUID.clixml' | |
AutoMock Invoke-RestMethod -Path TestDrive:/ -Reset | |
Test-Path $Item | Should -Be $false | |
} | |
} | |
Context "=== Record/Append Behavior: Record if not found, replay if found ===" { | |
AutoMock Invoke-Restmethod -Path TestDrive:/ -Record -Append | |
It "Should show a recording message above with DebugPreference enabled (when first run)" { | |
Invoke-Restmethod "ipinfo.io/4.2.2.2/json" | % ip | should -be '4.2.2.2' | |
} | |
It "Should show a replay message above with DebugPreference enabled (didn't actually go to the internet, read from cache instead)" { | |
Invoke-Restmethod "ipinfo.io/4.2.2.2/json" | % ip | should -be '4.2.2.2' | |
} | |
} | |
Context "=== Record Behavior: Records and overwrites existing mock cache ===" { | |
AutoMock Invoke-Restmethod -Path TestDrive:/ -Record | |
It "Should show a recording message above with DebugPreference enabled" { | |
Invoke-Restmethod "ipinfo.io/4.2.2.2/json" | % ip | should -be '4.2.2.2' | |
} | |
It "Should still show a recording message above with DebugPreference enabled, because record is set" { | |
Invoke-Restmethod "ipinfo.io/4.2.2.2/json" | % ip | should -be '4.2.2.2' | |
} | |
} | |
Context "=== Default Behavior - Replay Mode: Never Records and Fails if a mocked cache is not found. ===" { | |
It "Should show a replay message above with DebugPreference enabled" { | |
AutoMock Invoke-Restmethod -Record -Path TestDrive:/ | |
Invoke-Restmethod "ipinfo.io/4.2.2.2/json" | % ip | should -be '4.2.2.2' | |
AutoMock Invoke-Restmethod -Path TestDrive:/ | |
Invoke-Restmethod "ipinfo.io/4.2.2.2/json" | % ip | should -be '4.2.2.2' | |
} | |
It "Should throw an error because this doesn't exist in cache" { | |
AutoMock Invoke-Restmethod -Record -Path TestDrive:/ | |
Invoke-Restmethod "ipinfo.io/4.2.2.2/json" | % ip | should -be '4.2.2.2' | |
AutoMock Invoke-Restmethod -Path TestDrive:/ | |
try { | |
Invoke-Restmethod "ipinfo.io/1.2.5.5/json" | % ip | should -be '1.2.5.5' | |
} catch { | |
$errorMessage = [String]$PSItem | |
} | |
$errorMessage | Should -Match 'No matching recorded AutoMock found' | |
} | |
} | |
Context "Mock a remoted Exchange Online command with complicated output, like Get-DistributionGroup" { | |
if (-not (get-command get-distributiongroup)) { | |
$ExchangeTestSkip = $true | |
} | |
if (-not $ExchangeTestSkip) { | |
AutoMock Get-DistributionGroup -Record -Path TestDrive:/ | |
} | |
It "Should show a record message above (the first time)" -Skip:$ExchangeTestSkip { | |
Get-DistributionGroup -resultsize 500 | |
} | |
It "Should still show a record message above (because it's different parameters)" -Skip:$ExchangeTestSkip { | |
Get-DistributionGroup -resultsize 499 | |
} | |
if (-not $ExchangeTestSkip) { | |
AutoMock Get-DistributionGroup -Path TestDrive:/ | |
} | |
It "Pulls from cache and reads result count as 500" -Skip:$ExchangeTestSkip { | |
(Get-DistributionGroup -resultsize 500).count | should -be '500' | |
} | |
It "Pulls from cache and reads result count as 499" -Skip:$ExchangeTestSkip { | |
(Get-DistributionGroup -resultsize 499).count | should -be '499' | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
write-host -fore Green @' | |
Try running the following 3 commands in order: | |
Invoke-Pester ./zDemo.Tests.ps1 <--- it will fail saying "Mock for Invoke-RestMethod not found" | |
$env:AUTOMOCK_RECORD = $true | |
Invoke-Pester ./zDemo.Tests.ps1 <--- it will record the mocks and save them to your temp drive | |
Remove-Item env:AUTOMOCK_RECORD (and optionally, disconnect your network!) | |
Invoke-Pester ./zDemo.Tests.ps1 <--- It will succeed and run much faster because it is pulling from the offline cache! | |
Try again with $debugpreference='continue' | |
'@ | |
. $PSScriptRoot/AutoMock.ps1 | |
Describe "AutoMock Examples" { | |
AutoMock Invoke-RestMethod | |
It 'Records Slow Sites' { | |
(Invoke-RestMethod -Uri 'http://slowwly.robertomurray.co.uk/delay/1000/url/http://ipinfo.io/4.2.2.2/json').timezone | Should -Be 'America/Chicago' | |
} | |
It 'Also works via pipeline' { | |
@{test=5} | Invoke-RestMethod -method post -uri 'http://slowwly.robertomurray.co.uk/delay/1000/url/https://postman-echo.com/post' | % json | % test | should -be 5 | |
} | |
It "Aliases that reference the original command get mocked too" { | |
(irm 'http://slowwly.robertomurray.co.uk/delay/1000/url/http://ipinfo.io/4.2.2.2/json').postal | Should -Be '71203' | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment