Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/Main.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,13 @@ function Invoke-Pester {
# Write-PesterDebugMessage is used regardless of WriteScreenPlugin.
Resolve-OutputConfiguration -PesterPreference $PesterPreference

# Resolve the shuffle seed once for the whole run (#2425), so it is reported a single
# time and shared by every container - including parallel workers, which each receive
# this resolved configuration. ShuffleSeed 0 means "pick a new seed for this run".
if ($PesterPreference.Run.Shuffle.Value -and 0 -eq $PesterPreference.Run.ShuffleSeed.Value) {
$PesterPreference.Run.ShuffleSeed = [System.Random]::new().Next(1, [int]::MaxValue)
}

if ('None' -ne $PesterPreference.Output.Verbosity.Value) {
$plugins.Add((Get-WriteScreenPlugin -Verbosity $PesterPreference.Output.Verbosity.Value))
}
Expand Down
122 changes: 122 additions & 0 deletions src/Pester.Runtime.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ function New-PesterState {

Stack = [Collections.Stack]@()

# [System.Random] used to shuffle the execution order of containers, blocks and tests
# when Run.Shuffle is enabled. Seeded from Run.ShuffleSeed so a run can be repeated.
# Stays $null when Run.Shuffle is disabled.
ShuffleRandom = $null

# Set of block containers that opt out of shuffling via a '#pester:no-shuffle' comment.
# Their blocks and tests keep declaration order even when Run.Shuffle is enabled.
NoShuffleContainers = $null

# Captured here so the <> template expansion (which runs in the user's session state) can
# invoke it via "& $____Pester.FormatNicelyForTemplate" while the function itself stays bound
# to the Pester module session state, where Format-Nicely2 is available (#2744).
Expand Down Expand Up @@ -2034,6 +2043,34 @@ function Invoke-Test {
$state.PluginData = $PluginData
$state.Configuration = $Configuration

# Shuffled execution order (#2425). The seed is normally resolved once in Invoke-Pester
# and written back to the configuration so it can be reported and repeated. When Invoke-Test
# is called directly (e.g. from tests) with Run.Shuffle enabled but no seed, pick one here.
if ($PesterPreference.Run.Shuffle.Value) {
$shuffleSeed = $PesterPreference.Run.ShuffleSeed.Value
if (0 -eq $shuffleSeed) {
$shuffleSeed = [System.Random]::new().Next(1, [int]::MaxValue)
$PesterPreference.Run.ShuffleSeed = $shuffleSeed
}

$state.ShuffleRandom = [System.Random]::new($shuffleSeed)

# A file or script block can opt out with a '#pester:no-shuffle' comment. Collect those
# containers so their blocks and tests keep declaration order during discovery post-processing.
$state.NoShuffleContainers = [System.Collections.Generic.HashSet[object]]::new()
foreach ($container in $BlockContainer) {
if (Test-BlockContainerIsNoShuffle -Container $container) {
$null = $state.NoShuffleContainers.Add($container)
}
}

# Shuffle the order the containers (test files / script blocks) run in. The blocks and
# tests inside each container are shuffled later, during discovery post-processing.
if (@($BlockContainer).Count -gt 1) {
$BlockContainer = Get-ShuffledOrder -Random $state.ShuffleRandom -InputObject $BlockContainer
}
}

# # TODO: this it potentially unreliable, because suppressed errors are written to Error as well. And the errors are captured only from the caller state. So let's use it only as a useful indicator during migration and see how it works in production code.

# # finding if there were any non-terminating errors during the run, user can clear the array, and the array has fixed size so we can't just try to detect if there is any difference by counts before and after. So I capture the last known error in that state and try to find it in the array after the run
Expand Down Expand Up @@ -2153,6 +2190,67 @@ function Invoke-Test {
$executedContainers
}

function Get-ShuffledOrder {
# Fisher-Yates shuffle. Returns a new array with the items in a random but
# deterministic order for a given seeded [System.Random], so a run can be repeated.
param (
[Parameter(Mandatory = $true)]
[System.Random] $Random,
$InputObject
)

$items = [object[]]@($InputObject)
for ($i = $items.Length - 1; $i -gt 0; $i--) {
$j = $Random.Next(0, $i + 1)
if ($i -ne $j) {
$tmp = $items[$i]
$items[$i] = $items[$j]
$items[$j] = $tmp
}
}

# comma to return the array as a single object, preventing pipeline unrolling
, $items
}

function Test-BlockContainerIsNoShuffle {
# Returns $true when a container opts out of shuffling (#2425) via a file-level comment directive,
# parsed similarly to PowerShell's #requires:
#
# #pester:no-shuffle
#
# The marker is matched against real comment tokens using the PowerShell tokenizer, so it is
# recognized only inside comments and never inside strings or here-strings. It may appear anywhere
# in the file or script block. Blocks and tests in a marked container keep their declaration order.
[OutputType([bool])]
param (
[Parameter(Mandatory = $true)]
$Container
)

$tokens = $null
$parseErrors = $null

if ('File' -eq $Container.Type) {
$null = [System.Management.Automation.Language.Parser]::ParseFile($Container.Item.FullName, [ref] $tokens, [ref] $parseErrors)
}
elseif ('ScriptBlock' -eq $Container.Type) {
$null = [System.Management.Automation.Language.Parser]::ParseInput($Container.Item.ToString(), [ref] $tokens, [ref] $parseErrors)
}
else {
return $false
}

foreach ($token in $tokens) {
if ($token.Kind -eq [System.Management.Automation.Language.TokenKind]::Comment -and
$token.Text -match '^#\s*pester:no-shuffle\b') {
return $true
}
}

return $false
}

function PostProcess-DiscoveredBlock {
param (
[Parameter(Mandatory = $true)]
Expand All @@ -2178,6 +2276,30 @@ function PostProcess-DiscoveredBlock {
$b.Root = $RootBlock
$b.BlockContainer = $BlockContainer

# Shuffle the order of this block's direct children (its child blocks and tests, kept
# together in .Order) when Run.Shuffle is enabled. This shuffles same-level items only:
# the Describes in a file, the Describes/Contexts in a Describe, and the Its in a block.
# We do it here, before First/Last are marked below, and rebuild .Blocks and .Tests to
# follow the shuffled .Order so the one-time setup/teardown boundaries match the real
# execution order. Uses the run's seeded RNG so the order is repeatable (#2425). Containers
# that opt out with '#pester:no-shuffle' are skipped and keep their declaration order.
$containerOptedOut = $null -ne $state.NoShuffleContainers -and $state.NoShuffleContainers.Contains($BlockContainer)
if ($null -ne $state.ShuffleRandom -and -not $containerOptedOut -and $b.Order.Count -gt 1) {
$shuffledOrder = Get-ShuffledOrder -Random $state.ShuffleRandom -InputObject $b.Order
$b.Order.Clear()
$b.Blocks.Clear()
$b.Tests.Clear()
foreach ($item in $shuffledOrder) {
$null = $b.Order.Add($item)
if ('Test' -eq $item.ItemType) {
$null = $b.Tests.Add($item)
}
else {
$null = $b.Blocks.Add($item)
}
}
}

$tests = $b.Tests

if ($b.IsRoot) {
Expand Down
38 changes: 38 additions & 0 deletions src/csharp/Pester/RunConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public class RunConfiguration : ConfigurationSection
private StringOption _skipRemainingOnFailure;
private BoolOption _failOnNullOrEmptyForEach;
private StringOption _repoRoot;
private BoolOption _shuffle;
private IntOption _shuffleSeed;

public static RunConfiguration Default { get { return new RunConfiguration(); } }
public static RunConfiguration ShallowClone(RunConfiguration configuration)
Expand All @@ -62,6 +64,8 @@ public RunConfiguration(IDictionary configuration) : this()
configuration.AssignObjectIfNotNull<string>(nameof(SkipRemainingOnFailure), v => SkipRemainingOnFailure = v);
configuration.AssignValueIfNotNull<bool>(nameof(FailOnNullOrEmptyForEach), v => FailOnNullOrEmptyForEach = v);
configuration.AssignObjectIfNotNull<string>(nameof(RepoRoot), v => RepoRoot = v);
configuration.AssignValueIfNotNull<bool>(nameof(Shuffle), v => Shuffle = v);
configuration.AssignValueIfNotNull<int>(nameof(ShuffleSeed), v => ShuffleSeed = v);
}
}

Expand All @@ -80,6 +84,8 @@ public RunConfiguration(IDictionary configuration) : this()
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);
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("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);
ShuffleSeed = new IntOption("Seed used to shuffle execution order when Run.Shuffle is enabled. The default 0 picks a new seed for each run and reports it at the start, so the run can be repeated by setting Run.ShuffleSeed to that value.", 0);
RepoRoot = new StringOption("EXPERIMENTAL: Root directory of the repository. Found by searching for the .git directory recursively. When not found, the current working directory is used. Before each test file is discovered and run - in both sequential and parallel runs - Pester dot-sources a 'Pester.BeforeContainer.ps1' from this directory if one is present, so helper modules or dot-sourced setup the parent session would normally provide are available to every container. This is especially useful in parallel runs where each worker starts from a clean runspace and re-runs it.", FindRepoRoot());
}

Expand Down Expand Up @@ -307,6 +313,38 @@ public StringOption RepoRoot
}
}

public BoolOption Shuffle
{
get { return _shuffle; }
set
{
if (_shuffle == null)
{
_shuffle = value;
}
else
{
_shuffle = new BoolOption(_shuffle, value.Value);
}
}
}

public IntOption ShuffleSeed
{
get { return _shuffleSeed; }
set
{
if (_shuffleSeed == null)
{
_shuffleSeed = value;
}
else
{
_shuffleSeed = new IntOption(_shuffleSeed, value.Value);
}
}
}

private static string FindRepoRoot()
{
var originalDir = Directory.GetCurrentDirectory();
Expand Down
8 changes: 8 additions & 0 deletions src/en-US/about_PesterConfiguration.help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ SECTIONS AND OPTIONS
Type: string
Default value: '<path of .git>'

Shuffle: 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.
Type: bool
Default value: $false

ShuffleSeed: Seed used to shuffle execution order when Run.Shuffle is enabled. The default 0 picks a new seed for each run and reports it at the start, so the run can be repeated by setting Run.ShuffleSeed to that value.
Type: int
Default value: 0

Filter:
Tag: Tags of Describe, Context or It to be run. Use 'None' to run only tests that have no tags.
Type: string[]
Expand Down
6 changes: 6 additions & 0 deletions src/functions/Output.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
$script:ReportStrings = DATA {
@{
VersionMessage = "Pester v{0}"
ShuffleMessage = "Shuffling execution order using seed {0}. Set 'Run.ShuffleSeed = {0}' to repeat this order."

CoverageMessage = 'Covered {2:0.##}% / {5:0.##}%. {3:N0} analyzed {0} in {4:N0} {1}.'
MissedSingular = 'Missed command:'
Expand Down Expand Up @@ -528,6 +529,11 @@ function Get-WriteScreenPlugin ($Verbosity) {
$parallelSuffix = if ($Context.Parallel) { ' in parallel' } else { '' }
Write-PesterHostMessage -ForegroundColor $ReportTheme.Container "`nRunning tests from $(@($Context.BlockContainers).Length) files$parallelSuffix."
}

if ($PesterPreference.Run.Shuffle.Value) {
# Report the resolved seed so a randomized run (#2425) can be repeated.
Write-PesterHostMessage -ForegroundColor $ReportTheme.Discovery ($ReportStrings.ShuffleMessage -f $PesterPreference.Run.ShuffleSeed.Value)
}
}

$p.ContainerDiscoveryEnd = {
Expand Down
Loading
Loading