Skip to content
6 min read

Data-Driven Tests with -ForEach and -TestCases

  • powershell
  • pester
  • testing
  • data-driven
  • foreach
  • testcases

You wrote a test for Get-Discount at $100. Then you copied it for $200. Then $0, then a negative amount, then a huge number. Now you have five It blocks that are identical except for two values—and when the function's signature changes, you get to edit all five. There's a better way: describe the cases as data and let one test run over all of them.

What you'll learn

  • Driving an It with -ForEach @(@{...}) and hashtables
  • Templating test names with <placeholder> tokens
  • Applying -ForEach to Describe and Context, not just It
  • The -TestCases alias and how it relates to -ForEach
  • When a table genuinely beats separate tests

The repetition smell

Here is the function and the copy-paste pile it tends to grow:

function Get-Discount {
    param([Parameter(Mandatory)][double]$Amount)
    if     ($Amount -ge 100) { 0.10 }
    elseif ($Amount -ge 50)  { 0.05 }
    else                     { 0.00 }
}
Describe 'Get-Discount' {
    It 'gives 10% at 100'  { Get-Discount -Amount 100 | Should -Be 0.10 }
    It 'gives 10% at 250'  { Get-Discount -Amount 250 | Should -Be 0.10 }
    It 'gives 5% at 50'    { Get-Discount -Amount 50  | Should -Be 0.05 }
    It 'gives 5% at 99'    { Get-Discount -Amount 99  | Should -Be 0.05 }
    It 'gives 0% at 10'    { Get-Discount -Amount 10  | Should -Be 0.00 }
}

Five blocks, one idea. Let's collapse them.

One It, many cases with -ForEach

Pass -ForEach an array of hashtables. Pester runs the It block once per hashtable, and each key becomes a variable inside the block:

Describe 'Get-Discount' {
    It 'gives <Expected> for an amount of <Amount>' -ForEach @(
        @{ Amount = 100; Expected = 0.10 }
        @{ Amount = 250; Expected = 0.10 }
        @{ Amount = 50;  Expected = 0.05 }
        @{ Amount = 99;  Expected = 0.05 }
        @{ Amount = 10;  Expected = 0.00 }
    ) {
        Get-Discount -Amount $Amount | Should -Be $Expected
    }
}

Inside the block, $Amount and $Expected are the keys from the current hashtable. Add a sixth case? Add one line. The logic lives in exactly one place.

Templated names with <placeholder>

Look at the It name above: 'gives <Expected> for an amount of <Amount>'. The <...> tokens are Pester placeholders — at runtime it substitutes the matching hashtable key, so the output reads as five distinct, readable tests:

gives 0.1 for an amount of 100
gives 0.1 for an amount of 250
gives 0.05 for an amount of 50
gives 0.05 for an amount of 99
gives 0 for an amount of 10

This matters more than it looks. Without placeholders every case shares the same name, so when case three fails you cannot tell which row broke from the summary. The placeholder token must match the hashtable key exactly — <Amount> pairs with Amount.

-ForEach on Describe and Context

-ForEach is not limited to It. Put it on a Context (or Describe) and the whole block repeats, with the keys available to every test inside — handy when several assertions share the same setup:

Describe 'Get-Discount tiers' {
    Context 'an amount of <Amount>' -ForEach @(
        @{ Amount = 250; Expected = 0.10 }
        @{ Amount = 75;  Expected = 0.05 }
    ) {
        It 'returns the <Expected> rate' {
            Get-Discount -Amount $Amount | Should -Be $Expected
        }

        It 'never returns a negative rate' {
            Get-Discount -Amount $Amount | Should -BeGreaterOrEqual 0
        }
    }
}

Both Its run for each Context case, and $Amount/$Expected are in scope throughout.

-TestCases: the same idea, older spelling

If you read older examples you will see -TestCases instead of -ForEach:

Describe 'Get-Discount' {
    It 'gives <Expected> for an amount of <Amount>' -TestCases @(
        @{ Amount = 100; Expected = 0.10 }
        @{ Amount = 10;  Expected = 0.00 }
    ) {
        Get-Discount -Amount $Amount | Should -Be $Expected
    }
}

In Pester 5, -TestCases is an alias for -ForEach on It — they do the same thing. Newer code tends to prefer -ForEach because it also works on Describe and Context, so it is the one name to remember.

When a table beats separate tests

Use a table when the cases test the same behavior with different data — input/expected pairs, boundary values, a list of valid formats. Keep tests separate when each exercises different behavior, needs its own setup, or tells a distinct story. A data table that needs a comment to explain each row has usually stopped being data and become five tests in a trench coat — split it back out.

Try it yourself

Take three near-identical It blocks from your own suite — anything shaped like "input X gives output Y." Convert them into one It -ForEach with a hashtable per case, and use <placeholder> tokens so each case gets a unique, readable name. Run it and confirm the output lists all three with their substituted values.

Common mistakes

No placeholders, so every case shares one name. When a case fails you cannot tell which row. Always put <Key> tokens in the It name for each value that varies.

Over-parametrizing into mush. A table with eight keys and rows that each need a mental decode is harder to read than separate tests. If cases differ in behavior, not just data, keep them apart.

Hashtable keys that do not match the block. A key named Value will not populate a $Amount variable, and <Amount> in the name will render literally. Keep keys, variables, and placeholders spelled identically.

Recap

-ForEach turns a pile of copy-pasted It blocks into one parametrized test driven by a table of hashtables, with <placeholder> tokens keeping each case readable. It works on Describe and Context too, and -TestCases is just the classic alias on It. Now that your suite is growing, you will want to run only part of it — the fast tests, or everything except the slow integration ones.

Next up: Part 16 — Tagging and Filtering Which Tests Run.