AssertExceptionThrown.psm1

<#
.SYNOPSIS
Function wrapper that tests the exceptions a function should generate.
 
.DESCRIPTION
Function wrapper that tests the exceptions a function should generate. "Should -Throw" will
only check the exception message, not the type of exception. This function can test both
exception message and type.
 
.NOTES
Copyright: (c) 2018 Simon Elms
Requires: PowerShell 5 (may work on earlier versions but untested)
                Pester 4 (may work on earlier versions but untested)
Version: 1.0.0
Date: 22 Feb 2018
 
Usage is similar to that for Pester "Should -Throw": Wrap the function under test, along with
any arguments, in curly braces and pipe it to Assert-ExceptionThrown.
 
.PARAMETER FunctionScriptBlock
A call to the function under test, including any arguments, wrapped in curly braces to form a
scriptblock.
 
.PARAMETER WithTypeName
The type name of the exception that is expected to be thrown by the function under test.
 
The test passes if the type name specified via -WithTypeName matches the end of the full type
name of the exception that is thrown. This allows leading namespaces to be left out of the
expected type name.
 
So, for example, if the function under test throws a
System.Management.Automation.ActionPreferenceStopException,
the following will pass:
    -WithTypeName 'System.Management.Automation.ActionPreferenceStopException'
    -WithTypeName 'Management.Automation.ActionPreferenceStopException'
    -WithTypeName 'Automation.ActionPreferenceStopException'
    -WithTypeName 'ActionPreferenceStopException'
 
The test will fail if truncated namespaces or class names are specified. For example, the
following will fail:
    -WithTypeName 'mation.ActionPreferenceStopException' (truncated namespace 'Automation')
    -WithTypeName 'System.Management.Automation.ActionPref' (truncated class name
                                                            'ActionPreferenceStopException')
    -WithTypeName 'StopException' (truncated class name 'ActionPreferenceStopException')
 
The comparison between the expected and actual exception type names is case insensitive.
 
.PARAMETER WithMessage
All or part of the exception message that is expected when the function under test is run.
  
The test is effectively "Actual exception message must contain WithMessage". The
comparison between the expected and actual exception messages is case insensitive.
 
.PARAMETER Not
A switch parameter that inverts the test.
 
The effect of the -Not parameter depends on whether -WithTypeName or
-WithMessage are set. If neither -WithTypeName nor
-WithMessage are set then -Not means "Function should not throw any exception."
 
If either -WithTypeName and/or -WithMessage are set then -Not means
"Function should not throw an exception with the specified class name and/or message." This
means the test will pass if no exception is thrown, or if an exception is thrown which does not
have the specified class name and/or message.
 
.EXAMPLE
Test that a function taking two arguments throws an exception with a specified message
 
{ MyFunctionWithTwoArgs -Key 'header' -Value 10 } |
    Assert-ExceptionThrown -WithMessage 'Value was of type int32, expected string'
 
The name of the function under test is MyFunctionWithTwoArgs. The test will only pass if
MyFunctionWithTwoArgs, with the specified arguments, throws an exception with a message
that contains the specified text.
 
.EXAMPLE
Test that a function taking no arguments throws an exception of a specified type
 
{ MyFunction } |
    Assert-ExceptionThrown -WithTypeName System.ArgumentException
 
The test will only pass if MyFunction throws an System.ArgumentException.
 
.EXAMPLE
Specify a short type name, without namespace, for the expected exception
 
{ MyFunction } |
    Assert-ExceptionThrown -WithTypeName ArgumentException
 
The test will pass if MyFunction throws a System.ArgumentException.
 
.EXAMPLE
Test that a function does not throw an exception
 
{ MyFunctionWithTwoArgs -Key 'header' -Value 'value' } |
    Assert-ExceptionThrown -Not
 
The test will pass only if MyFunctionWithTwoArgs, with the specified arguments, does not throw
any exception.
 
.EXAMPLE
Test that a function does not throw an exception with a specified message
 
{ MyFunctionWithTwoArgs -Key 'header' -Value 10 } |
    Assert-ExceptionThrown -Not -WithMessage 'Value was of type int32, expected string'
 
The test will fail if MyFunctionWithTwoArgs, with the specified arguments, throws an exception
with a message that contains the specified text. It will pass if MyFunctionWithTwoArgs does not
throw an exception, or if it throws an exception with a different message.
 
.EXAMPLE
Test that a function does not throw an exception of a specified type
 
{ MyFunction } |
    Assert-ExceptionThrown -Not -WithTypeName ArgumentException
 
The test will fail if MyFunction throws a System.ArgumentException. It will pass if MyFunction
does not throw an exception, or if it throws an exception of a different type.
 
.LINK
https://github.com/AnotherSadGit/PesterAssertExceptionThrown
#>

function Assert-ExceptionThrown 
(
    [Parameter(
        Position=0, 
        ValueFromPipeline=$true)
    ]
    [scriptblock]$FunctionScriptBlock, 

    [string]$WithTypeName,
    [string]$WithMessage, 
    [switch]$UseFullTypeName, 

    [switch]$Not
)
{
    process
    {
        if ($FunctionScriptBlock -eq $Null)
        {
            throw [ArgumentNullException] `
                "Script block for function under test not found. Input to 'Assert-ExceptionThrown' must be enclosed in curly braces."
        }

        try
        {
            Invoke-Command -ScriptBlock $FunctionScriptBlock
        }
        catch
        {
            $errorMessages = Private_GetExceptionError -Exception $_.Exception `
                -WithTypeName $WithTypeName `
                -WithMessage $WithMessage `
                -Not:$Not
                
            if ($errorMessages.Count -gt 0)
            {
                throw [System.Exception] ($errorMessages -join [Environment]::NewLine)
            }            

            return
        }

        # Will only get here if no exception was thrown by the function under test...
        
        if ([string]::IsNullOrWhiteSpace($WithTypeName) -and 
            [string]::IsNullOrWhiteSpace($WithMessage))
        {
            # No expectations were specified for the exception type or the exception message.
            # So in this case -Not means "Should not throw any exception."
            if ($Not)
            {
                return
            }
            
            # No exception expectations were specified and -Not was also not specified. This
            # means "Should throw an exception".
            throw [System.Exception] "Expected an exception to be thrown but none was."
        }

        # If any exception expectation was specified then -Not means "Should not throw an
        # exception of the specified type and/or with the specified message, as appropriate."
        # So not throwing an exception is a pass.
        if ($Not)
        {
            return
        }

        $errorMessages = @()

        if (-not [string]::IsNullOrWhiteSpace($WithTypeName))
        {
            $errorMessages += 
                "Expected $WithTypeName to be thrown but it wasn't."
        }

        if (-not [string]::IsNullOrWhiteSpace($WithMessage))
        {
            $errorMessages += 
                "Expected exception with message '$WithMessage' to be thrown but it wasn't."
        }

        if ($errorMessages.Count -gt 0)
        {
            throw [System.Exception] ($errorMessages -join [Environment]::NewLine)
        }
    }
}

<#
.SYNOPSIS
Gets an array of error messages where an exception does not meet expectations.
 
.DESCRIPTION
Gets an array of error messages where an exception does not meet expectations. If all
expectations are met the array will be empty.
#>

function Private_GetExceptionError
(
    [Exception]$Exception,

    [string]$WithTypeName,
    [string]$WithMessage, 

    [switch]$Not
)
{
    if ([string]::IsNullOrWhiteSpace($WithTypeName) -and 
        [string]::IsNullOrWhiteSpace($WithMessage))
    {
        # No expectations were specified for the exception type or the exception message.
        # So in this case -Not means "Should not throw any exception."
        if ($Not)
        {
            # Note leading comma to turn this error message into an array with a single item.
            return ,"Expected no exception but $($Exception.GetType().FullName) was thrown with message '$($Exception.Message)'."
        }
            
        # No exception expectations were specified and -Not was also not specified. This
        # means "Should throw an exception, any exception".
        return @()
    }

    $errorMessages = @()
    $exceptionTypeMatched = $True
    $exceptionMessageMatched = $True

    if (-not [string]::IsNullOrWhiteSpace($WithTypeName))
    {
        $exceptionTypeMatched = $False
        $actualExceptionTypeName = $Exception.GetType().FullName
        
        if ($actualExceptionTypeName.EndsWith($WithTypeName.Trim(), 
                                                [StringComparison]::CurrentCultureIgnoreCase))
        {
            # Don't allow silliness like
            # Assert-ExceptionThrown -WithTypeName 'stem.ArgumentException'
            # or
            # Assert-ExceptionThrown -WithTypeName 'tion'
            # to pass. Although namespaces can be left out of the expected type name, we don't
            # want truncated namespaces or truncated class names.

            $actualTypeNameParts = $actualExceptionTypeName -split '.', 0, 'simplematch'
            $expectedTypeNameParts = $WithTypeName.Trim() -split '.', 0, 'simplematch'
            if ($actualTypeNameParts.Count -lt $expectedTypeNameParts.Count)
            {
                $exceptionTypeMatched = $False
            }
            else
            {
                # To handle leading parts of the namespace being missed from the expected type
                # name, compare the parts of the type name in reverse.
                [array]::Reverse($actualTypeNameParts)
                [array]::Reverse($expectedTypeNameParts)
                
                $exceptionTypeMatched = $True
                for ($i = 0; $i -lt $expectedTypeNameParts.Count; $i++) 
                {
                    # -ine is case insensitive not equals.
                    if ($actualTypeNameParts[$i] -ine $expectedTypeNameParts[$i])
                    {
                        $exceptionTypeMatched = $False
                        break
                    }
                }
            }
        }
        if ($Not -and $exceptionTypeMatched)
        {
            $errorMessages += `
                "Expected an exception of a different type than $WithTypeName but exception thrown was of that type."
        }
        elseif (-not $Not -and -not $exceptionTypeMatched)
        {
            $errorMessages += `
                "Expected $WithTypeName but exception thrown was $actualExceptionTypeName."
        }

    }

    if (-not [string]::IsNullOrWhiteSpace($WithMessage))
    {
        # -ilike is case insensitive like.
        $exceptionMessageMatched = ($Exception.Message -ilike "*$WithMessage*")
        if ($Not -and $exceptionMessageMatched)
        {
            $errorMessages += `
                "Expected exception message different than '$WithMessage' but exception thrown had that message."
        }
        elseif (-not $Not -and -not $exceptionMessageMatched)
        {
            $errorMessages += `
                "Expected exception message '$WithMessage' but actual exception message was '$($Exception.Message)'."
        }
    }

    if ($Not)
    {
        if ($exceptionTypeMatched -and $exceptionMessageMatched)
        {
            return $errorMessages
        }
        else
        {
            return @()
        }
    }
    else
    {
        if ($exceptionTypeMatched -and $exceptionMessageMatched)
        {
            return @()
        }
        else
        {
            return $errorMessages
        }
    }
}

# Only export public functions. To simplify the exporting of public functions but not private
# ones public functions must follow the standard PowerShell naming convention,
# "<verb>-<singular noun>", while private functions must not contain a dash, "-".
Export-ModuleMember -Function *-*