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 } |