File Layout: .Tests.ps1, Folders, and Naming Conventions
- powershell
- pester
- testing
- project-layout
- conventions
Pester finds your tests by convention, not configuration. Name and place your files correctly and "run everything" just works—point Invoke-Pester at a folder and it discovers the lot. In this post we'll cover the discovery rule, how to lay out a project so tests mirror source, and how to write paths that work no matter where you run from.
What you'll learn
- The
*.Tests.ps1discovery rule that makes Pester find files - Why one test file per source file scales well
- How to split
Tests/UnitfromTests/Integration - How to mirror your source structure in your tests
- How
$PSScriptRootmakes paths robust
The discovery rule: *.Tests.ps1
This is the convention everything else hangs on: Pester discovers any file whose name ends in .Tests.ps1. When you run Invoke-Pester against a folder, it recurses through subfolders and runs every matching file — and silently ignores everything else.
# Runs every *.Tests.ps1 file under the current folder, recursively
Invoke-Pester
# Or point it at a specific folder
Invoke-Pester -Path ./TestsThe flip side: a test file named MyFunctionTests.ps1 or Test-MyFunction.ps1 will never be discovered, because it doesn't match *.Tests.ps1. The casing of .Tests.ps1 matters less than the shape — but stick to Verb-Noun.Tests.ps1 and you'll never be surprised.
One test file per source file
The cleanest convention is a one-to-one mapping: each source file gets a sibling test file with the same base name plus .Tests.ps1.
Get-Square.ps1 -> Get-Square.Tests.ps1
Get-DiscountedPrice.ps1 -> Get-DiscountedPrice.Tests.ps1This keeps things findable: when a test fails, you know exactly which source file to open. It also keeps each test file small and focused, which matters more than it sounds — a single 2,000-line All.Tests.ps1 becomes a place tests go to be forgotten.
A sample repo layout
Here's a layout that scales from one function to a whole module. Source on one side, tests mirroring it on the other, split by test type:
MyProject/
src/
Public/
Get-DiscountedPrice.ps1
Get-FullName.ps1
Private/
ConvertTo-Cents.ps1
Tests/
Unit/
Get-DiscountedPrice.Tests.ps1
Get-FullName.Tests.ps1
Integration/
PricingPipeline.Tests.ps1
Helpers/
TestHelpers.ps1Two ideas are doing the work here:
Tests/UnitvsTests/Integration. Unit tests are fast and isolated (one function, no network, no disk). Integration tests exercise several pieces together and tend to be slower. Splitting them by folder lets you run the fast ones constantly and the slow ones on demand:Invoke-Pester -Path ./Tests/Unit.- Mirroring source structure. The test tree echoes the source tree, so anyone can navigate from a function to its tests (and back) without searching.
Shared setup code that several test files reuse lives in a Helpers folder and gets loaded explicitly — keep it out of the .Tests.ps1 naming so Pester doesn't try to run it as a test file.
Robust paths with $PSScriptRoot
A test file needs to load the source file it's testing. The temptation is to hard-code a path — and that breaks the moment someone clones the repo elsewhere or CI checks it out under a different root. Instead, use $PSScriptRoot, which is the folder containing the currently running script. Build every path relative to it.
Given the layout above, Tests/Unit/Get-FullName.Tests.ps1 reaches its source like this:
Describe 'Get-FullName' {
BeforeAll {
# $PSScriptRoot = .../MyProject/Tests/Unit
$sourceFile = Join-Path $PSScriptRoot '..' '..' 'src' 'Public' 'Get-FullName.ps1'
. $sourceFile
}
It 'should join first and last with a space' {
Get-FullName -First 'Ada' -Last 'Lovelace' | Should -Be 'Ada Lovelace'
}
}Join-Path with .. segments walks up from the test's folder to the repo root and back down into src. Because it's all relative to $PSScriptRoot, the test works no matter where the repo lives. (Note the dot-source goes in BeforeAll, per Part 10 — never at the top level.)
The source file for reference:
function Get-FullName {
param(
[Parameter(Mandatory)][string]$First,
[Parameter(Mandatory)][string]$Last
)
"$First $Last"
}Loading shared helpers
When a helper file is reused across tests, dot-source it the same way — relative to $PSScriptRoot:
BeforeAll {
. (Join-Path $PSScriptRoot '..' 'Helpers' 'TestHelpers.ps1')
}Because TestHelpers.ps1 doesn't end in .Tests.ps1, Pester won't try to execute it as a test file. It's just a library your tests load.
Try it yourself
Pick one of your own functions and set up the convention from scratch:
- Create a
Tests/Unitfolder in your project. - Add a
Foo.Tests.ps1(matching your function's name) inside it. - In a
BeforeAll, dot-source the function using$PSScriptRootandJoin-Path— no hard-coded paths. - Write one
Itthat asserts on the function's output. - Run
Invoke-Pester -Path ./Testsand confirm your file is discovered and runs.
Common mistakes
File layout is where "run everything" quietly fails. Watch for:
- Hard-coded absolute paths.
. C:\Users\me\proj\src\Foo.ps1works on exactly one machine. Always build paths from$PSScriptRootso the suite runs anywhere, including CI.- Files missing
.Tests.ps1. A file namedFooTests.ps1orTest-Foo.ps1is never discovered, so its tests silently never run — the most dangerous failure, because everything looks green.- One mega test file. Cramming every test into a single file makes failures hard to locate and merges painful. One test file per source file keeps things findable.
Recap
Pester discovers files matching *.Tests.ps1, so naming is what makes "run everything" work. Keep one test file per source file, split fast Tests/Unit tests from slower Tests/Integration ones, mirror your source structure so tests are easy to find, and build every path from $PSScriptRoot so the suite runs on any machine. Get the layout right and your tests stay discoverable as the project grows.
Next up: Part 13 — Getting Your Function Under Test (Dot-Sourcing vs Modules).