From c36ac3210086a7836d2a1633e39ec05adc18ac10 Mon Sep 17 00:00:00 2001 From: Volodymyr Dvernytskyi Date: Sun, 19 Apr 2026 23:04:02 +0300 Subject: [PATCH 1/4] Add test isolation support --- Actions/.Modules/ReadSettings.psm1 | 5 + Actions/.Modules/TestIsolation.psm1 | 95 ++++++++++++ Actions/.Modules/settings.schema.json | 38 +++++ Actions/RunPipeline/RunPipeline.ps1 | 6 + README.md | 1 + RELEASENOTES.md | 23 +++ Scenarios/TestIsolation.md | 100 +++++++++++++ Scenarios/settings.md | 1 + Tests/ReadSettings.Test.ps1 | 92 ++++++++++++ Tests/TestIsolation.Test.ps1 | 198 ++++++++++++++++++++++++++ 10 files changed, 559 insertions(+) create mode 100644 Actions/.Modules/TestIsolation.psm1 create mode 100644 Scenarios/TestIsolation.md create mode 100644 Tests/TestIsolation.Test.ps1 diff --git a/Actions/.Modules/ReadSettings.psm1 b/Actions/.Modules/ReadSettings.psm1 index ea3bbe350a..9a12ac685a 100644 --- a/Actions/.Modules/ReadSettings.psm1 +++ b/Actions/.Modules/ReadSettings.psm1 @@ -175,6 +175,11 @@ function GetDefaultSettings "doNotRunTests" = $false "doNotRunBcptTests" = $false "doNotRunPageScriptingTests" = $false + "testIsolation" = [ordered]@{ + "enabled" = $false + "defaultRunnerCodeunitId" = 0 + "partitions" = @() + } "doNotPublishApps" = $false "doNotSignApps" = $false "configPackages" = @() diff --git a/Actions/.Modules/TestIsolation.psm1 b/Actions/.Modules/TestIsolation.psm1 new file mode 100644 index 0000000000..4a6ecf0424 --- /dev/null +++ b/Actions/.Modules/TestIsolation.psm1 @@ -0,0 +1,95 @@ +# Test Isolation module for AL-Go for GitHub +# Builds a scriptblock compatible with Run-AlPipeline's -RunTestsInBcContainer +# override. The scriptblock invokes Run-TestsInBcContainer once per declared +# partition (each with its own test runner and codeunit-range filter), plus one +# trailing call under the default runner whose filter excludes every codeunit +# matched by the explicit partitions. +# See TestIsolation-Plan.md at the repo root. + +function New-PartitionedTestRunnerScriptBlock { + <# + .SYNOPSIS + Build a scriptblock for Run-AlPipeline's -RunTestsInBcContainer hook. + .DESCRIPTION + Run-AlPipeline invokes the override once per test app with a hashtable + of parameters (extensionId, containerName, disabledTests, JUnit/XUnit + file, AppendTo*ResultFile, auth context, etc.). The returned + scriptblock loops over the configured partitions and, for each one, + invokes Run-TestsInBcContainer with the partition's runner id and + codeunit-range filter. After all partitions, it issues one trailing + call under defaultRunnerCodeunitId whose -testCodeunitRange is the + negation of the union of every explicit partition's filter — so every + test codeunit not matched by an explicit partition runs under the + default runner exactly once. + + Result-file appending is preserved because we forward the JUnit/XUnit + file params Run-AlPipeline already configured (AppendTo*ResultFile = $true). + .PARAMETER Settings + The testIsolation settings object with `defaultRunnerCodeunitId` and + `partitions` (array of @{ runnerCodeunitId; codeunits }). Closed over + by the returned scriptblock. + .OUTPUTS + [scriptblock] returning $true if every invocation reported success. + #> + Param( + [Parameter(Mandatory = $true)] + $Settings + ) + + $capturedPartitions = @($Settings.partitions) + $capturedDefaultRunner = [int] $Settings.defaultRunnerCodeunitId + + # Build the negative filter for the trailing default-runner call by + # collecting every '|'-separated piece from every partition's codeunits + # filter, prefixing each with '<>' and joining with '&'. Result is a + # BC integer-field filter expression that matches every codeunit ID NOT + # covered by an explicit partition. + $defaultRangeFilter = '' + if ($capturedPartitions.Count -gt 0) { + $negatedPieces = @() + foreach ($p in $capturedPartitions) { + foreach ($piece in ([string] $p.codeunits).Split('|')) { + $trimmed = $piece.Trim() + if ($trimmed) { $negatedPieces += "<>$trimmed" } + } + } + $defaultRangeFilter = $negatedPieces -join '&' + } + + return { + Param([Hashtable] $parameters) + + $appId = "$($parameters.extensionId)" + $allPassed = $true + $invocations = 0 + + foreach ($p in $capturedPartitions) { + $call = @{} + foreach ($k in $parameters.Keys) { $call[$k] = $parameters[$k] } + $call['testCodeunitRange'] = "$($p.codeunits)" + $call['testRunnerCodeunitId'] = "$([int] $p.runnerCodeunitId)" + + Write-Host "Running partition runner=$($p.runnerCodeunitId) range='$($p.codeunits)' app=$appId" + $invocations++ + + $passed = Run-TestsInBcContainer @call + if (-not $passed) { $allPassed = $false } + } + + $defaultCall = @{} + foreach ($k in $parameters.Keys) { $defaultCall[$k] = $parameters[$k] } + if ($defaultRangeFilter) { $defaultCall['testCodeunitRange'] = $defaultRangeFilter } + if ($capturedDefaultRunner -gt 0) { $defaultCall['testRunnerCodeunitId'] = "$capturedDefaultRunner" } + + Write-Host "Running default partition runner=$capturedDefaultRunner range='$defaultRangeFilter' app=$appId" + $invocations++ + + $passed = Run-TestsInBcContainer @defaultCall + if (-not $passed) { $allPassed = $false } + + Write-Host "Partitioned test run for app $appId complete. Invocations: $invocations. All passed: $allPassed" + return $allPassed + }.GetNewClosure() +} + +Export-ModuleMember -Function New-PartitionedTestRunnerScriptBlock diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index 2c90ab3b21..a071bdfbf9 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -323,6 +323,44 @@ "treatTestFailuresAsWarnings": { "type": "boolean" }, + "testIsolation": { + "type": "object", + "additionalProperties": false, + "required": [ "enabled", "defaultRunnerCodeunitId", "partitions" ], + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable partitioned test runs. When true, AL-Go invokes Run-TestsInBcContainer once per entry in 'partitions' (each with its own test runner) plus one fallback call covering everything else under 'defaultRunnerCodeunitId'. See https://aka.ms/ALGoSettings#testIsolation" + }, + "defaultRunnerCodeunitId": { + "type": "integer", + "minimum": 0, + "description": "Test runner codeunit ID used for codeunits not matched by any 'partitions' entry. 0 = the BcContainerHelper default runner." + }, + "partitions": { + "type": "array", + "description": "Each entry runs the codeunits matching its 'codeunits' BC filter under the configured 'runnerCodeunitId'. Codeunits not matched by any entry fall through to 'defaultRunnerCodeunitId'.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ "runnerCodeunitId", "codeunits" ], + "properties": { + "runnerCodeunitId": { + "type": "integer", + "minimum": 1, + "description": "ID of a Subtype = TestRunner codeunit whose TestIsolation property satisfies the codeunits matched below." + }, + "codeunits": { + "type": "string", + "minLength": 1, + "description": "BC filter expression against the codeunit ID (e.g. '60100|60200..60299'). Same syntax as the BC Test Tool's TestCodeunitRangeFilter." + } + } + } + } + }, + "description": "See https://aka.ms/ALGoSettings#testIsolation" + }, "rulesetFile": { "type": "string" }, diff --git a/Actions/RunPipeline/RunPipeline.ps1 b/Actions/RunPipeline/RunPipeline.ps1 index 03e3a040ef..137f6feffb 100644 --- a/Actions/RunPipeline/RunPipeline.ps1 +++ b/Actions/RunPipeline/RunPipeline.ps1 @@ -487,6 +487,12 @@ try { $runAlPipelineParams["preprocessorsymbols"] = $settings.preprocessorSymbols $runAlPipelineParams["features"] = $settings.features + if ($settings.testIsolation.enabled -and -not $settings.doNotRunTests) { + Import-Module (Join-Path $PSScriptRoot "..\.Modules\TestIsolation.psm1" -Resolve) + Write-Host "Test isolation enabled - $($settings.testIsolation.partitions.Count) explicit partition(s) + default runner ($($settings.testIsolation.defaultRunnerCodeunitId))" + $runAlPipelineParams["RunTestsInBcContainer"] = New-PartitionedTestRunnerScriptBlock -Settings $settings.testIsolation + } + Write-Host "Invoke Run-AlPipeline with buildmode $buildMode" Run-AlPipeline @runAlPipelineParams ` -accept_insiderEula ` diff --git a/README.md b/README.md index 489e9259c8..d21e5750d4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Try out the [AL-Go workshop](https://aka.ms/algoworkshop) for an in-depth worksh 1. [DeliveryTargets and NuGet/GitHub Packages](Scenarios/DeliveryTargets.md) 1. [Enabling Telemetry for AL-Go workflows and actions](Scenarios/EnablingTelemetry.md) 1. [Add a performance test app to an existing project](Scenarios/AddAPerformanceTestApp.md) +1. [Partition test runs by required isolation](Scenarios/TestIsolation.md) 1. [Publish your app to AppSource](Scenarios/PublishToAppSource.md) 1. [Connect your GitHub repository to Power Platform](Scenarios/SetupPowerPlatform.md) 1. [How to set up Service Principal for Power Platform](Scenarios/SetupServicePrincipalForPowerPlatform.md) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index adfc952a6c..e9fa8a6da4 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,3 +1,26 @@ +### Test Isolation - run selected test codeunits under a custom test runner + +Business Central test codeunits can declare a `RequiredTestIsolation` value (BC runtime 16+) that tells the runtime which transactional isolation they need. The standard test runner shipped by BC has a single fixed `TestIsolation` value and cannot satisfy multiple requirements at once. AL-Go for GitHub now supports running tests under multiple runners in a single pipeline pass. + +When `testIsolation.enabled` is set in your settings, AL-Go partitions the test stage: each entry in `partitions` runs the matched codeunits under the configured `runnerCodeunitId`, and everything else runs under `defaultRunnerCodeunitId`. Results are merged back into the same JUnit file downstream reporting already consumes; container lifecycle and `disabledTests.json` handling are unchanged. + +Enable it by adding: + +```json +{ + "testIsolation": { + "enabled": true, + "defaultRunnerCodeunitId": 0, + "partitions": [ + { "runnerCodeunitId": 130451, "codeunits": "60200..60299" }, + { "runnerCodeunitId": 130452, "codeunits": "60300|60301" } + ] + } +} +``` + +Requires BC 15+ (the test page must expose the `TestCodeunitRangeFilter` control used by BcContainerHelper for codeunit-range filtering). See the full settings reference and compatibility notes in [Test Isolation](Scenarios/TestIsolation.md). The feature is opt-in - existing projects are unaffected unless they set `testIsolation.enabled = true`. + ### Issues - Issue 2204 - Workspace compilation ignores vsixFile setting diff --git a/Scenarios/TestIsolation.md b/Scenarios/TestIsolation.md new file mode 100644 index 0000000000..672621343a --- /dev/null +++ b/Scenarios/TestIsolation.md @@ -0,0 +1,100 @@ +# Partitioning tests by required isolation + +Business Central test codeunits can declare, per codeunit, what transactional isolation they need from the test runner that executes them (`RequiredTestIsolation`, runtime 16+). The standard test runner shipped by BC has a single fixed `TestIsolation` value and cannot satisfy multiple requirements at once. AL-Go for GitHub lets you split a single test stage into multiple runs — each driven by a test runner whose `TestIsolation` matches a chosen group of codeunits — so tests behave the same in CI as they do in the BC Test Tool. + +## Background: three AL properties + +| Property | Applies to | Values | Runtime | +|---|---|---|---| +| `TestIsolation` | Test **runner** codeunit (`Subtype = TestRunner`) | `Disabled` (default), `Codeunit`, `Function` | 1.0 | +| `RequiredTestIsolation` | Test codeunit (`Subtype = Test`) | `None` (default), `Disabled`, `Codeunit`, `Function` | 16.0 (BC 2025 W2+) | +| `TestType` | Test codeunit | `UnitTest` (default), `IntegrationTest`, `Uncategorized`, `AITest` | 16.0 | + +The runner's `TestIsolation` decides actual database rollback behavior after tests execute. The test codeunit's `RequiredTestIsolation` is a declaration of what the codeunit expects. If the runner doesn't satisfy it, the test may fail. See Microsoft's [TestIsolation property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-testisolation-property) and [RequiredTestIsolation property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-requiredtestisolation-property) docs for full semantics. + +## When to enable this + +Enable `testIsolation` only if some of your test codeunits need a non-default test runner. Projects whose tests all run under the BC default runner do not need this feature. + +You supply the runner codeunits — typically by adopting one of the [BCApps Test Runner](https://github.com/microsoft/BCApps/tree/main/src/Tools/Test%20Framework/Test%20Runner) codeunits, or by authoring your own `Subtype = TestRunner` codeunit with the `TestIsolation` property set to the value you need. Your runner codeunit must be installed in the container at test time (typically as part of a test app or a test-runner app). + +## Configuration + +Add a `testIsolation` block to your `.AL-Go/settings.json` (or any higher-precedence settings location — see [settings.md](settings.md#where-are-the-settings-located)): + +```json +{ + "testIsolation": { + "enabled": true, + "defaultRunnerCodeunitId": 0, + "partitions": [ + { "runnerCodeunitId": 130451, "codeunits": "60200..60299" }, + { "runnerCodeunitId": 130452, "codeunits": "60300|60301" } + ] + } +} +``` + +| Key | Meaning | +|---|---| +| `enabled` | Master switch. `false` (default) — AL-Go uses the standard single-pass test behavior. | +| `defaultRunnerCodeunitId` | Runner used for every test codeunit not matched by an entry in `partitions`. `0` means "let BcContainerHelper pick the BC default runner." | +| `partitions[].runnerCodeunitId` | Codeunit ID of the test runner that will execute the codeunits matched by this entry. Must be a `Subtype = TestRunner` codeunit reachable in the container. | +| `partitions[].codeunits` | A BC filter expression matching the test codeunits to run under that runner. Same syntax you would type into the BC Test Tool's `TestCodeunitRangeFilter` — see below. | + +### `codeunits` filter syntax + +`codeunits` is passed verbatim to BC's test runner page filter, so anything BC accepts as an integer-field filter works: + +| Syntax | Meaning | +|---|---| +| `60100` | exact codeunit ID | +| `60100\|60101\|60102` | enumeration (OR) | +| `60100..60199` | inclusive range | +| `60100\|60200..60299` | combined | + +You don't need to use `<>` (not equal) in `codeunits` — AL-Go automatically derives the inverse to route every other codeunit to `defaultRunnerCodeunitId`. + +## How AL-Go uses this + +When `testIsolation.enabled` is `true`, AL-Go installs a custom `RunTestsInBcContainer` scriptblock into Run-AlPipeline. For each test app, the scriptblock: + +1. Invokes `Run-TestsInBcContainer` once per entry in `partitions`, with `-testRunnerCodeunitId` set to the partition's runner and `-testCodeunitRange` set to the partition's `codeunits` filter. +2. Issues one trailing call under `defaultRunnerCodeunitId` whose `-testCodeunitRange` is the negation of every explicit partition's filter — so every test codeunit not in any partition runs exactly once under the default runner. + +Container lifecycle, app installation, and `disabledTests.json` discovery continue to be handled by Run-AlPipeline. Results are appended into the same JUnit file that downstream reporting (`AnalyzeTests`) already consumes — no other workflow steps need to change. + +## Workflow-specific overrides + +The normal AL-Go settings cascade applies. To use partitioning only in your nightly workflow, add a `.AL-Go/.settings.json` containing the `testIsolation` block — the CI workflow runs unchanged. + +## Compatibility + +- **Requires BC 15+ (test page 130455).** Partitioning works by typing a filter expression into the BC test page's `TestCodeunitRangeFilter` control. That control exists on the standard test page used by BC 15 and later. On BC 14 and earlier (test page 130409) the control is not present, BcContainerHelper silently no-ops the filter, and every partition's call would run **all** test codeunits — producing wrong results. If you need this feature on an older BC version, supply a custom `testPage` setting that points to a page exposing the control, or stay on the single-pass behavior. +- **No source changes are required** to use this feature. The relationship between an in-source `RequiredTestIsolation = Function` declaration and a `partitions` entry that routes that codeunit to a `TestIsolation = Function` runner is your responsibility to keep aligned. Future BC tooling may automate this mapping; until then, settings are the source of truth for CI. +- **`disabledTests.json` continues to work transparently.** Run-AlPipeline aggregates the disabled-test list per app and passes it via the scriptblock parameters; AL-Go forwards it to every partition call, so a disabled test is excluded from every runner. +- **Existing projects are not affected** unless they opt in via `testIsolation.enabled = true`. + +## Edge cases + +- **Empty `partitions` with `enabled: true`.** The pipeline issues exactly one `Run-TestsInBcContainer` call per test app under `defaultRunnerCodeunitId` (or BC's default if it is `0`) with no codeunit filter. Use this when you want to swap the standard test runner project-wide without partitioning. +- **A codeunit ID listed in two `partitions` entries.** AL-Go does not validate this; both entries fire, so the codeunit runs twice under different runners and appears twice in the JUnit results. Make sure your `codeunits` filters do not overlap. +- **A codeunit ID listed in a `partitions` entry but not present in any test app.** BC's filter ignores unmatched IDs — harmless, no error. +- **Cloud / `useCompilerFolder` runs.** Run-AlPipeline calls the override with `compilerFolder` instead of `containerName`; AL-Go forwards parameters verbatim, so the same partitioning works. +- **BCPT and PageScripting tests** go through different Run-AlPipeline scriptblocks (`-RunBCPTTestsInBcContainer`, `-RunPageScriptingTestsInBcContainer`) and are unaffected by `testIsolation`. + +## Performance + +Each entry in `partitions` adds one `Run-TestsInBcContainer` call per test app, plus one trailing default-runner call. For N partitions and M test apps, that is `(N + 1) * M` invocations — versus 1 invocation per test app today. Each call has fixed per-invocation overhead (BC test page setup, control population). Enable `testIsolation` only when isolation requirements actually demand it. + +## Related + +- [TestIsolation property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-testisolation-property) +- [RequiredTestIsolation property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-requiredtestisolation-property) +- [TestType property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-testtype-property) +- [Test Runner codeunits](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-testrunner-codeunits) +- [BCApps Test Runner](https://github.com/microsoft/BCApps/tree/main/src/Tools/Test%20Framework/Test%20Runner) + +______________________________________________________________________ + +[back](../README.md) diff --git a/Scenarios/settings.md b/Scenarios/settings.md index 6a54b92c42..874ae29fd8 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -242,6 +242,7 @@ Please read the release notes carefully when installing new versions of AL-Go fo | doNotBuildTests | This setting forces the pipeline to NOT build and run the tests and performance tests in testFolders and bcptTestFolders | false | | doNotRunTests | This setting forces the pipeline to NOT run the tests in testFolders. Tests are still being built and published. Note this setting can be set in a [workflow specific settings file](#where-are-the-settings-located) to only apply to that workflow | false | | doNotRunBcptTests | This setting forces the pipeline to NOT run the performance tests in testFolders. Performance tests are still being built and published. Note this setting can be set in a [workflow specific settings file](#where-are-the-settings-located) to only apply to that workflow | false | +| testIsolation | Opts the project into partitioned test runs, so that selected test codeunits are executed by a custom test runner (matching their `RequiredTestIsolation` requirement) and everything else runs under a default runner. Read more at [Test Isolation](TestIsolation.md). | `{ "enabled": false, "defaultRunnerCodeunitId": 0, "partitions": [] }` | | memoryLimit | Specifies the memory limit for the build container. By default, this is left to BcContainerHelper to handle and will currently be set to 8G | 8G | | BcContainerHelperVersion | This setting can be set to a specific version (ex. 3.0.8) of BcContainerHelper to force AL-Go to use this version. **latest** means that AL-Go will use the latest released version. **preview** means that AL-Go will use the latest preview version. **dev** means that AL-Go will use the dev branch of containerhelper. | latest (or preview for AL-Go preview) | | unusedALGoSystemFiles (**deprecated**) | An array of AL-Go System Files, which won't be updated during Update AL-Go System Files. They will instead be removed.
Use this setting with care, as this can break the AL-Go for GitHub functionality and potentially leave your repo no longer functional. | [ ] | diff --git a/Tests/ReadSettings.Test.ps1 b/Tests/ReadSettings.Test.ps1 index 24fdb69c9a..220a00aaf3 100644 --- a/Tests/ReadSettings.Test.ps1 +++ b/Tests/ReadSettings.Test.ps1 @@ -310,6 +310,98 @@ InModuleScope ReadSettings { # Allows testing of private functions Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema | Should -Be $true } + It 'testIsolation default shape is correct' { + $defaultSettings = GetDefaultSettings + $defaultSettings.testIsolation.enabled | Should -Be $false + $defaultSettings.testIsolation.defaultRunnerCodeunitId | Should -Be 0 + $defaultSettings.testIsolation.partitions | Should -BeNullOrEmpty + } + + It 'testIsolation accepts a populated partitions array' -Skip:($PSVersionTable.PSVersion.Major -lt 7) { + $defaultSettings = GetDefaultSettings + $defaultSettings.testIsolation.enabled = $true + $defaultSettings.testIsolation.defaultRunnerCodeunitId = 130450 + $defaultSettings.testIsolation.partitions = @( + [ordered]@{ runnerCodeunitId = 130451; codeunits = '60100|60200..60299' } + [ordered]@{ runnerCodeunitId = 130452; codeunits = '60300' } + ) + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema | Should -Be $true + } + + It 'testIsolation rejects unknown top-level keys' -Skip:($PSVersionTable.PSVersion.Major -lt 7) { + $defaultSettings = GetDefaultSettings + $defaultSettings.testIsolation.typoKey = 'oops' + try { + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema -ErrorAction Stop + } + catch { + $_.Exception.Message | Should -Match '/testIsolation/typoKey' + } + } + + It 'testIsolation rejects negative defaultRunnerCodeunitId' -Skip:($PSVersionTable.PSVersion.Major -lt 7) { + $defaultSettings = GetDefaultSettings + $defaultSettings.testIsolation.defaultRunnerCodeunitId = -1 + try { + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema -ErrorAction Stop + } + catch { + $_.Exception.Message | Should -Match 'defaultRunnerCodeunitId' + } + } + + It 'testIsolation rejects partition entry with runnerCodeunitId = 0' -Skip:($PSVersionTable.PSVersion.Major -lt 7) { + $defaultSettings = GetDefaultSettings + $defaultSettings.testIsolation.partitions = @( + [ordered]@{ runnerCodeunitId = 0; codeunits = '60100' } + ) + try { + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema -ErrorAction Stop + } + catch { + $_.Exception.Message | Should -Match 'runnerCodeunitId' + } + } + + It 'testIsolation rejects partition entry with empty codeunits' -Skip:($PSVersionTable.PSVersion.Major -lt 7) { + $defaultSettings = GetDefaultSettings + $defaultSettings.testIsolation.partitions = @( + [ordered]@{ runnerCodeunitId = 130451; codeunits = '' } + ) + try { + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema -ErrorAction Stop + } + catch { + $_.Exception.Message | Should -Match 'codeunits' + } + } + + It 'testIsolation rejects partition entry missing required keys' -Skip:($PSVersionTable.PSVersion.Major -lt 7) { + $defaultSettings = GetDefaultSettings + $defaultSettings.testIsolation.partitions = @( + [ordered]@{ runnerCodeunitId = 130451 } + ) + try { + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema -ErrorAction Stop + } + catch { + $_.Exception.Message | Should -Match 'codeunits' + } + } + + It 'testIsolation rejects unknown keys inside a partition entry' -Skip:($PSVersionTable.PSVersion.Major -lt 7) { + $defaultSettings = GetDefaultSettings + $defaultSettings.testIsolation.partitions = @( + [ordered]@{ runnerCodeunitId = 130451; codeunits = '60100'; extraKey = 'oops' } + ) + try { + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema -ErrorAction Stop + } + catch { + $_.Exception.Message | Should -Match 'extraKey' + } + } + It 'overwriteSettings property resets settings from destination object (simple types)' { $dst = [ordered]@{ setting1 = "value1" diff --git a/Tests/TestIsolation.Test.ps1 b/Tests/TestIsolation.Test.ps1 new file mode 100644 index 0000000000..d12abde380 --- /dev/null +++ b/Tests/TestIsolation.Test.ps1 @@ -0,0 +1,198 @@ +Import-Module (Join-Path $PSScriptRoot '../Actions/.Modules/TestIsolation.psm1') -Force + +Describe 'TestIsolation' { + + Context 'New-PartitionedTestRunnerScriptBlock' { + + BeforeAll { + # The generated scriptblock looks up Run-TestsInBcContainer through + # the global function table. Install a global stub that records every + # invocation so each test can assert on the call sequence. + function global:Run-TestsInBcContainer { + [CmdletBinding()] + Param( + [string] $testCodeunit, + [string] $testCodeunitRange, + [string] $testRunnerCodeunitId, + [string] $extensionId, + [string] $containerName, + $disabledTests, + [string] $JUnitResultFileName, + [string] $XUnitResultFileName, + [switch] $AppendToJUnitResultFile, + [switch] $AppendToXUnitResultFile, + [switch] $returnTrueIfAllPassed, + [Parameter(ValueFromRemainingArguments = $true)] + $rest + ) + $call = [pscustomobject]@{ + testCodeunitRange = $testCodeunitRange + testRunnerCodeunitId = $testRunnerCodeunitId + extensionId = $extensionId + containerName = $containerName + AppendToJUnitResultFile = [bool] $AppendToJUnitResultFile + hasRangeFilter = $PSBoundParameters.ContainsKey('testCodeunitRange') + hasRunnerId = $PSBoundParameters.ContainsKey('testRunnerCodeunitId') + } + $script:RunTestsInvocations += , $call + if ($script:RunTestsResponse -is [scriptblock]) { + return (& $script:RunTestsResponse $call) + } + return [bool] $script:RunTestsResponse + } + } + + AfterAll { + Remove-Item -Path function:global:Run-TestsInBcContainer -ErrorAction SilentlyContinue + } + + BeforeEach { + $script:RunTestsInvocations = @() + $script:RunTestsResponse = $true + } + + It 'returns a scriptblock' { + $sb = New-PartitionedTestRunnerScriptBlock -Settings @{ defaultRunnerCodeunitId = 0; partitions = @() } + $sb | Should -BeOfType [scriptblock] + } + + It 'with empty partitions and defaultRunnerCodeunitId = 0, invokes default runner once with no filter and no runner id' { + $sb = New-PartitionedTestRunnerScriptBlock -Settings @{ + defaultRunnerCodeunitId = 0 + partitions = @() + } + $result = & $sb @{ extensionId = 'a'; containerName = 'c' } + + $result | Should -Be $true + $script:RunTestsInvocations.Count | Should -Be 1 + $script:RunTestsInvocations[0].hasRangeFilter | Should -Be $false + $script:RunTestsInvocations[0].hasRunnerId | Should -Be $false + } + + It 'with empty partitions and defaultRunnerCodeunitId set, default call uses that runner' { + $sb = New-PartitionedTestRunnerScriptBlock -Settings @{ + defaultRunnerCodeunitId = 130450 + partitions = @() + } + & $sb @{ extensionId = 'a'; containerName = 'c' } | Out-Null + + $script:RunTestsInvocations.Count | Should -Be 1 + $script:RunTestsInvocations[0].testRunnerCodeunitId | Should -Be '130450' + $script:RunTestsInvocations[0].hasRangeFilter | Should -Be $false + } + + It 'invokes one call per partition with that partition runner and exact range string' { + $sb = New-PartitionedTestRunnerScriptBlock -Settings @{ + defaultRunnerCodeunitId = 0 + partitions = @( + @{ runnerCodeunitId = 130451; codeunits = '60200..60299' } + @{ runnerCodeunitId = 130452; codeunits = '60300|60301' } + ) + } + & $sb @{ extensionId = 'a'; containerName = 'c' } | Out-Null + + # 2 partition calls + 1 default call + $script:RunTestsInvocations.Count | Should -Be 3 + + $script:RunTestsInvocations[0].testRunnerCodeunitId | Should -Be '130451' + $script:RunTestsInvocations[0].testCodeunitRange | Should -Be '60200..60299' + + $script:RunTestsInvocations[1].testRunnerCodeunitId | Should -Be '130452' + $script:RunTestsInvocations[1].testCodeunitRange | Should -Be '60300|60301' + } + + It 'default call uses negated union of every partition piece joined with &' { + $sb = New-PartitionedTestRunnerScriptBlock -Settings @{ + defaultRunnerCodeunitId = 0 + partitions = @( + @{ runnerCodeunitId = 130451; codeunits = '60200..60299' } + @{ runnerCodeunitId = 130452; codeunits = '60300|60301' } + ) + } + & $sb @{ extensionId = 'a'; containerName = 'c' } | Out-Null + + $defaultCall = $script:RunTestsInvocations[2] + $defaultCall.testCodeunitRange | Should -Be '<>60200..60299&<>60300&<>60301' + $defaultCall.hasRunnerId | Should -Be $false + } + + It 'default call uses defaultRunnerCodeunitId together with the negated filter' { + $sb = New-PartitionedTestRunnerScriptBlock -Settings @{ + defaultRunnerCodeunitId = 130450 + partitions = @( + @{ runnerCodeunitId = 130451; codeunits = '60100' } + ) + } + & $sb @{ extensionId = 'a'; containerName = 'c' } | Out-Null + + $defaultCall = $script:RunTestsInvocations[1] + $defaultCall.testRunnerCodeunitId | Should -Be '130450' + $defaultCall.testCodeunitRange | Should -Be '<>60100' + } + + It 'forwards arbitrary Run-AlPipeline parameters to every invocation' { + $sb = New-PartitionedTestRunnerScriptBlock -Settings @{ + defaultRunnerCodeunitId = 0 + partitions = @( + @{ runnerCodeunitId = 130451; codeunits = '60100' } + ) + } + & $sb @{ + extensionId = 'app-a' + containerName = 'mycontainer' + AppendToJUnitResultFile = $true + } | Out-Null + + $script:RunTestsInvocations.Count | Should -Be 2 + $script:RunTestsInvocations | ForEach-Object { + $_.extensionId | Should -Be 'app-a' + $_.containerName | Should -Be 'mycontainer' + $_.AppendToJUnitResultFile | Should -Be $true + } + } + + It 'returns $false if any invocation fails but still runs every call' { + $script:RunTestsResponse = { param($call) $call.testRunnerCodeunitId -ne '130452' } + + $sb = New-PartitionedTestRunnerScriptBlock -Settings @{ + defaultRunnerCodeunitId = 0 + partitions = @( + @{ runnerCodeunitId = 130451; codeunits = '60100' } + @{ runnerCodeunitId = 130452; codeunits = '60200' } + ) + } + $result = & $sb @{ extensionId = 'a'; containerName = 'c' } + + $result | Should -Be $false + $script:RunTestsInvocations.Count | Should -Be 3 + } + + It 'trims whitespace in codeunits pieces when building the negative filter' { + $sb = New-PartitionedTestRunnerScriptBlock -Settings @{ + defaultRunnerCodeunitId = 0 + partitions = @( + @{ runnerCodeunitId = 130451; codeunits = ' 60100 | 60200..60299 ' } + ) + } + & $sb @{ extensionId = 'a'; containerName = 'c' } | Out-Null + + $defaultCall = $script:RunTestsInvocations[1] + $defaultCall.testCodeunitRange | Should -Be '<>60100&<>60200..60299' + } + + It 'works with PSCustomObject partitions (as JSON-deserialised settings would supply)' { + $sb = New-PartitionedTestRunnerScriptBlock -Settings ([pscustomobject]@{ + defaultRunnerCodeunitId = 0 + partitions = @( + [pscustomobject]@{ runnerCodeunitId = 130451; codeunits = '60100' } + ) + }) + & $sb @{ extensionId = 'a'; containerName = 'c' } | Out-Null + + $script:RunTestsInvocations.Count | Should -Be 2 + $script:RunTestsInvocations[0].testRunnerCodeunitId | Should -Be '130451' + $script:RunTestsInvocations[0].testCodeunitRange | Should -Be '60100' + $script:RunTestsInvocations[1].testCodeunitRange | Should -Be '<>60100' + } + } +} From d9039ab7b4dcc40b1bf1faa73c38b1d0fcf09470 Mon Sep 17 00:00:00 2001 From: Volodymyr Dvernytskyi Date: Sun, 19 Apr 2026 23:16:48 +0300 Subject: [PATCH 2/4] Update test isolation descriptions --- Actions/.Modules/TestIsolation.psm1 | 18 +++++++++++------- Actions/.Modules/settings.schema.json | 2 +- Actions/RunPipeline/RunPipeline.ps1 | 2 +- Scenarios/TestIsolation.md | 2 ++ Tests/TestIsolation.Test.ps1 | 9 ++++++--- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Actions/.Modules/TestIsolation.psm1 b/Actions/.Modules/TestIsolation.psm1 index 4a6ecf0424..85724bb513 100644 --- a/Actions/.Modules/TestIsolation.psm1 +++ b/Actions/.Modules/TestIsolation.psm1 @@ -1,10 +1,14 @@ -# Test Isolation module for AL-Go for GitHub -# Builds a scriptblock compatible with Run-AlPipeline's -RunTestsInBcContainer -# override. The scriptblock invokes Run-TestsInBcContainer once per declared -# partition (each with its own test runner and codeunit-range filter), plus one -# trailing call under the default runner whose filter excludes every codeunit -# matched by the explicit partitions. -# See TestIsolation-Plan.md at the repo root. +<# +.SYNOPSIS +Test Isolation module for AL-Go for GitHub. + +.DESCRIPTION +Builds a scriptblock compatible with Run-AlPipeline's -RunTestsInBcContainer +override. The scriptblock invokes Run-TestsInBcContainer once per declared +partition (each with its own test runner and codeunit-range filter), plus one +trailing call under the default runner whose filter excludes every codeunit +matched by the explicit partitions. +#> function New-PartitionedTestRunnerScriptBlock { <# diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index a071bdfbf9..e1313cab6a 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -330,7 +330,7 @@ "properties": { "enabled": { "type": "boolean", - "description": "Enable partitioned test runs. When true, AL-Go invokes Run-TestsInBcContainer once per entry in 'partitions' (each with its own test runner) plus one fallback call covering everything else under 'defaultRunnerCodeunitId'. See https://aka.ms/ALGoSettings#testIsolation" + "description": "Enable partitioned test runs. When true, AL-Go invokes Run-TestsInBcContainer once per entry in 'partitions' (each with its own test runner) plus one fallback call covering everything else under 'defaultRunnerCodeunitId'." }, "defaultRunnerCodeunitId": { "type": "integer", diff --git a/Actions/RunPipeline/RunPipeline.ps1 b/Actions/RunPipeline/RunPipeline.ps1 index 137f6feffb..85cd07fd6b 100644 --- a/Actions/RunPipeline/RunPipeline.ps1 +++ b/Actions/RunPipeline/RunPipeline.ps1 @@ -488,7 +488,7 @@ try { $runAlPipelineParams["features"] = $settings.features if ($settings.testIsolation.enabled -and -not $settings.doNotRunTests) { - Import-Module (Join-Path $PSScriptRoot "..\.Modules\TestIsolation.psm1" -Resolve) + Import-Module (Join-Path $PSScriptRoot '../.Modules/TestIsolation.psm1' -Resolve) Write-Host "Test isolation enabled - $($settings.testIsolation.partitions.Count) explicit partition(s) + default runner ($($settings.testIsolation.defaultRunnerCodeunitId))" $runAlPipelineParams["RunTestsInBcContainer"] = New-PartitionedTestRunnerScriptBlock -Settings $settings.testIsolation } diff --git a/Scenarios/TestIsolation.md b/Scenarios/TestIsolation.md index 672621343a..8e3465a65b 100644 --- a/Scenarios/TestIsolation.md +++ b/Scenarios/TestIsolation.md @@ -87,6 +87,8 @@ The normal AL-Go settings cascade applies. To use partitioning only in your nigh Each entry in `partitions` adds one `Run-TestsInBcContainer` call per test app, plus one trailing default-runner call. For N partitions and M test apps, that is `(N + 1) * M` invocations — versus 1 invocation per test app today. Each call has fixed per-invocation overhead (BC test page setup, control population). Enable `testIsolation` only when isolation requirements actually demand it. +The trailing default-runner call fires unconditionally when `testIsolation.enabled` is `true`. If your `partitions` filters happen to cover every test codeunit in the app, the default-runner call still executes with a filter that matches nothing — zero tests run, but the test page setup still costs ~1 extra invocation per test app. To avoid it, remove partitions until coverage is non-exhaustive, or don't enable `testIsolation` for that project. + ## Related - [TestIsolation property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-testisolation-property) diff --git a/Tests/TestIsolation.Test.ps1 b/Tests/TestIsolation.Test.ps1 index d12abde380..00b46186aa 100644 --- a/Tests/TestIsolation.Test.ps1 +++ b/Tests/TestIsolation.Test.ps1 @@ -5,9 +5,12 @@ Describe 'TestIsolation' { Context 'New-PartitionedTestRunnerScriptBlock' { BeforeAll { - # The generated scriptblock looks up Run-TestsInBcContainer through - # the global function table. Install a global stub that records every - # invocation so each test can assert on the call sequence. + # Installed as a global function (not a Pester Mock) because the + # scriptblock returned by New-PartitionedTestRunnerScriptBlock + # resolves Run-TestsInBcContainer at invocation time through the + # global function table — Mock's command-lookup scope does not + # reach across the .GetNewClosure() boundary. The AfterAll block + # removes the stub so no other *.Test.ps1 sees it. function global:Run-TestsInBcContainer { [CmdletBinding()] Param( From 1f0214ef36f8a46b602bc550979b174e24ab3e97 Mon Sep 17 00:00:00 2001 From: Volodymyr Dvernytskyi Date: Mon, 20 Apr 2026 00:17:46 +0300 Subject: [PATCH 3/4] Add telemetry for test isolation --- Actions/RunPipeline/RunPipeline.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Actions/RunPipeline/RunPipeline.ps1 b/Actions/RunPipeline/RunPipeline.ps1 index 85cd07fd6b..0d10d082f8 100644 --- a/Actions/RunPipeline/RunPipeline.ps1 +++ b/Actions/RunPipeline/RunPipeline.ps1 @@ -490,6 +490,12 @@ try { if ($settings.testIsolation.enabled -and -not $settings.doNotRunTests) { Import-Module (Join-Path $PSScriptRoot '../.Modules/TestIsolation.psm1' -Resolve) Write-Host "Test isolation enabled - $($settings.testIsolation.partitions.Count) explicit partition(s) + default runner ($($settings.testIsolation.defaultRunnerCodeunitId))" + + $testIsolationTelemetry = [System.Collections.Generic.Dictionary[[System.String], [System.String]]]::new() + Add-TelemetryProperty -Hashtable $testIsolationTelemetry -Key 'PartitionCount' -Value "$($settings.testIsolation.partitions.Count)" + Add-TelemetryProperty -Hashtable $testIsolationTelemetry -Key 'DefaultRunnerCodeunitId' -Value "$($settings.testIsolation.defaultRunnerCodeunitId)" + Trace-Information -Message "Test Isolation enabled" -AdditionalData $testIsolationTelemetry + $runAlPipelineParams["RunTestsInBcContainer"] = New-PartitionedTestRunnerScriptBlock -Settings $settings.testIsolation } From 28a1f88c511c9fa40ac031c8701dd926c83df7ee Mon Sep 17 00:00:00 2001 From: Volodymyr Dvernytskyi Date: Tue, 21 Apr 2026 18:39:07 +0300 Subject: [PATCH 4/4] Update Actions/.Modules/TestIsolation.psm1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Actions/.Modules/TestIsolation.psm1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Actions/.Modules/TestIsolation.psm1 b/Actions/.Modules/TestIsolation.psm1 index 85724bb513..5a89972e13 100644 --- a/Actions/.Modules/TestIsolation.psm1 +++ b/Actions/.Modules/TestIsolation.psm1 @@ -84,8 +84,9 @@ function New-PartitionedTestRunnerScriptBlock { foreach ($k in $parameters.Keys) { $defaultCall[$k] = $parameters[$k] } if ($defaultRangeFilter) { $defaultCall['testCodeunitRange'] = $defaultRangeFilter } if ($capturedDefaultRunner -gt 0) { $defaultCall['testRunnerCodeunitId'] = "$capturedDefaultRunner" } + $defaultRunnerDisplay = if ($capturedDefaultRunner -gt 0) { "$capturedDefaultRunner" } else { "BC default" } - Write-Host "Running default partition runner=$capturedDefaultRunner range='$defaultRangeFilter' app=$appId" + Write-Host "Running default partition runner=$defaultRunnerDisplay range='$defaultRangeFilter' app=$appId" $invocations++ $passed = Run-TestsInBcContainer @defaultCall