Why isn’t Test Driven Development more widely adopted and accepted by the PowerShell community?

We've all heard that TDD (Test Driven Development) means that you write unit tests before writing any code. Most of us are probably writing functional or acceptance tests after the fact because the idea of Test Driven Development isn't clearly defined, at least not in my opinion. I originally thought it meant to write thorough unit tests to test all functionality for a specific piece of code such as a PowerShell function from start to finish before writing any of the production code for the function itself. This would be very difficult to accomplish in the real world and in my opinion, it's one of the reasons Test Driven Development isn't more widely adopted.

The idea of writing all of the tests first is a false premise and maybe a misconception on my part as well as others. The unit tests are actually written in parallel with the production code. A simple failing test is written first and then just enough production code is written to make that one simple test pass. Then another simple failing test is written and then the production code to make it pass is written and so on. The goal is to write each one in very small, quick, and simple iterations otherwise TDD adds unnecessary overhead and won't be adopted.

My workflow for TDD is shown below. I created this myself (it's NOT some image copied from the Internet).

tdd-workflow875x400.png

My setup for writing PowerShell code when using Test Driven Development is to open two tabs in the PowerShell ISE or SAPIEN PowerShell Studio. One of the tabs is for my tests and the other is for a PowerShell function that I'm developing. I also have the PowerShell console open on a separate monitor and I run the following function in the console to assist me in keeping on task as far as my TDD workflow goes:

 1#Requires -Version 4.0 -Modules Pester
 2function Invoke-MrTDDWorkflow {
 3
 4    [CmdletBinding()]
 5    param (
 6        [ValidateScript({
 7          If (Test-Path -Path $_ -PathType Container) {
 8            $true
 9          }
10          else {
11            Throw "'$_' is not a valid directory."
12          }
13        })]
14        [string]$Path = (Get-Location),
15
16        [ValidateNotNullOrEmpty()]
17        [int]$Seconds = 30
18    )
19
20    Add-Type -AssemblyName System.Windows.Forms
21    Clear-Host
22
23    while (-not $Complete) {
24
25        if ((Invoke-Pester -Script $Path -Quiet -PassThru -OutVariable Results).FailedCount -eq 0) {
26
27            if ([System.Windows.Forms.MessageBox]::Show('Is the code complete?', 'Status', 4, 'Question', 'Button2') -eq 'Yes') {
28                $Complete = $true
29            }
30            else {
31                $Complete = $False
32                Write-Output "Write a failing unit test for a simple feature that doesn't yet exist."
33
34                if ($psISE) {
35                    [System.Windows.Forms.MessageBox]::Show('Click Ok to Continue')
36                }
37                else {
38                    Write-Output 'Press any key to continue ...'
39                    $Host.UI.RawUI.ReadKey('NoEcho, IncludeKeyDown') | Out-Null
40                }
41
42                Clear-Host
43            }
44
45        }
46        else {
47            Write-Output "Write code until unit test: '$(@($Results.TestResult).Where({$_.Passed -eq $false}, 'First', 1).Name)' passes"
48            Start-Sleep -Seconds $Seconds
49            Clear-Host
50        }
51
52    }
53
54}

The most recent version of the Invoke-MrTDDWorkflow function shown in the previous code example can be found in my PowerShell repository on GitHub.

I'll start out with an empty folder:

1Get-ChildItem -Path .\TDD\

tdd-workflow1a.jpg

The Invoke-MrTDDWorkflow function is run, it asks the question "Is the code complete?" since there are currently no failing tests. The default button is "No" so the <enter> key can be pressed instead of having to reach for the mouse which would take more time.

1Invoke-MrTDDWorkflow -Path .\TDD\

tdd-workflow2a.jpg

After selecting "No", the following message is displayed:

tdd-workflow3a.jpg

This is where most who are new to TDD make the mistake of thinking they have to write a complete suite of tests to thoroughly test every aspect of the code they're going to write. Some may disagree with me but that methodology is incorrect and it's what drives people away from using TDD. Also keep in mind that you don't necessarily know how the task is going to be accomplished at this point so try to write tests generically enough that the result could be achieved different ways since there are so many different ways to accomplish the same task in PowerShell. This will help prevent having to rewrite tests when code is refactored in the future.

I'm going to write a PowerShell function that doesn't exist yet. With TDD, you're not allowed to write any production code until a test is written for it first. You're also not allowed to write more of a test than is sufficient to fail. Those are the first two laws of TDD. I'll simply write a test to see if the function exists.

Depending on whether or not -ErrorAction SilentlyContinue is included, the test returns more or less output. I prefer including it to minimize the output since I simply want to know the result of the test without all of the details. Lots of tests will be added and run by the time the function is completed and more details per test means more time to determine if each individual test passed or not.

 1$here = Split-Path -Parent $MyInvocation.MyCommand.Path
 2$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path).Replace('.Tests.', '.')
 3. "$here\$sut"
 4
 5Describe 'Get-MrSystemInfo' {
 6    It 'Should exist' {
 7        Get-Command -Name Get-MrSystemInfo -ErrorAction SilentlyContinue |
 8        Should Be $true
 9    }
10}

tdd-workflow5a.jpg

Now that I have one simple failing test, I need to write code for the Get-MrSystemInfo function until that one test passes. This is the third law of TDD. You are not allowed to write more production code than is required to make the current test pass. I press in my console session so my workflow goes to the next step and by default it will run the tests every 30 seconds and prompt me with the following message until all tests pass:

1<enter>

tdd-workflow6a.jpg

As mentioned before, I've only written the code required to make the current test pass. No more, no less:

1function Get-MrSystemInfo {
2}

The test passes and I'm once again asked: "Is the code complete?" since there are currently no failing tests:

tdd-workflow2a.jpg

I answer no again and repeat the same process of writing another test:

tdd-workflow3a.jpg

The function needs to return the system manufacturer so I add that one single test:

 1$here = Split-Path -Parent $MyInvocation.MyCommand.Path
 2$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path).Replace('.Tests.', '.')
 3. "$here\$sut"
 4
 5Describe 'Get-MrSystemInfo' {
 6    It 'Should exist' {
 7        Get-Command -Name Get-MrSystemInfo -ErrorAction SilentlyContinue |
 8        Should Be $true
 9    }
10    It 'Should return the system manufacturer' {
11        (Get-MrSystemInfo).Manufacturer |
12        Should Not BeNullOrEmpty
13    }
14}

After saving the new test in the Get-MrSystemInfo.Tests.ps1 file, I press enter again and a different test is specified in my workflow:

1<enter>

tdd-workflow7a.jpg

I write only the code that makes this new test pass:

1function Get-MrSystemInfo {
2    Get-CimInstance -ClassName Win32_ComputerSystem |
3    Select-Object -Property Manufacturer
4}

The test passes and I'm asked again if the code is complete. I'm not so I'm back in the same workflow loop as before, writing another failing test. One thing to keep in mind is each iteration of writing a test and then production code to make the test pass should be quick and easy.

 1$here = Split-Path -Parent $MyInvocation.MyCommand.Path
 2$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path).Replace('.Tests.', '.')
 3. "$here\$sut"
 4
 5Describe 'Get-MrSystemInfo' {
 6    It 'Should exist' {
 7        Get-Command -Name Get-MrSystemInfo -ErrorAction SilentlyContinue |
 8        Should Be $true
 9    }
10    It 'Should return the system manufacturer' {
11        (Get-MrSystemInfo).Manufacturer |
12        Should Not BeNullOrEmpty
13    }
14    It 'Should return the hard disk size' {
15        (Get-MrSystemInfo).'DiskSize(GB)' |
16        Should BeOfType [int]
17    }
18}

Writing the production code for the function to make this new test pass requires some of the existing code be refactored:

 1function Get-MrSystemInfo {
 2    $CS = Get-CimInstance -ClassName Win32_ComputerSystem
 3    $Disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3'
 4
 5    foreach ($Disk in $Disks) {
 6        [pscustomobject]@{
 7            Manufacturer = $CS.Maufacturer
 8            'DiskSize(GB)' = $Disk.Size / 1GB -as [int]
 9        }
10    }
11}

When new code or the refactoring of existing code breaks existing functionality, I can immediately see it as in this scenario where the second test that previously passed is now failing for some reason:

tdd-workflow7a.jpg

The problem is "Manufacturer" was misspelled in the previous code example when it was refactored. This problem has been corrected and now all tests pass.

 1function Get-MrSystemInfo {
 2    $CS = Get-CimInstance -ClassName Win32_ComputerSystem
 3    $Disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3'
 4
 5    foreach ($Disk in $Disks) {
 6        [pscustomobject]@{
 7            Manufacturer = $CS.Manufacturer
 8            'DiskSize(GB)' = $Disk.Size / 1GB -as [int]
 9        }
10    }
11}

You might be thinking the hard drive capacity or size won't do much good without the drive letter and you're right so that would be the next test to write.

Keep repeating this process until the function is complete. The production code can be refactored as much as you want even months down the road, even when your organization has experienced turnover and the person or people who originally wrote the code are no longer with your organization. Simply rerun the unit tests and you'll know if the code still produces the desired results. Best of all, you'll no longer have to worry about breaking production code when adding new features or refactoring existing code in the future.

µ