Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions src/Main.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -645,12 +645,11 @@ function Invoke-Pester {
}

# Parallel mode runs each file in its own runspace and merges the executed
# containers back. It only applies to file-based runs on PowerShell 7+; other cases fall
# back to the normal sequential path with a warning. CodeCoverage is supported: each
# worker measures its own file with breakpoints and the parent merges the results (see
# the parallel branch below).
# containers back. It applies to file-based runs on both Windows PowerShell 5.1 and
# PowerShell 7; other cases fall back to the normal sequential path with a warning.
# CodeCoverage is supported: each worker measures its own file with breakpoints and the
# parent merges the results (see the parallel branch below).
$useParallel = $PesterPreference.Run.Parallel.Value
$parallelSupported = $PSVersionTable.PSVersion.Major -ge 7
$allFileContainers = 0 -eq @($containers | & $SafeCommands['Where-Object'] { 'File' -ne $_.Type }).Count
$coverageEnabled = $PesterPreference.CodeCoverage.Enabled.Value
# Run.SkipRemainingOnFailure = 'Run' stops the whole run after the first failed
Expand All @@ -670,7 +669,7 @@ function Invoke-Pester {
# them inherits those type constraints, which silently corrupts the loop variable.
$parallelContainers = [System.Collections.Generic.List[object]]@()
$nonParallelContainers = [System.Collections.Generic.List[object]]@()
if ($useParallel -and $parallelSupported -and $allFileContainers -and -not $skipRemainingRunScope) {
if ($useParallel -and $allFileContainers -and -not $skipRemainingRunScope) {
foreach ($fileContainer in $containers) {
if (Test-PesterFileIsNonParallel -Path $fileContainer.Item.FullName) {
$nonParallelContainers.Add($fileContainer)
Expand All @@ -681,10 +680,7 @@ function Invoke-Pester {
}
}

if ($useParallel -and -not $parallelSupported) {
& $SafeCommands['Write-Warning'] "Run.Parallel requires PowerShell 7 or later for 'ForEach-Object -Parallel'. Running the tests sequentially instead."
}
elseif ($useParallel -and -not $allFileContainers) {
if ($useParallel -and -not $allFileContainers) {
& $SafeCommands['Write-Warning'] "Run.Parallel currently parallelizes only file-based runs (Run.Path). The provided ScriptBlock/Container test(s) will run sequentially instead."
}
elseif ($useParallel -and $skipRemainingRunScope) {
Expand All @@ -700,7 +696,7 @@ function Invoke-Pester {
# If every file opted out with #pester:no-parallel, the run is effectively sequential,
# so fall through to the sequential path, which fires the framework's own global plugin
# steps at the correct interleaved points.
$ranInParallel = $useParallel -and $parallelSupported -and $allFileContainers -and -not $skipRemainingRunScope -and 0 -lt $parallelContainers.Count
$ranInParallel = $useParallel -and $allFileContainers -and -not $skipRemainingRunScope -and 0 -lt $parallelContainers.Count
if ($ranInParallel) {
$foldedContainers = [System.Collections.Generic.List[object]]@()
$hasNonParallel = 0 -lt $nonParallelContainers.Count
Expand Down
4 changes: 2 additions & 2 deletions src/csharp/Pester/RunConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ public RunConfiguration(IDictionary configuration) : this()
Throw = new BoolOption("Throw an exception when test run fails. When used together with Exit, throwing an exception is preferred.", false);
PassThru = new BoolOption("Return result object to the pipeline after finishing the test run.", false);
SkipRun = new BoolOption("Runs the discovery phase but skips run. Use it with PassThru to get object populated with all tests.", false);
Parallel = new BoolOption("EXPERIMENTAL: Run test files in parallel, each file in its own runspace, using PowerShell 7+ 'ForEach-Object -Parallel'. Files that contain the '#pester:no-parallel' directive run sequentially after the parallel batch. Falls back to a sequential run on Windows PowerShell 5.1, when non-file containers (ScriptBlock) are used, when Run.SkipRemainingOnFailure is set to 'Run', or when every file opts out of parallel. CodeCoverage is supported: each worker measures its own file and Pester merges the results into a single report.", false);
ParallelThrottleLimit = new IntOption("EXPERIMENTAL: Maximum number of test files to run at the same time when Run.Parallel is enabled, passed through to 'ForEach-Object -Parallel -ThrottleLimit'. The default 0 uses all available processors ([Environment]::ProcessorCount). Set a lower number to cap how many runspaces run concurrently. Only used when Run.Parallel is enabled.", 0);
Parallel = new BoolOption("EXPERIMENTAL: Run test files in parallel, each file in its own runspace, on both Windows PowerShell 5.1 and PowerShell 7. Files that contain the '#pester:no-parallel' directive run sequentially after the parallel batch. Falls back to a sequential run when non-file containers (ScriptBlock) are used, when Run.SkipRemainingOnFailure is set to 'Run', or when every file opts out of parallel. CodeCoverage is supported: each worker measures its own file and Pester merges the results into a single report.", false);
ParallelThrottleLimit = new IntOption("EXPERIMENTAL: Maximum number of test files to run at the same time when Run.Parallel is enabled. The default 0 uses all available processors ([Environment]::ProcessorCount). Set a lower number to cap how many runspaces run concurrently. Only used when Run.Parallel is enabled. Setting it to 1 does not turn parallel off, the files still go through the parallel machinery one at a time, each in its own runspace, so a breakpoint set in the calling session still does not hit inside a worker.", 0);
SkipRemainingOnFailure = new StringOption("Skips remaining tests after failure for selected scope, options are None, Run, Container and Block.", "None");
FailOnNullOrEmptyForEach = new BoolOption("Fails discovery when -ForEach is provided $null or @() in a block or test. Can be overridden for a specific Describe/Context/It using -AllowNullOrEmptyForEach.", true);
Shuffle = new BoolOption("EXPERIMENTAL: Shuffle the order in which test files, and the blocks (Describe/Context) and tests (It) inside them, are executed. Items are only reordered within their own level. Uses Run.ShuffleSeed so a run can be repeated, and helps surface hidden dependencies between tests. A single file can opt out with a '#pester:no-shuffle' comment.", false);
Expand Down
4 changes: 2 additions & 2 deletions src/en-US/about_PesterConfiguration.help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ SECTIONS AND OPTIONS
Type: bool
Default value: $false

Parallel: EXPERIMENTAL: Run test files in parallel, each file in its own runspace, using PowerShell 7+ 'ForEach-Object -Parallel'. Files that contain the '#pester:no-parallel' directive run sequentially after the parallel batch. Falls back to a sequential run on Windows PowerShell 5.1, when non-file containers (ScriptBlock) are used, when Run.SkipRemainingOnFailure is set to 'Run', or when every file opts out of parallel. CodeCoverage is supported: each worker measures its own file and Pester merges the results into a single report.
Parallel: EXPERIMENTAL: Run test files in parallel, each file in its own runspace, on both Windows PowerShell 5.1 and PowerShell 7. Files that contain the '#pester:no-parallel' directive run sequentially after the parallel batch. Falls back to a sequential run when non-file containers (ScriptBlock) are used, when Run.SkipRemainingOnFailure is set to 'Run', or when every file opts out of parallel. CodeCoverage is supported: each worker measures its own file and Pester merges the results into a single report.
Type: bool
Default value: $false

ParallelThrottleLimit: EXPERIMENTAL: Maximum number of test files to run at the same time when Run.Parallel is enabled, passed through to 'ForEach-Object -Parallel -ThrottleLimit'. The default 0 uses all available processors ([Environment]::ProcessorCount). Set a lower number to cap how many runspaces run concurrently. Only used when Run.Parallel is enabled.
ParallelThrottleLimit: EXPERIMENTAL: Maximum number of test files to run at the same time when Run.Parallel is enabled. The default 0 uses all available processors ([Environment]::ProcessorCount). Set a lower number to cap how many runspaces run concurrently. Only used when Run.Parallel is enabled. Setting it to 1 does not turn parallel off, the files still go through the parallel machinery one at a time, each in its own runspace, so a breakpoint set in the calling session still does not hit inside a worker.
Type: int
Default value: 0

Expand Down
128 changes: 117 additions & 11 deletions src/functions/Pester.Parallel.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function Test-PesterFileIsNonParallel {
function Test-PesterFileIsNonParallel {
<#
.SYNOPSIS
Returns $true when a test file opts out of parallel execution via a file-level directive.
Expand Down Expand Up @@ -119,6 +119,102 @@ function Split-PesterEventTape {
}
}

function Invoke-InRunspacePool {
<#
.SYNOPSIS
Runs a scriptblock once per input item, concurrently, in a pool of runspaces.

.DESCRIPTION
The parallelism primitive behind Run.Parallel. `ForEach-Object -Parallel` does the same thing,
but it only exists on PowerShell 7 and Pester also supports Windows PowerShell 5.1, so this is
built on the runspace API that both editions have. One implementation for both keeps the two
editions from drifting apart.

The scriptblock is handed over as text and re-parsed inside the worker runspace, so nothing
with runspace affinity crosses the boundary. Values reach the worker as named parameters
rather than through `$using:`, which is a ForEach-Object -Parallel feature. The runspaces live
in this process, so the values themselves cross as live objects either way.

Each item gets its own PowerShell instance and they are all started at once; the pool is what
limits how many actually run, so ThrottleLimit means the same as it does on ForEach-Object.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('Pester.BuildAnalyzerRules\Measure-SafeCommands', '', Justification = 'Runspace and PowerShell API calls, not cmdlets.')]
[CmdletBinding()]
param(
[object[]] $InputObject,

[Parameter(Mandatory)]
[scriptblock] $ScriptBlock,

[int] $ThrottleLimit = 1,

# Passed to every worker as named parameters, on top of the item itself.
[System.Collections.IDictionary] $Parameters = @{},

# Name of the worker parameter that receives the current input item.
[string] $ItemParameterName = 'item'
)

$items = @($InputObject)
if (0 -eq $items.Count) {
return
}

if ($ThrottleLimit -lt 1) { $ThrottleLimit = 1 }

$sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
# Share the host, the way ForEach-Object -Parallel does, so a worker that does write to the
# console reaches the same one. Pester's workers are silenced, this is for anything else.
$pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $ThrottleLimit, $sessionState, $Host)
$pool.ThreadOptions = [System.Management.Automation.Runspaces.PSThreadOptions]::ReuseThread
$pool.Open()

$invocations = [System.Collections.Generic.List[object]]@()
try {
foreach ($item in $items) {
$powershell = [System.Management.Automation.PowerShell]::Create()
$powershell.RunspacePool = $pool
$null = $powershell.AddScript($ScriptBlock.ToString())
$null = $powershell.AddParameter($ItemParameterName, $item)
foreach ($key in $Parameters.Keys) {
$null = $powershell.AddParameter($key, $Parameters[$key])
}

$invocations.Add([PSCustomObject]@{
PowerShell = $powershell
Handle = $powershell.BeginInvoke()
})
}

foreach ($invocation in $invocations) {
try {
$invocation.PowerShell.EndInvoke($invocation.Handle)
}
catch {
# One worker failing must not take the rest of the run with it, the remaining files
# still have results worth reporting. Surface it and keep going.
& $SafeCommands['Write-Error'] -ErrorRecord $_
}

# The worker has no console of its own, so anything it wrote to these streams would be
# lost. Re-emit it here.
foreach ($errorRecord in $invocation.PowerShell.Streams.Error) {
& $SafeCommands['Write-Error'] -ErrorRecord $errorRecord
}
foreach ($warningRecord in $invocation.PowerShell.Streams.Warning) {
& $SafeCommands['Write-Warning'] -Message $warningRecord.Message
}
}
}
finally {
foreach ($invocation in $invocations) {
$invocation.PowerShell.Dispose()
}
$pool.Close()
$pool.Dispose()
}
}

function Invoke-TestInParallel {
<#
.SYNOPSIS
Expand All @@ -127,16 +223,17 @@ function Invoke-TestInParallel {
that fired while it ran.

.DESCRIPTION
Used by Invoke-Pester when Run.Parallel is enabled on PowerShell 7+. Each test file is
executed by a full Invoke-Pester run inside its own runspace via `ForEach-Object -Parallel`.
Used by Invoke-Pester when Run.Parallel is enabled. Each test file is executed by a full
Invoke-Pester run inside its own runspace, from a pool (see Invoke-InRunspacePool), which
works the same on Windows PowerShell 5.1 and on PowerShell 7.
The worker runs silently (Output.Verbosity = None) so it produces no console output of its
own; instead it records every per-container and per-test plugin step (with the live Block /
Test / Result objects) into an ordered tape. The parent replays that tape to its reporting
plugins (screen output + IDE adapters), so the events emitted for a parallel run match a
sequential run - only the concurrency differs.

Because Pester.dll is loaded once per process (via Add-Type -Path) and shared by every
runspace, the [Pester.Container] objects and the recorded contexts are live objects - no
runspace in it, the [Pester.Container] objects and the recorded contexts are live objects - no
serialization happens - so they can be folded straight back into a single run and replayed.
The execution-critical plugins (Mock, TestDrive, TestRegistry, SkipRemainingOnFailure) run
inside the worker where the test bodies execute; only the reporting plugins are replayed by
Expand All @@ -156,7 +253,7 @@ function Invoke-TestInParallel {
report generation and file write via the CodeCoverageSkipReport module flag.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('Pester.BuildAnalyzerRules\Measure-SafeCommands', '', Justification = 'Get-Module/Import-Module run in a fresh ForEach-Object -Parallel runspace where the module-internal $SafeCommands table is unavailable.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('Pester.BuildAnalyzerRules\Measure-ObjectCmdlets', '', Justification = 'ForEach-Object -Parallel is the runspace-parallelism primitive with no language-keyword equivalent; the accompanying Where-Object/Sort-Object run once over the small per-run result set.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('Pester.BuildAnalyzerRules\Measure-ObjectCmdlets', '', Justification = 'Where-Object/Sort-Object run once over the small per-run result set.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Recorder factory parameters are used inside the returned closure, which the rule does not follow.')]
[CmdletBinding()]
param(
Expand Down Expand Up @@ -231,11 +328,14 @@ function Invoke-TestInParallel {
# every worker's hits and emit one report. A recorder plugin is injected (via the supported
# $script:additionalPlugins channel) to capture the ordered plugin-event tape returned for replay.
$worker = {
$item = $_
$modulePath = $using:modulePath
$baseConfig = $using:baseConfig
$recordedSteps = $using:recordedSteps
$collectCoverage = $using:collectCoverage
param($item, $modulePath, $baseConfig, $recordedSteps, $collectCoverage, $workingDirectory)

# A fresh runspace starts at the process working directory, which is not necessarily where
# the caller is. ForEach-Object -Parallel keeps the caller's location and test files resolve
# relative paths against it, so put the worker there too.
if (-not [string]::IsNullOrEmpty($workingDirectory)) {
Set-Location -LiteralPath $workingDirectory
}

if (-not (Get-Module -Name Pester)) {
Import-Module $modulePath
Expand Down Expand Up @@ -360,7 +460,13 @@ function Invoke-TestInParallel {

$results = @()
if (0 -lt $work.Count) {
$results = $work | & $SafeCommands['ForEach-Object'] -ThrottleLimit $throttle -Parallel $worker
$results = Invoke-InRunspacePool -InputObject $work -ScriptBlock $worker -ThrottleLimit $throttle -Parameters @{
modulePath = $modulePath
baseConfig = $baseConfig
recordedSteps = $recordedSteps
collectCoverage = $collectCoverage
workingDirectory = $ExecutionContext.SessionState.Path.CurrentFileSystemLocation.Path
}
}

# Keep only well-formed worker results (defensive against stray pipeline output).
Expand Down
Loading
Loading