Putting It Together: A TDD Workflow + Common Pitfalls (Capstone)
- PowerShell
- Pester
- Testing
- TDD
- Beginners
- Capstone
Over twenty-four posts, you've collected every tool you need: Describe/It/Should, setup and teardown, data-driven tables, mocking, coverage, configuration, and CI. This final post turns those tools into a habit. Test-driven development is a rhythm: write the test first, watch it fail, make it pass, then clean up. It makes tests feel like part of writing code, not a tax on it. Let's walk one small function through the loop, then zoom out to a checklist.
What you'll learn
- The red-green-refactor loop in PowerShell, step by step
- A worked TDD example built test-first
- A six-section checklist distilled from the series
- The top beginner pitfalls and how to dodge them
- Where to go after Pester
Red, green, refactor
The loop has three beats. Red: write one small failing test for the behavior you want next. Green: write the minimum code to make it pass, even if it's ugly. Refactor: with the test green, clean up the code (and the test) without changing behavior, leaning on the test to prove you didn't break anything. Then repeat. Tests come first, which forces you to define "done" before writing a line of implementation.
This rhythm has a name and a person behind it. Test-driven development—and the red-green-refactor loop at its heart—was formalized by Kent Beck, who laid it out in Test-Driven Development: By Example (2002) as part of his work on Extreme Programming. Decades later it still holds up beautifully, and the clarity of it is entirely his. Thank you, Kent.
Step 1 — Red: a failing test
Suppose we want a Get-DiscountedPrice function. Before writing it, we write a test for the simplest behavior and run it. It must fail, that proves the test tests something.
# Pricing.Tests.ps1
BeforeAll {
. "$PSScriptRoot/Pricing.ps1"
}
Describe 'Get-DiscountedPrice' {
It 'applies a 10 percent discount to a 100 price' {
Get-DiscountedPrice -Price 100 -Percent 10 | Should -Be 90
}
}Run it with Invoke-Pester ./Pricing.Tests.ps1. It goes red, the command does not exist yet. That red is the goal of step one.
Step 2 — Green: the minimum to pass
Write the least code that satisfies the test. Resist handling every case, just make this test pass.
# Pricing.ps1
function Get-DiscountedPrice {
param(
[decimal]$Price,
[decimal]$Percent
)
return $Price - ($Price * $Percent / 100)
}Run again. Green. You have one proven behavior. The discipline is to stop here and add the next test rather than speculatively writing code no test covers.
Step 3 — Red again: push the edges
Real code has edge cases. Add a test for invalid input, the error path from Part 7. It goes red because the function does not guard against it yet.
Context 'when the percent is out of range' {
It 'throws on a negative percent' {
{ Get-DiscountedPrice -Price 100 -Percent -5 } |
Should -Throw -ExpectedMessage '*between 0 and 100*'
}
}Step 4 — Green: satisfy the new test
Add just enough validation to pass, without breaking the first test.
function Get-DiscountedPrice {
param(
[decimal]$Price,
[decimal]$Percent
)
if ($Percent -lt 0 -or $Percent -gt 100) {
throw "Percent must be between 0 and 100, got $Percent."
}
return $Price - ($Price * $Percent / 100)
}Run the whole file. Both tests pass. The first test gave you the confidence to change the function without fear, that is the safety net in action.
Step 5 — Refactor with the net up
The logic is fine, but a data-driven table (Part 15) makes the happy-path cases clearer. Refactor the test while the code stays green.
Describe 'Get-DiscountedPrice' {
It 'discounts <Price> by <Percent> percent to <Expected>' -ForEach @(
@{ Price = 100; Percent = 10; Expected = 90 }
@{ Price = 100; Percent = 0; Expected = 100 }
@{ Price = 50; Percent = 50; Expected = 25 }
) {
Get-DiscountedPrice -Price $Price -Percent $Percent |
Should -Be $Expected
}
}Run it. Still green. You expanded coverage and improved readability without touching the implementation, because the tests proved nothing broke.
The series in one checklist
Each section maps to a question you ask while building a suite:
- Foundations (1-4): Is Pester 5.x installed and not shadowed by v3.4? Do you run via
Invoke-Pester, and does the exit code mean what you think? - Assertions (5-8): Are you using the right
Shouldoperator, testing error paths with-Throw, and preferring positive claims over weak negatives? - Structure (9-12): Does each
Ittest one behavior, withContextgrouping scenarios, files named*.Tests.ps1, and$PSScriptRootfor paths? - Testing real code (13-16): Is your code loaded in
BeforeAll, side effects sandboxed inTestDrive:, repetitive cases folded into-ForEach, and slow tests tagged? - Mocking (17-21): Are you mocking only real side effects, asserting calls with
Should -Invoke, and reaching forInModuleScopeonly when a module call needs it? - Quality & automation (22-24): Does coverage point at source, is your run a saved configuration object, and does CI fail on red?
Pin it near your editor and you have the whole series at a glance.
The top beginner pitfalls
Four traps catch nearly everyone, and now you can name them:
- Discovery-phase code. Pester 5 reads your file twice. Bare top-level code runs during Discovery, so put loading and setup in
BeforeAll. - Stale modules. A test passes against last edit's code because the module was not re-imported. Use
Import-Module -ForceinBeforeAll. - Over-mocking. A suite that mocks everything tests the mocks, not your logic, and breaks on every refactor. Mock side effects; let pure logic run.
- Untested error paths. Happy-path-only suites give false confidence. Half of robust code is what happens when things go wrong, so write the
-Throwtests too.
Try it yourself
Pick one small function you wish existed, perhaps a ConvertTo-KebabCase or Get-FileAgeInDays, and build it entirely test-first. Write a failing test for the simplest case (red), the minimum code to pass (green), then add tests for edge cases and bad input one at a time, refactoring as you go. Enable coverage with New-PesterConfiguration to confirm every branch is exercised. You will end with a function and a suite, born together.
Common mistakes
Writing tests after the fact and calling it TDD. Tests written after the code tend to assert what the code already does, bugs included. TDD means the test comes first.
Skipping the refactor step. Red-green is two-thirds of the loop. The refactor beat, cleaning up with the net up, is where the design improves. Do not stop at the first green.
Treating the suite as finished. A test suite is living documentation. Every bug deserves a reproducing test first, every new behavior a test before the code. The suite grows with the project.
Recap
TDD is a rhythm: write a failing test (red), write the minimum code to pass (green), then refactor with the tests green. Run that loop one behavior at a time and the suite grows alongside the code instead of trailing it. Keep the six-section checklist nearby, watch for the four classic pitfalls, Discovery-phase code, stale modules, over-mocking, and untested error paths, and your tests keep earning their keep. From here, explore advanced Pester features, add PSScriptAnalyzer for linting alongside your tests, and scale these patterns to larger modules.
Back to the beginning: Part 1 — What Is Pester, and Why Test PowerShell at All? You started there wondering whether testing was worth it. Now you have the loop, the tools, and the checklist. Open one of your own scripts, write a single failing It, and start your own suite today.