blade.ps1

<#
.SYNOPSIS
Runs Blade tests in a file or set of directories.
 
.DESCRIPTION
Blade is a simple testing framework, inspired by NUnit. It reads in all the files under a given path (or paths), and opens each file that matches the `Test-*` pattern. It will then execute the tests in that file. Blade tests are functions that that use the `Test` verb in their name, i.e. whose name match the `Test-*` pattern.
 
When executing the tests in a file, Blade does the following:
 
 * Calls the `Start-TestFixture` function (if one is defined)
 * Executes each test. For each test, Blade calls the `Start-Test` function (if defined), followed by the test, followed by the `Stop-Test` function (if defined).
 * Calls the `Stop-TestFixture` function (if one is defined)
 
Blade will return `Blade.TestResult` objects for all failed tests and a final `Blade.RunResult` object summarizing the results. Use the `PassThru` switch to also get `Blade.TestResult` objects for passing tests.
 
You can access the `Blade.RunResult` object from the last test run via the global `LASTBLADERESULT` variable.
 
.LINK
about_Blade
 
.EXAMPLE
.\blade Test-MyScript.ps1
 
Will run all the tests in the `Test-MyScript.ps1` script.
 
.EXAMPLE
.\blade Test-MyScript.ps1 -Test MyTest
 
Will run the `MyTest` test in the `Test-MyScript.ps1` test script.
 
.EXAMPLE
blade .\MyModule
 
Will run all tests in the files which match the `Test-*.ps1` wildcard in the .\MyModule directory.
 
.EXAMPLE
blade .\MyModule -Recurse
 
Will run all test in files which match the `Test-*.ps1` wildcard under the .\MyModule directory and its sub-directories.
 
#>

# Copyright 2012 - 2014 Aaron Jensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[CmdletBinding()]
param(
    [Parameter(Mandatory=$true,Position=0)]
    [string[]]
    # The paths to search for tests. All files matching Test-*.ps1 will be run.
    $Path,

    [string]
    # The name of the tests being run.
    $Name,

    [string[]]
    # The individual test in the script to run. Defaults to all tests.
    $Test,

    [string]
    # Path to the file where XML results should be saved.
    $XmlLogPath,
    
    [Switch]
    # Return objects for each test run, and a final summary object.
    $PassThru,
    
    [Switch]
    # Recurse through directories under `$Path` to find tests.
    $Recurse
)

#Requires -Version 3
Set-StrictMode -Version 'Latest'

& (Join-Path -Path $PSScriptRoot -ChildPath 'Import-Blade.ps1' -Resolve)

function Get-FunctionsInFile($testScript)
{
    Write-Verbose "Loading test script '$testScript'."
    $testScriptContent = Get-Content "$testScript"
    if( -not $testScriptContent )
    {
        return @()
    }

    $errors = [Management.Automation.PSParseError[]] @()
    $tokens = [System.Management.Automation.PsParser]::Tokenize( $testScriptContent, [ref] $errors )
    if( $errors -ne $null -and $errors.Count -gt 0 )
    {
        Write-Error "Found $($errors.count) error(s) parsing '$testScript'."
        return
    }
    
    Write-Verbose "Found $($tokens.Count) tokens in '$testScript'."
    
    $functions = New-Object System.Collections.ArrayList
    $atFunction = $false
    for( $idx = 0; $idx -lt $tokens.Count; ++$idx )
    {
        $token = $tokens[$idx]
        if( $token.Type -eq 'Keyword'-and $token.Content -eq 'Function' )
        {
            $atFunction = $true
        }
        
        if( $atFunction -and $token.Type -eq 'CommandArgument' -and $token.Content -ne '' )
        {
            Write-Verbose "Found function '$($token.Content).'"
            [void] $functions.Add( $token.Content )
            $atFunction = $false
        }
    }
    
    return $functions.ToArray()
}

function Invoke-Test
{
    <#
    .SYNOPSIS
    PRIVATE. Invokes a test from a fixture.
 
    .DESCRIPTION
    Internal function. Do not use.
    #>

    [CmdletBinding()]
    param(
        $fixture, 
        $function
    )

    Set-StrictMode -Version 'Latest'

    [Blade.TestResult]$testInfo = New-Object 'Blade.TestResult' $fixture,$function

    $Error.Clear()

    $testPassed = $false
    try
    {
        if( Test-path function:Start-Test )
        {
            . Start-Test | ForEach-Object { $testInfo.Output.Add( $_ ) }
        }
        elseif( Test-Path function:SetUp )
        {
            . SetUp | ForEach-Object { $testInfo.Output.Add( $_ ) }
        }
        
        if( Test-Path function:$function )
        {
            . $function | ForEach-Object { $testInfo.Output.Add( $_ ) }
        }
        $testPassed = $true
    }
    catch [Blade.AssertionException]
    {
        $ex = $_.Exception
        $testInfo.Completed( $ex )
    }
    catch
    {
        $testInfo.Completed( $_ )
    }
    finally
    {
        $tearDownResult = New-Object 'Blade.TestResult' $fixture,$function
        $tearDownFailed = $false
        try
        {
            if( Test-Path function:Stop-Test )
            {
                . Stop-Test | ForEach-Object { $tearDownResult.Output.Add( $_ ) }
            }
            elseif( Test-Path -Path function:TearDown )
            {
                . TearDown | ForEach-Object { $tearDownResult.Output.Add( $_ ) }
            }
            $tearDownResult.Completed()
        }
        catch
        {
            $tearDownResult.Completed( $_ )
            $tearDownFailed = $true
        }
        finally
        {
            if( $testPassed )
            {
                $testInfo.Completed()
            }
            $testInfo
            if( $tearDownFailed )
            {
                $tearDownResult
            }
        }

        $Error.Clear()
    }

}

$getChildItemParams = @{ }
if( $Recurse )
{
    $getChildItemParams.Recurse = $true
}

$testScripts = @( Get-ChildItem $Path Test-*.ps1 @getChildItemParams )
if( $testScripts -eq $null )
{
    $testScripts = @()
}

$Error.Clear()
$testsIgnored = 0
$TestScript = $null
$TestDir = $null

$results = $null
$testScripts | 
    ForEach-Object {
        $testCase = $_
        $TestScript = (Resolve-Path $testCase.FullName).Path
        $TestDir = Split-Path -Parent $testCase.FullName 
        
        $testModuleName =  [System.IO.Path]::GetFileNameWithoutExtension($testCase)

        $functions = Get-FunctionsInFile $testCase.FullName |
                        Where-Object { $_ -match '^(Test|Ignore)-(.*)$' } |
                        Where-Object { 
                            if( $PSBoundParameters.ContainsKey('Test') )
                            {
                                return $Test | Where-Object { $Matches[2] -like $_ } 
                            }

                            if( $Matches[1] -eq 'Ignore' )
                            {
                                Write-Warning ("Skipping ignored test '{0}'." -f $_)
                                $testsIgnored++
                                return $false
                            }

                            return $true
                        }
        if( -not $functions )
        {
            return
        }

        @('Start-TestFixture','Start-Test','Setup','TearDown','Stop-Test','Stop-TestFixture') |
            ForEach-Object { Join-Path -Path 'function:' -ChildPath $_ } |
            Where-Object { Test-Path -Path $_ } |
            Remove-Item
        
        . $testCase.FullName
        try
        {
            if( Test-Path -Path 'function:Start-TestFixture' )
            {
                . Start-TestFixture | Write-Verbose
            }

            foreach( $function in $functions )
            {

                if( -not (Test-Path -Path function:$function) )
                {
                    continue
                }
                
                Invoke-Test $testModuleName $function
            }

            if( Test-Path -Path function:Stop-TestFixture )
            {
                try
                {
                    . Stop-TestFixture | Write-Verbose
                }
                catch
                {
                    Write-Error ("An error occured tearing down test fixture '{0}': {1}" -f $testCase.Name,$_)
                    $result = New-Object 'Blade.TestResult' $testModuleName,'Stop-TestFixture'
                    $result.Finished( $_ )
                }                
            }
        }
        finally
        {
            foreach( $function in $functions )
            {
                if( $function -and (Test-Path function:$function) )
                {
                    Remove-Item function:\$function
                }
            }
        }        
    } | 
    Tee-Object -Variable 'results' |
    Where-Object { $PassThru -or -not $_.Passed } 

$Global:LastBladeResult = New-Object 'Blade.RunResult' $Name,([Blade.TestResult[]]$results), $testsIgnored
if( $LastBladeResult.Errors -or $LastBladeResult.Failures )
{
    Write-Error $LastBladeResult.ToString()
}

if( $XmlLogPath )
{
    $LastBladeResult | Export-RunResultXml -FilePath $XmlLogPath
}

if( $PassThru )
{
    $LastBladeResult
}
else
{
    $LastBladeResult | Format-Table | Out-Host
}