Modules/businessdev.ALbuild.Apps/Public/Test-BcTestAssertion.ps1

function Test-BcTestAssertion {
    <#
    .SYNOPSIS
        Gates a build on every AL [Test] procedure containing at least one assertion (anti-mock gate).
 
    .DESCRIPTION
        A green test suite proves nothing if the tests assert nothing: a [Test] that only calls the code
        under test passes whatever the code returns, and it still counts as "covered". This scans the AL
        [Test] procedures in a workspace and reports every one whose body contains no recognisable
        assertion, so a pull request cannot introduce tests that cannot fail.
 
        Recognised as an assertion:
          * the assertion library - Assert.*, LibraryAssert.*, Assertion.*
          * assertion members - .AreEqual/.AreNotEqual/.IsTrue/.IsFalse/.IsEmpty/.RecordCount/
                                         .ExpectedError/.KnownFailure/.Equal/.NotEqual/.Fail
          * the AL error keywords - asserterror, TestField(), FieldError()
          * a guard Error() - the hand-rolled assert pattern `if <unexpected> then Error(...)`
          * verification helpers - a call whose name contains Verify/Validate/Ensure/Assert/Check,
                                         so a test that delegates its checks to a helper procedure is not
                                         reported. Use -IgnoreVerificationHelpers for the stricter reading.
 
        An empty test body is reported as well (reason 'Empty'). The scan is deliberately generous: it
        must not cry wolf on a real test, because a gate that produces false positives gets switched off.
 
        Related: Get-BcTestQuality scores the whole suite (assertion density, arrange/act/assert smells)
        for reporting; this cmdlet answers the single yes/no question a build gate needs and shares the
        same AL scan, so the two can never disagree about which procedures are tests.
 
    .PARAMETER WorkspaceRoot
        One or more AL source roots to scan for [Test] procedures. Several roots are scanned as ONE suite
        (a test found under two overlapping roots is counted once), so a multi-project repository yields
        a single verdict instead of one per project.
 
    .PARAMETER Path
        A single .al file to check instead of a whole workspace.
 
    .PARAMETER AssertionPattern
        Overrides the built-in assertion regex (case-insensitive, matched per statement line) - for a
        product whose tests assert through an in-house library the default does not know.
 
    .PARAMETER IgnoreVerificationHelpers
        Do not accept a call to a Verify*/Validate*/Ensure*/Assert*/Check* helper as an assertion. Stricter:
        every test must then assert in its own body.
 
    .PARAMETER ThrowOnFailure
        Throw a terminating error when at least one test has no assertion (for use as a pipeline gate).
 
    .PARAMETER Quiet
        Suppress the gate's own PASSED/FAILED line and the offender list. For callers that report the
        verdict themselves -- the Azure DevOps task renders it as a pipeline issue, as a warning or an
        error depending on failOnIssue -- so the same result is not logged twice.
 
    .EXAMPLE
        Test-BcTestAssertion -WorkspaceRoot . -ThrowOnFailure
        Fail the build if any [Test] procedure in the repository asserts nothing.
 
    .EXAMPLE
        (Test-BcTestAssertion -WorkspaceRoot .).offenders | Format-Table codeunit, testName, line
        List the tests that need an assertion.
 
    .OUTPUTS
        PSCustomObject verdict: passed, testCount, testCodeunitCount, offenderCount, offenders[].
    #>

    [CmdletBinding(DefaultParameterSetName = 'Workspace')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Workspace')] [ValidateNotNullOrEmpty()] [string[]] $WorkspaceRoot,
        [Parameter(Mandatory, ParameterSetName = 'File')] [ValidateNotNullOrEmpty()] [string] $Path,
        [string] $AssertionPattern,
        [switch] $IgnoreVerificationHelpers,
        [switch] $ThrowOnFailure,
        [switch] $Quiet
    )

    $helperRx = '\b\w*(verify|validate|ensure|assert|check)\w*\s*\('
    $builtIn = @(
        '\b(assert|libraryassert|assertion)\s*\.'
        '\.(areequal|arenotequal|istrue|isfalse|isempty|isnotempty|recordcount|expectederror|knownfailure|equal|notequal|fail)\s*\('
        '\basserterror\b'
        '\.(testfield|fielderror)\s*\('
        '\berror\s*\('
    )
    if (-not $IgnoreVerificationHelpers) { $builtIn += $helperRx }

    $pattern = if ($AssertionPattern) { $AssertionPattern } else { '(?i)(' + ($builtIn -join '|') + ')' }
    $assertRx = [regex]::new($pattern)

    # @(...) around the whole expression: a single test would otherwise be unrolled to a scalar, and
    # .Count on a scalar throws under StrictMode on Windows PowerShell 5.1.
    $tests = @(if ($PSCmdlet.ParameterSetName -eq 'File') { Get-BcAlTestProcedure -Path $Path }
    else {
        $found = [System.Collections.Generic.List[object]]::new()
        foreach ($root in $WorkspaceRoot) {
            foreach ($proc in (Get-BcAlTestProcedure -WorkspaceRoot $root)) { $found.Add($proc) }
        }
        # Overlapping roots (a project inside another) would otherwise count the same test twice.
        $found | Sort-Object FilePath, Line -Unique
    })

    $offenders = [System.Collections.Generic.List[object]]::new()
    foreach ($proc in @($tests)) {
        $statements = @($proc.Statements)
        $asserts = @($statements | Where-Object { $assertRx.IsMatch($_) }).Count
        if ($asserts -gt 0) { continue }
        $offenders.Add([PSCustomObject]@{
                codeunit       = $proc.Codeunit
                testName       = $proc.TestName
                filePath       = $proc.FilePath
                line           = $proc.Line
                statementCount = $statements.Count
                reason         = if ($statements.Count -eq 0) { 'Empty' } else { 'NoAssertions' }
            })
    }

    $verdict = [PSCustomObject]@{
        passed              = ($offenders.Count -eq 0)
        testCount           = $tests.Count
        testCodeunitCount   = @($tests | Select-Object -ExpandProperty Codeunit -Unique).Count
        offenderCount       = $offenders.Count
        offenders           = @($offenders)
        assertionPattern    = $pattern
        helperCallsAccepted = (-not $IgnoreVerificationHelpers)
    }

    if ($verdict.passed) {
        if (-not $Quiet) {
            Write-ALbuildLog -Level Success ("Assertion gate PASSED: all {0} [Test] procedure(s) in {1} codeunit(s) assert." -f $verdict.testCount, $verdict.testCodeunitCount)
        }
    }
    else {
        $msg = "{0} of {1} [Test] procedure(s) contain no assertion - they cannot fail." -f $offenders.Count, $verdict.testCount
        if (-not $Quiet) {
            # Offenders as plain lines: one ##vso error per test would bury the summary in the pipeline UI.
            foreach ($o in $offenders) {
                Write-ALbuildLog (" {0}::{1} ({2}:{3}) - {4}" -f $o.codeunit, $o.testName, (Split-Path -Leaf $o.filePath), $o.line, $o.reason)
            }
            Write-ALbuildLog -Level Warning "Assertion gate FAILED: $msg"
        }
        if ($ThrowOnFailure) { throw "Assertion gate failed: $msg" }
    }

    return $verdict
}