Paul Selles

Computers and cats

Monthly Archives: August 2013

Invoke-MsTest PowerShell Module

I have recently created Invoke-MsTest a great PowerShell module for Visual Studio unit testing. I have made he project available on CodePlex (https://invokemstest.codeplex.com/). The module runs MsTest.exe behind the scenes and will return the results as a PSObject or a Boolean. This is a great tool to incorporate into your CI flow.

This project was inspired by the Invoke-MsBuild by my colleague Daniel Schroeder.

The project is available at on CodePlex but here is a sample script, it is strongly recommended that you download it from the CodePlex as the code in this blog is not guaranteed to up up to date.

function Invoke-MsTest
{
<#
	.SYNOPSIS
		Run tests for a given Visual Studio solution or project using MsTest
	
	.DESCRIPTION
		Parses each Visual Studio solution or project and extracts test containers.
		Test each file using MsTest.exe to create a single .trx file.
		Optionally user may choose to only run specific test names.
		Optionally user may view or keep the MsTest.exe command window open to view results.
	
	.PARAMETER Path
		The path of the Visual Studio solution or project to build (e.g. a .sln or .csproj file).
	
	.PARAMETER Tests
		A string array with the test names to be ran. 
		By Default the script will run all test associated with the project or solution.
		
	.PARAMETER Unique
		Switch that will only run tests who’s name exactly matches those listed in Tests.
	
	.PARAMETER ResultsDirectoryPath
		The directory path to create the test result file (.trx)
		By Defaults the test file will be written to the users temp directory (e.g. C:\Users\[User Name]\AppData\Local\Temp).
		
	.PARAMETER Verbose
		Switch will print test information to the PowerShell console.
	
	.PARAMETER ShowTestWindow
		Switch will show the cmd window running MsTest.exe
		
	.PARAMETER PromptToCloseTestWindow
		Switch that gives users the option of keeping the cmd window running MsTest.exe open once all the tests are complete.
		
	.PARAMETER NoResults
		Switch will block the results object from returning (Cannot be used with IsAllPassed).
		
	.PARAMETER IsAllPassed
		Switch will return a boolean true if all test pass else a false (Cannot be used with NoResults).
		
	.PARAMETER MsTestParameters
		Additionally MsTest Parameters that the user may wish to include (http://msdn.microsoft.com/en-US/library/ms182489(v=vs.80).aspx)
		Note: This script uses /testcontainer to run tests and will not support /testmetadata parameters.
	
	.OUTPUTS
		If NoResults is not selected then the script will pass an object with Outcomes, Test names (or project/solution names), Storage (container dll), and Comments.
		If all tests passed, there will be no output.

	.EXAMPLE
		Import-Module "C:\Path To Module\Inoke-MsTest.psm1"
		$TestResults = Invoke-MsTest -Path "C:\Some Folder\MySolution.sln"
	
		Foreach ($TestResult in $TestResults)
		{
			if ($TestResult.Outcome -eq "Failed")
			{
				Write-Host $TestResult.Name -ForgroundColor Red
			}
		}
	
		The default test will run all test under a given Visual Studio solution or project and return a list of failed tests, project, or solutions.
		The above example will print the name of all test failures to the console.
	
	.EXAMPLE
		$TestFailures = Invoke-MsTest -Path "C:\Some Folder\MySolution.sln" -Tests MyTest1,MyTest2,MyTest3
	
		Listing tests will only run tests that match the test name given.
		Note: MyTest1 will match with MyTest10, MyTest11, MyTest100 to use only exact matching test names use the 'Unique' switch
	
	.EXAMPLE
		Invoke-MsTest -Path "C:\Some Folder\MyProject.csproj" -ResultsDirectoryPath "C:\Some Folder"
	
		Runs test and drops the test results file in the "C:\Some Folder" directory
		
	.EXAMPLE
		Invoke-MsTest -Path "C:\Some Folder\MyProject.csproj" -Verbose -NoResults
		
		Runs test and print test projess/results to the console. This will not pass back the results object.
		
	.EXAMPLE
		Invoke-MsTest -Path "C:\Some Folder\MySolution.sln" -PromptToCloseTestWindow
		
		Runs test and keep the cmd MsTest.exe window open awaiting user input.
	
	.LINK
		Project home: https://invokemstest.codeplex.com/
	
	.NOTES
		Name:   Invoke-MsTest
		Author: Paul Selles [https://paulselles.wordpress.com/]
		Version: 1.0
		Inspired by my colleague, Daniel Schroeder's Invoke-MsBuild [https://invokemsbuild.codeplex.com]
#>
	[CmdletBinding(DefaultParameterSetName="Default")]
	param
	(
		# List the root path for your projects or solutions
		#	this will find all test containers within and run them
		[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)]
		[ValidateScript({(Test-Path $_ -PathType Leaf) -and (((Dir $_).Extension -eq ".sln") -or ((Dir $_).Extension -match '.*..*proj'))})]
		[String]$Path,	
		
		# List of names of the specific test to run, if nothing is
		# 	specified then run all tests in the test containers
		[parameter(Mandatory=$false)]
		[ValidateNotNullOrEmpty()]
		[String[]]$Tests=$null,
		
		# Will only run tests that exactly match the test name 'Tests' parameter
		[Parameter(Mandatory=$false)]
		[Alias("U")]
		[Switch]$Unique,
		
		# Option to select the location of the result files
		[parameter(Mandatory=$false)]
		[ValidateScript({Test-Path $_ -PathType Container})]
		[Alias("Results")]
		[Alias("R")]
		[string]$ResultsDirectoryPath=$env:Temp,
			
		# Show the tests running in a command window
		[parameter(Mandatory=$false)]
		[Alias("Show")]
		[Alias("S")]
		[switch] $ShowTestWindow,
		
		[parameter(Mandatory=$false)]
		[Alias("Prompt")]
		[switch] $PromptToCloseTestWindow,
		
		# Will not return test results
		[parameter(Mandatory=$false,ParameterSetName="NoResults")]
		[Switch]$NoResults,
		
		# Will return a boolean result, $true if all tests pass otherwise $false
		[parameter(Mandatory=$false,ParameterSetName="IsAllPassed")]
		[Alias("BoolResults")]
		[Switch]$IsAllPassed,
		
		# Desired test run parameters
		[parameter(Mandatory=$false)]
		[ValidateNotNullOrEmpty()]
		[Alias("Params")]
		[Alias("P")]
		[string]$MsTestParameters
	)
	
	BEGIN { }
	END { }
	PROCESS
	{
		Set-StrictMode -Version Latest
		
		# Array for our test results
		$TestResults = @()
		
		# Sets the verbose Parameter
		if ($PSBoundParameters['Verbose']) { $Verbose = $true }
		else { $Verbose = $false }
		
		# Get the path to the MsTest executable
		# Exit if MsTest path is invalid
		$MsTest = Get-MsTest
		if (-not (Test-Path $MsTest)) { return }
		
		# Array for out container and projects
		$TestContainers = @()
		$Projects = @()
		
		# Get a list of project under the supplied solution file
		if (((Dir $Path).Extension -eq ".sln"))
		{
			Get-Content -Path $Path | % {
				if ($_ -match '\s*Project.+=\s*.*,\s*\"\s*(.*proj)\s*\"\s*,\s*') {
					$Projects += Join-Path -Path (Dir $Path).DirectoryName $matches[1]
				}
			}		
		}
		# If a project file we don't need to so much
		elseif ((Dir $Path).Extension -match '.*..*proj')
		{
			$Projects += $Path
		}
		
		# Loop through all project files and 
		Foreach ($Project in $Projects)
		{
			$ProjectXml = [Xml](Get-Content -Path $Project)
			$ns = New-Object Xml.XmlNamespaceManager $ProjectXml.NameTable
			$ns.AddNamespace('dns','http://schemas.microsoft.com/developer/msbuild/2003')
			
			# Test is the project includes the unit test class
			if ([Bool]($ProjectXml.SelectNodes('//dns:Project/dns:ItemGroup/dns:Reference', $ns) | Where {$_.Include -match 'Microsoft.VisualStudio.QualityTools.UnitTestFramework'}))
			{			
				# Collect all possible configurations
				$Configurations = $ProjectXml.SelectNodes('//dns:Project/dns:PropertyGroup[@Condition]', $ns).Condition
			
				# Collect all PropertyGroup Nodes referencing IntermediateOutputPath and BaseIntermediateOutputPath elements
				$IntermediateOutputPathNodes = $ProjectXml.SelectNodes('//dns:Project/dns:PropertyGroup[dns:IntermediateOutputPath]', $ns)
				$BaseIntermediateOutputPathNodes = $ProjectXml.SelectNodes('//dns:Project/dns:PropertyGroup[dns:BaseIntermediateOutputPath]', $ns)
			
				# Container for the IntermediateOutputPath relative to the proj directory
				$IntermediateOutputPaths = @()
			
				# IntermediateOutputPath take precedence over BaseIntermediateOutputPath 
				# Remove all BaseIntermediateOutputPaths that matches with an IntermediateOutputPath Configuration
				# Register the IntermediateOutputPath
				# Remove Configuration from Configurations list
				Foreach ($IntermediateOutputPathNode in $IntermediateOutputPathNodes)
				{
					$Configuration = $IntermediateOutputPathNode.Condition
					$BaseIntermediateOutputPathNodes = $BaseIntermediateOutputPathNodes | ? {$_.GetAttribute('Condition') -ne $Configuration}	
					$IntermediateOutputPaths += $IntermediateOutputPathNode.IntermediateOutputPath
					$Configurations = $Configurations | ? {$_ -ne $Configuration}
				}
				
				# Register the remaining BaseIntermediateOutputPath
				Foreach ($BaseIntermediateOutputPathNode in $BaseIntermediateOutputPathNodes)
				{
					$Configuration = $IntermediateOutputPathNode.Condition
					$IntermediateOutputPaths += $BaseIntermediateOutputPathNode.BaseIntermediateOutputPath
					$Configurations = $Configurations | ? {$_ -ne $Configuration}
				}
				
				$IntermediateOutputPaths = $IntermediateOutputPaths | % {Join-Path (Dir $Project) $_} | Where {Test-Path "$_\*FileListAbsolute.txt"}
				if ($Configurations) {
					$IntermediateOutputPaths += (Get-ChildItem -Path (Join-Path (Dir $Project).DirectoryName obj) -Directory).FullName | Where {Test-Path "$_\*FileListAbsolute.txt"}
				}
							
				# If all IntermediateOutputPaths are empty alert user
				if (-not $IntermediateOutputPaths)
				{
					if ($Verbose) { Write-Host "Error :"(Dir $Project).Name": Could not find IntermediateOutputPath contents. Please rebuild the solution/project and try again." -ForegroundColor Red }
					$TestResults += New-Object PSObject -Property @{Outcome="projerror";Name=(Dir $Project).Name;Storage="";Comments="Could not find IntermediateOutputPath contents. Please rebuild the solution/project and try again."}
					break
				}
				
				# Get the latest FileListAbsolute this is cumbersome because if it is an array of one element the string will return the first char
				$FileListAbsolute = (($IntermediateOutputPaths | % {(Get-ChildItem -Path $_ -File *.FileListAbsolute.txt)} | Sort -Descending {(Dir $_.FullName).LastWriteTime})[0]).FullName
		
				# Find all test containers from the DLL files submitted
				$ProjectTestContainers = @()

				# Get a list of all the DLL in the project and find the test containers
				Get-Content -Path $FileListAbsolute | % { if ($_ -match '.*\\bin\\.*.dll' -and $_ -notmatch '.*nunit.core.dll') {
					if (-not (Test-Path $_))
					{
						if ($Verbose) { Write-Host "Error :"(Dir $Project).Name": Could not find library $_. Please rebuild the solution/project and try again." -ForegroundColor Red }
						$TestResults += New-Object PSObject -Property @{Outcome="projerror";Name=(Dir $Project).Name;Storage="$_";Comments="Could not find library $_. Please rebuild the solution/project and try again."}
					}
					elseif ([IO.File]::ReadAllText($_) -match 'TestClass')
					{
						$ProjectTestContainers += $_
					}
				}}
				
				# If there are not test containers, let the user know
				if (-not $ProjectTestContainers)
				{
					if ($Verbose) { Write-Host "Warning :"(Dir $Project).Name": Could not find test containers." -ForegroundColor Yellow }
					$TestResults += New-Object PSObject -Property @{Outcome="projwarning";Name=(Dir $Project).Name;Storage="";Comments="Could not find test containers."}
				}
				else
				{
					if ($Verbose) { Write-Host "Added :"(Dir $Project).Name": Found" $ProjectTestContainers.Count "test container(s)" -ForegroundColor Green }
				}
				
				$TestContainers += $ProjectTestContainers
			}
		}
		if (-not $TestContainers)
		{
			# By returning the unique TestResults we prevent spamming the same problem (ie. Multiple assemblies cannot be found with only report once)
			if ($Verbose) { Write-Host "Error :"(Dir $Path).Name": Could not find any test containers."  -ForegroundColor Red }
			$TestResults += New-Object PSObject -Property @{Outcome="testerror";Name=(Dir $Path).Name;Storage="";Comments="Could not find any test containers."}
			if ($IsAllPassed) {(!$TestResults)}
			elseif (!$NoResults) {$TestResults}
			return
		}
		
		# Add Parameters to test arguments
		$TestArguments = "${MsTestParameters}"
		
		# Add test names the arguments and unique, if chosen
		if ($Tests)
		{
			$TestArguments = "${TestArguments} /test:"
			foreach ($Test in $Tests) {$TestArguments = "${TestArguments}${Test},"}
			$TestArguments = $($TestArguments.Substring(0,$TestArguments.Length-1))
			if ($Unique) { $TestArguments = "${TestArguments} /unique" }
		}
		
		foreach ($TestContainer in $TestContainers)
		{
			if ($Verbose) { Write-Host "Loading Test: " (Dir $TestContainer).BaseName -ForegroundColor Cyan }
			$TestArguments = "${TestArguments} /testcontainer:${TestContainer}" 
		}
		
		# Select our window style
		$WindowStyle = if ($ShowTestWindow -or $PromptToCloseTestWindow) { "Normal" } else { "Hidden" }
	
		# Time Stamp will be added to our test files
		$TimeStamp = (Get-Date -Format "yyyy-MM-dd hh_mm_ss")
		
		# Add the test results file to the directory
		$TestResultsFile = "InvokeMsTestResults ${TimeStamp}.trx"
		$TestResultsFile = Join-Path $ResultsDirectoryPath $TestResultsFile
		$TestArguments = "${TestArguments} /resultsfile:`"${TestResultsFile}`""
	
		# Construct the test test cmd argument
		$PauseForInput = if ($PromptToCloseTestWindow) { "Pause & " } else { "" }
		$TestCmdArgument = "/k "" ""${MsTest}"" ${TestArguments} & ${PauseForInput} Exit"" "
	
		# Starts the MsTests
		Start-Process cmd.exe -ArgumentList $TestCmdArgument -WindowStyle $windowStyle -Wait
		
		if (Test-Path $TestResultsFile)
		{
			$TestResultsFileXml = [Xml](Get-Content -Path $TestResultsFile)
			$ns = New-Object Xml.XmlNamespaceManager $TestResultsFileXml.NameTable
			$ns.AddNamespace('dns','http://microsoft.com/schemas/VisualStudio/TeamTest/2010')
			
			$UnitTests = $TestResultsFileXml.SelectNodes('//dns:TestRun/dns:TestDefinitions/dns:UnitTest',$ns)
			$UnitTestResults = $TestResultsFileXml.SelectNodes('//dns:TestRun/dns:Results/dns:UnitTestResult',$ns)

			if ($Verbose) { Write-Host "--------------------------------------------------------------------------------`r`nResults`r`n--------------------------------------------------------------------------------" }

			Foreach ($UnitTestResult in $UnitTestResults)
			{	
				$Storage = ($UnitTests | Where {$_.id -eq $UnitTestResult.testId}).storage
				$TestResults += New-Object PSObject -Property @{Outcome=$UnitTestResult.outcome;Name=$UnitTestResult.testName;Storage=$Storage;Comments=""}
				if ($Verbose)
				{
					if ($UnitTestResult.outcome -eq "passed") { $Color = 'Green' }
					elseif ($UnitTestResult.outcome -eq "warning") { $Color = 'Yellow' }
					elseif ($UnitTestResult.outcome -eq "failed") { $Color = 'Red' }
					else { $Color = 'Gray' }
					
					Write-Host $UnitTestResult.outcome"`t"$UnitTestResult.testName"`t"$Storage -ForegroundColor $Color
					
				}
			}
		}
		else
		{
			if ($Verbose) { Write-Host "Error: Could not find or open test results file." }
			$TestResults += New-Object PSObject -Property @{Outcome="testerror";Name=(Dir $Path).Name;Storage="";Comments="Could not find or open test results file."}
		}
		if ($IsAllPassed) {(($TestResults.outcome | Where {$_ -eq "Passed"}).Count -eq $TestResults.Count)}
		elseif (!$NoResults) {$TestResults}
	}
}
################################################################################
function Get-MsTest
{
	<#
	.SYNOPSIS
		Gets path for latest version of MsTest.exe
	
	.DESCRIPTION
		Gets path for latest version of MsTest.exe
	#>
	
	
	$MsTest = "$(Get-VsCommonTools)..\IDE\MsTest.exe"
	if (Test-Path $MsTest) {$MsTest}
	else {Write-Error "Unable to find MsTest.exe"}
}
################################################################################
function Get-VsCommonTools
{
	<#
	.SYNOPSIS
		Gets path to the current VS common tools
	
	.DESCRIPTION
		Gets path to the current VS common tools. 
		Current list supports VS11 and VS10, you may need to add to this list
		to satisfy your needs.
	#>
	
	$VsCommonToolsPaths = @(@($env:VS110COMNTOOLS,$env:VS100COMNTOOLS) | Where-Object {$_ -ne $null})
	if ($VsCommonToolsPaths.Count -ne 0) {$VsCommonToolsPaths[0]}
	else {Write-Error "Unable to find Visual Studio Common Tool Path."}
}
################################################################################
Export-ModuleMember -Function Invoke-MsTest

Take the module for a spin and let me know how well it works. Comments and suggestions are more than welcome.

Edit: Added a DefaultParameterSet.

%d bloggers like this: