Last week, a PowerShell scripting challenge was posted on the Iron Scripter web site. The idea was that when you run a Pester test, you can save the results to a specially formatted XML file.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
Invoke-Pester C:\scripts\sample.test.ps1 -OutputFile d:\temp\results.xml -OutputFormat JUnitXml
I get this result.
The challenge was to write a PowerShell command that could take the XML file and recreate this output as much as possible. Here's my result.
I have added a metadata header, but otherwise I think this is a pretty good recreation. Are you curious how I did this? If you want to try your hand at the challenge first, stop reading now.
Spoiler Alert
I wrote my function to recreate Pester results using the JunitXML format. It would be nice at some point to modify the function to be able to handle both XML formats. The function I wrote has a Path parameter . I can specify a value or pipe a file to the command.
Param( [Parameter(position = 0, ValueFromPipeline, HelpMessage = "Specify the path to the JunitXML Pester test file.")] [ValidateNotNullorEmpty()] #verify the file exists and it is an XML file [ValidateScript({ If ((Test-Path $_) -AND ($_.split('.')[-1] -eq 'XML' )) { $True } else { #display a custom validation error message Throw "The file could not be found or wasn't an XML file." } })] [Alias("pspath")] [string]$Path )
The parameter definition is using a few validation tests. The main test is to ensure that the file exists and that it ends in XML. I have other validation later to test if it is a JunitXML file. Yes, I know that I could technically export the results using any file extension, but I'm assuming XML plus I wanted to demonstrate this technique.
The other feature in the parameter is that I am creating my own custom error message. When you use [ValidateScript()], the scriptblock must give you True or False. In my case, if the test fails, I am throwing a customized error message.
PowerShell 7 handles this even nicer.
At this point it becomes a matter of parsing the XML document.
Process { Write-Verbose "Processing $Path" [xml]$doc = Get-Content -path $Path
I can look inside the XML to determine if this is a junit file. If it isn't I'll bail out of the command.
if ($doc.testsuites.noNamespaceSchemaLocation -notmatch "junit") { Write-Warning "Could not import $Path. You must specify a Pester test result file using the JUnitXML format." #abort return }
Otherwise, I'm parsing the document to get the metadata information which is assembled in a here-string and written to the host using Write-Host. In fact, the entire function writes directly to the console using Write-Host. Nothing is written to the pipeline. Although I will show you an option later. For this challenge, using Write-Host is perfectly fine. Another option would be to write a version of my function for PowerShell 7 and use ANSI escapes for the colorization. I'll save that for another time or you can write it.
Parsing Tests
The tricky part was parsing the test results. I had this XML to work with.
<testcase name="Alpha.Should have a value over 1" status="Passed" classname="C:\work\sample.test.ps1" assertions="0" time="0.465" /> <testcase name="Alpha.Should fail on a null value" status="Failed" classname="C:\work\sample.test.ps1" assertions="0" time="0.190"> <failure message="Expected $true, but got $false." /> </testcase> <testcase name="Alpha.Should accept a mandatory Computername parameter" status="Passed" classname="C:\work\sample.test.ps1" assertions="0" time="0.300" /> <testcase name="Alpha.Testing.Should have a value of 4" status="Passed" classname="C:\work\sample.test.ps1" assertions="0" time="0.894" /> <testcase name="Alpha.Testing.Should run without error" status="Passed" classname="C:\work\sample.test.ps1" assertions="0" time="1.150" /> <testcase name="Bravo.Should have a value less than 1" status="Passed" classname="C:\work\sample.test.ps1" assertions="0" time="0.525" /> <testcase name="Bravo.Should export the gravitational constant" status="Skipped" classname="C:\work\sample.test.ps1" assertions="0" time="0.000"> <skipped message="" /> </testcase> <testcase name="Bravo.Should run without errors" status="Passed" classname="C:\work\sample.test.ps1" assertions="0" time="2.357" /> <testcase name="Bravo.Should run with credentials" status="Pending" classname="C:\work\sample.test.ps1" assertions="0" time="0.000"> <skipped message="" /> </testcase>
The Name attribute is the key item. The first part, up to the first period, is the Describe block. In my sample this is Alpha and Bravo. Anything after the period is the assertion or the It statement. Unless, there is an intermediate word like 'Testing'. This is a Pester Context element.
I tried several techniques to pull this text apart. Eventually I settled on using a regular expression pattern.
[regex]$rx = "(?^[\w-_\s]+(?=\.))\.((?(?<=\.)\w+(?=\.))\.)?(?(?<=\.).*)"
The pattern uses named captures to identify the Describe, Context and Assertion components.
Looking to learn more about regular expressions in PowerShell? I created an entire course on the subject for Pluralsight.
I use this pattern to turn each test case into a custom object.
$tests = $doc.testsuites.testsuite.testcase | Group-Object -property Name Write-Verbose "Processing $($tests.count) tests" #turn each test into an object using a regex to split $testobj = foreach ($test in $Tests) { $m = $rx.Matches($test.name) [pscustomobject]@{ Describe = $m.groups[2] Context = $m.groups[3] Assertion = $m.groups[4] TestCase = $test.group } } #foreach test
I found this to be much easier to work with. Now I can use commands like Group-Object and ForEach to process the results in an orderly fashion, writing results to the host based east test. I ended up creating a private helper function, _parseTestCase, that would write the result to the host. When you look at the code, you'll see I'm using a Switch statement to determine what symbol and foreground color to use.
For the summary, I grouped the TestCase node on the Status property as a hashtable. The hashtable keys will things like 'Failed' and 'Skipped'. This makes it easy to get the counts that I need to display.
#write the summary information to the host in color $testHash = $doc.testsuites.testsuite.testcase | Group-Object -property Status -AsHashTable -AsString Write-Host "Tests completed in $(New-TimeSpan -seconds $doc.testsuites.time)" -ForegroundColor White Write-Host "Tests Passed: $($doc.testsuites.tests), " -ForegroundColor White -NoNewline Write-Host "Failed: $($TestHash["Failed"].count), " -ForegroundColor Red -NoNewline Write-Host "Skipped: $($TestHash["Skipped"].count), " -ForegroundColor Yellow -NoNewline Write-Host "Pending: $($TestHash["Pending"].count), " -ForegroundColor gray -NoNewline Write-Host "Inconclusive: $($TestHash["Inconclusive"].count)" -ForegroundColor DarkGray
The complete code is on Github.
The function has an alias of 'iptr'. I haven't tested with a lot of different test files so there may be things I've overlooked. But for the tests I write, this appears to be working.
Write-Information
I have one more thing to show you. Even though my code is using Write-Host, not all is lost. Write-Host is also a wrapper for Write-Information. Which means when I run my command like this (using my alias)
PS C:\>; iptr S:\sample.pester-results.xml -InformationVariable out
I get a variable, $out.
But this is actually a rich object.
PS C:\> $out[0..1] | select * MessageData : ******************************************************************************** Test File : C:\work\sample.test.ps1 Test Result : C:\scripts\sample.pester-results.xml Test Date : 4/13/2020 7:04:48 PM Computername : DESKTOP1 ******************************************************************************** Source : C:\scripts\Analyze-TestResult.ps1 TimeGenerated : 4/24/2020 12:57:33 PM Tags : {PSHOST} User : BOVINE320\Jeff Computer : Bovine320 ProcessId : 17492 NativeThreadId : 26760 ManagedThreadId : 14 MessageData : Describing Alpha Source : C:\scripts\Analyze-TestResult.ps1 TimeGenerated : 4/24/2020 12:57:33 PM Tags : {PSHOST} User : BOVINE320\Jeff Computer : Bovine320 ProcessId : 17492 NativeThreadId : 26760 ManagedThreadId : 14
I can do anything I want with this data. I could re-export what I needed to a json or xml file. Or send it to a text file. The format won't be the same without a little tweaking. But again, I'll leave that exercise for you.
In the meantime, if you didn't create your own challenge solution, I hope you'll give this a try and let me know what you think. Enjoy your weekend.