Throughout this series we’ve explored how classes can help us structure automation solutions.

We’ve learned how to:

  • Build classes
  • Use inheritance responsibly
  • Design reusable frameworks
  • Separate responsibilities

Now we arrive at a topic that often separates hobby automation from production automation:

Testing.

Because no matter how elegant your architecture becomes, it doesn’t matter if nobody trusts the result. And trust is built through testing. Many PowerShell engineers avoid testing because they associate it with application developers writing thousands of unit tests – I must say that I also do not test as much as I should.

The reality is much simpler. You don’t need to test everything. You need to test the things that would hurt if they broke. And classes make that significantly easier.

The Automation Problem Nobody Talks About

Imagine this class:

class Server {

    [string]$Name

    [bool] IsOnline() {
        return Test-Connection -ComputerName $this.Name -Count 1 -Quiet
    }
}

Looks straightforward, but imagine somebody modifies it six months later.

class Server {

    [string]$Name

    [bool] IsOnline() {
        return Test-Connection -ComputerName $this.Name -Count 5 -Quiet
    }
}

Still looks reasonable. But perhaps this subtle change introduces performance issues or perhaps a completely unrelated change breaks connectivity checks entirely. How would you know? Without tests you’ll only know when production tells you. With tests we’ll see it before it becomes a problem. We’ll be testing with Pester. I’m not an expert in Pester, but I’ll do my best to explain it as good as I can.

What Should We Test?

Not everything deserves a test. For example Get-Date does not need testing. Microsoft has already tested that. A useful rule of thumb:

Test:

  • Business logic
  • Validation
  • Calculations
  • Decision making
  • Transformations

Avoid testing:

  • Microsoft cmdlets
  • .NET functionality
  • PowerShell language features

Focus on your code.

Why Classes Improve Testability

Consider a traditional script:

function Get-ServerHealth {

    $servers = Get-Content servers.txt

    foreach ($server in $servers) {

        # logic

    }

}

Everything exists inside one function. Testing individual behaviour is difficult. But with classes we have isolated these behaviour. Which makes testing extremely simple.

class Server {

    [datetime]$LastBootTime

    [int] GetUptimeDays() {

        return (
            (Get-Date) - $this.LastBootTime
        ).Days
    }
}

Our First Pester Test

Suppose we have:

class Server {

    [datetime]$LastBootTime

    [int] GetUptimeDays() {

        return (
            (Get-Date) - $this.LastBootTime
        ).Days
    }
}

A basic test might look like:

Describe "Server uptime calculations" {

    It "Returns uptime in days" {

        $server = [Server]::new()

        $server.LastBootTime = (Get-Date).AddDays(-30)

        $result = $server.GetUptimeDays()

        $result | Should -Be 30
    }
}

This is simple, readable and predictable. But most importantly: repeatable.

Testing Object State

Many class methods modify object properties.

class Server {

    [bool]$Online

    [void] MarkOffline() {

        $this.Online = $false
    }
}

Testing becomes straightforward.

Describe "Server state changes" {

    It "Marks server offline" {

        $server = [Server]::new()

        $server.Online = $true

        $server.MarkOffline()

        $server.Online | Should -BeFalse
    }
}

Notice we’re testing behaviour rather than implementation. We don’t care how it becomes offline. We care that it does.

Testing Constructors

Constructors are often overlooked. Yet they can contain critical logic.

class Server {

    [string]$Name

    Server(
        [string]$Name
    ) {

        $this.Name = $Name
    }
}

Describe "Server creation" {

    It "Stores server name" {

        $server = [Server]::new("SRV01")

        $server.Name | Should -Be "SRV01"
    }
}

This may seem trivial, until somebody modifies constructor behaviour in the future.

Testing Validation Logic

Validation is one of the best candidates for unit tests.

class Server {

    [string]$Name

    [bool] IsValid() {

        return (
            -not [string]::IsNullOrEmpty($this.Name) ## returns True if it has a value
        )
    }
}

Possible tests:

Describe "Server validation" {

    It "Accepts valid names" {

        $server = [Server]::new()

        $server.Name = "SRV01"

        $server.IsValid() | Should -BeTrue
    }

    It "Rejects empty names" {

        $server = [Server]::new()

        $server.Name = ""

        $server.IsValid() | Should -BeFalse
    }
}

Validation bugs are notoriously expensive in production. Think about quering mailboxes without a name, the cmdlets will default to the first 1000. What if you do a change on your results? See also my earlier post: When Automation Goes Wrong: A Painful Lesson in PowerShell

Testing validation, on the other hand, is usually cheap and will save you from mistakes like the example above.

The Challenge of External Dependencies

Things become harder when classes interact with external systems.

class Server {

    [string]$Name

    [bool] IsOnline() {

        return Test-Connection -ComputerName $this.Name -Count 1 -Quiet
    }
}

The problem with testing this is that network availability changes frequently. Therefore tests should not depend on outside factors like:

  • DNS
  • Networking
  • Azure
  • VMware
  • Active Directory
  • SQL

Otherwise your tests become unreliable. Is is better to use mocking in these cases.

Understanding Mocking

What is mocking? Mocking means replacing real behaviour with simulated behaviour.

Instead of actually executing Test-Connection, wich returns a value we can’t control, we run a mock:

Mock Test-Connection {
    return $true
}

Now the test focuses on our code rather than network conditions.

Testing Service Classes

Consider the service class from Part 4.

class HealthCheckService {

    [HealthResult] TestServer(
        [Server]$Server
    ) {

        $result = [HealthResult]::new()

        $result.ServerName = $Server.Name

        $result.Online = Test-Connection -ComputerName $this.Name -Count 1 -Quiet

        return $result
    }
}

Testing becomes:

Describe "Health checks" {

    Mock Test-Connection {
        return $true
    }

    It "Returns online result" {

        $server = [Server]::new("SRV01")

        $service = [HealthCheckService]::new()

        $result = $service.TestServer($server)

        $result.Online | Should -BeTrue
    }
}

We do not require an actual server or a network. Just business logic. Exactly what a unit test should focus on.

Testing Failures

Another mistake regularly made is that engineers unconsciously only test successful scenarios. Thats not how production works. Production actively searches for weaknesses. Suppose we have:

class Server {

    [string]$Name

    [bool] IsValid() {

        return (-not [string]::IsNullOrWhiteSpace($this.Name))
    }
}

Good tests include: Normal values, empty values, Null values and unexpected values

It "Rejects null names" {

    $server = [Server]::new()

    $server.Name = $null

    $server.IsValid() | Should -BeFalse
}

It "Rejects spaces only names" {

    $server = [Server]::new()

    $server.Name = "  "

    $server.IsValid() | Should -BeFalse
}

The most valuable tests often target the paths nobody expects.

Measuring Confidence

A common question is:

“How many tests should I have?”

There is no universal answer. Instead ask:

“Do I have confidence to make changes?”

If every code modification feels dangerous: Not enough tests.

If changes can be made safely: Probably enough tests.

The goal isn’t coverage in percentages. The goal is confidence that your code does what it supposed to do.

The Automation Engineer’s Secret Weapon

One overlooked benefit of testing is fearless refactoring. Without tests you could think: “Don’t touch that script.”, but with the tests (I hope) you will think: “Let’s improve it.” This becomes increasingly important as automation grows. Many organisations have critical scripts nobody wants to modify. Not because they’re difficult. But because they’re untested. Testing changes that equation entirely.

What to Test First

If you’re new to Pester, start small. Test:

  1. Constructors
  2. Validation logic
  3. Calculations
  4. Business rules
  5. Decision making

Leave integration tests and advanced mocking until later. The biggest wins often come from the simplest tests.

Summary

Class-based automation naturally lends itself to testing because behaviour is isolated.

Good candidates for testing include:

  • Constructors
  • Validation
  • Calculations
  • State changes
  • Business logic
  • Service methods

Pester helps us verify:

  • Objects behave correctly
  • Code changes don’t introduce regressions
  • Refactoring remains safe
  • Automation remains trustworthy

Testing is not about proving that code works. It’s about proving that future changes haven’t broken it.

Conclusion

The ultimate goal of automation is reliability. Classes help organise complexity. Frameworks help organise solutions. Testing helps maintain trust. Together they create automation that can survive long after its original author has moved on. When a script becomes business-critical, testing stops being a luxury and becomes part of the design. And once you’ve experienced the confidence that comes from making large changes backed by solid tests, it’s extremely difficult to go back.

Next in This Series

Part 6: Class Anti-Patterns and When Classes Become the Wrong Tool

In the final part of this series we’ll explore:

  • Overengineering
  • Deep inheritance hierarchies
  • Framework obsession
  • The “everything must be a class” mentality
  • When PSCustomObject is actually the better choice
  • How experienced PowerShell engineers balance simplicity and architecture

Because sometimes the most elegant object-oriented solution is no object-oriented solution at all.

Leave a Reply

Your email address will not be published. Required fields are marked *