Why check scripts at all

Scripts are code, and they fail like code: a missing quote that only matters when a file name has a space, a cd that fails on one server, a function that returns "OK" for 100% disk usage. The difference is that scripts often run with admin rights on production machines, so the bug is found by the system rather than by a user. Two kinds of check catch most of these problems early:

  • A linter does static analysis: it reads the script without running it and recognizes patterns that are usually mistakes. It costs nothing to run and needs no test data.
  • A unit test runs one function with known input and checks the result. It catches wrong logic, which no linter can see.
Job Bash PowerShell
Lint ShellCheck (0.11.0) PSScriptAnalyzer (1.25.0)
Test bats-core (1.14.0) Pester (6.2.0)
Format shfmt Invoke-Formatter (in PSScriptAnalyzer)
Style guide Google Shell Style Guide PowerShell Practice and Style (PoshCode)

Latest versions as of October 2026.

ShellCheck

ShellCheck is a free, open-source linter for shell scripts (Bash, sh, dash and ksh). It knows hundreds of common mistakes, each with a code such as SC2086, a severity (error, warning, info, style) and often a suggested fix. It is in the package repositories of all major Linux distributions and Homebrew, runs in editors such as VS Code through an extension, and has a web version at shellcheck.net. Here is a short rotation script with typical problems:

cat > rotate-exports.sh <<'END'
#!/bin/bash
dir=$1
cd $dir
export STAMP=$(date +%F)
for f in $(ls *.csv); do
  mv $f archive/$STAMP-$f
done
END
shellcheck rotate-exports.sh

In rotate-exports.sh line 3:
cd $dir
^-----^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
   ^--^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
cd "$dir" || exit


In rotate-exports.sh line 4:
export STAMP=$(date +%F)
       ^---^ SC2155 (warning): Declare and assign separately to avoid masking return values.


In rotate-exports.sh line 5:
for f in $(ls *.csv); do
         ^---------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
              ^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.


In rotate-exports.sh line 6:
  mv $f archive/$STAMP-$f
     ^-- SC2086 (info): Double quote to prevent globbing and word splitting.
                ^----^ SC2086 (info): Double quote to prevent globbing and word splitting.
                       ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
  mv "$f" archive/"$STAMP"-"$f"

For more information:
  https://www.shellcheck.net/wiki/SC2045 -- Iterating over ls output is fragi...
  https://www.shellcheck.net/wiki/SC2155 -- Declare and assign separately to ...
  https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...

Every finding is a real bug waiting for the right input:

  • SC2164: if cd fails, the script carries on in the wrong folder and moves the wrong files.
  • SC2086: an unquoted variable is split into words and glob-expanded; a file called meter readings.csv becomes two arguments (see Variables, Quoting and Expansion).
  • SC2155: in export STAMP=$(…) the exit status is that of export, which always succeeds, so a failing date goes unnoticed.
  • SC2045 and SC2035: looping over ls output breaks on spaces, and a file named -rf.csv would be read as options. Loop over a glob instead.

ShellCheck exits with status 1 when it finds something and 0 when the script is clean, which makes it easy to use as a gate. The corrected script:

cat > rotate-exports.sh <<'END'
#!/bin/bash
set -euo pipefail
dir=${1:?usage: rotate-exports.sh DIR}
cd "$dir" || exit 1
stamp=$(date +%F)
for f in ./*.csv; do
  [[ -e $f ]] || continue
  mv -- "$f" "archive/$stamp-${f#./}"
done
END
shellcheck rotate-exports.sh && echo "shellcheck: no findings"
shellcheck: no findings

Useful options and directives

-f gcc prints one line per finding (file:line:column: severity: message [code]), the format editors and CI systems understand. In that format the info level is called note:

printf '#!/bin/bash\ncd $1\nfor f in $(ls); do echo $f; done\n' > tidy.sh
shellcheck -f gcc tidy.sh
echo "exit status: $?"
tidy.sh:2:1: warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails. [SC2164]
tidy.sh:2:4: note: Double quote to prevent globbing and word splitting. [SC2086]
tidy.sh:3:10: error: Iterating over ls output is fragile. Use globs. [SC2045]
tidy.sh:3:25: note: Double quote to prevent globbing and word splitting. [SC2086]
exit status: 1
  • -S warning reports only warnings and errors (leaving out info and style); useful when you introduce ShellCheck on a large existing collection of scripts.
  • -s sh, or a #!/bin/sh shebang, checks for POSIX sh: then [[ ]], arrays and other Bash features are reported as SC3xxx warnings (see Cross-Platform Scripting). Without any shebang ShellCheck reports SC2148, because it cannot tell which shell you mean.
  • -x follows files loaded with source.
  • A .shellcheckrc file in the script's folder (or a parent folder, or your home folder) sets options for a whole project, for example disable=SC2034.

When you really mean what ShellCheck warns about, silence that one finding with a directive comment on the line before, and say why:

cat > report.sh <<'END'
#!/bin/bash
# The options are deliberately split into separate words:
opts="-n -t"
# shellcheck disable=SC2086
sort $opts readings.txt
END
shellcheck report.sh && echo "shellcheck: no findings"
shellcheck: no findings

(An array, opts=(-n -t) with "${opts[@]}", would avoid the warning altogether; see Arrays, Hash Tables and Objects.)

PSScriptAnalyzer

PSScriptAnalyzer is Microsoft's linter for PowerShell scripts and modules. It checks a set of rules for errors, warnings and information findings: aliases in scripts, unapproved verbs, plain-text password parameters, state-changing functions without ShouldProcess, unused variables and more. The VS Code PowerShell extension runs it as you type. Unlike ShellCheck it is a PowerShell module, installed from the PowerShell Gallery (version 1.25.0 as of October 2026):

Install-PSResource -Name PSScriptAnalyzer       # or: Install-Module -Name PSScriptAnalyzer
Invoke-ScriptAnalyzer -Path ./Export-Readings.ps1

RuleName                            Severity     ScriptName Line  Message
--------                            --------     ---------- ----  -------
PSUseApprovedVerbs                  Warning      Export-Rea 3     The cmdlet 'Delete-OldExport' uses an
                                                 dings.ps1        unapproved verb.
PSAvoidUsingPlainTextForPassword    Warning      Export-Rea 5     Parameter '$Password' should not use
                                                 dings.ps1        String type but either SecureString or
                                                                  PSCredential, otherwise it increases
                                                                  the chance to expose this sensitive
                                                                  information.
PSAvoidUsingCmdletAliases           Warning      Export-Rea 12    'gci' is an alias of 'Get-ChildItem'.
                                                 dings.ps1        Alias can introduce possible problems
                                                                  and make code less readable.

Useful parameters: -Recurse to check a folder, -Severity Error, Warning to filter, and -Settings with a settings file to choose rules for a project. Invoke-Formatter from the same module reformats code to a consistent layout. To silence one rule for one function, put a [Diagnostics.CodeAnalysis.SuppressMessageAttribute('RuleName', '')] attribute on it, again with a comment explaining why.

Writing testable scripts

A script that is one long list of commands can only be tested by running all of it, against real servers. Split it instead: put the logic in functions that take input as parameters and return a result, and keep the part that talks to the outside world (files, network, the clock) thin. A test can then load the functions and call them directly.

In Bash, the last lines of the script decide whether to run main: ${BASH_SOURCE[0]} equals $0 only when the file is executed, not when another script loads it with source. Even without a test framework, a few lines of comparison make a usable test:

cat > lib.sh <<'END'
#!/bin/bash
# Usage percentage → status word. Pure function: easy to test.
disk_status() {
  local pct=$1 limit=${2:-90}
  if (( pct >= limit )); then echo "WARNING"; else echo "OK"; fi
}

main() {
  echo "/var: $(disk_status "$1")"
}

# Run main only when executed, not when sourced by a test
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
fi
END

cat > test-lib.sh <<'END'
#!/bin/bash
source ./lib.sh
fails=0
check() {   # check DESCRIPTION EXPECTED ACTUAL
  if [[ $2 == "$3" ]]; then echo "ok   - $1"; else echo "FAIL - $1: expected '$2', got '$3'"; fails=$((fails + 1)); fi
}
check "below the limit"   OK      "$(disk_status 89)"
check "at the limit"      WARNING "$(disk_status 90)"
check "custom limit"      OK      "$(disk_status 91 95)"
exit $(( fails > 0 ))
END
bash lib.sh 91
bash test-lib.sh
echo "tests exit status: $?"
/var: WARNING
ok   - below the limit
ok   - at the limit
ok   - custom limit
tests exit status: 0

PowerShell does the same with dot sourcing: . ./DiskTools.ps1 runs the file in the current scope, so its functions become available to the test (see Running Scripts). The test cases are a list of hash tables, the same table-driven style that Pester uses:

Set-Content -Path ./DiskTools.ps1 -Value @'
function Get-DiskStatus {
    param([int] $Percent, [int] $Limit = 90)
    if ($Percent -ge $Limit) { 'WARNING' } else { 'OK' }
}
'@

. ./DiskTools.ps1          # dot-source: load the functions, run nothing else

$cases = @(
    @{ Name = 'below the limit'; Percent = 89; Limit = 90; Expected = 'OK' }
    @{ Name = 'at the limit';    Percent = 90; Limit = 90; Expected = 'WARNING' }
    @{ Name = 'custom limit';    Percent = 91; Limit = 95; Expected = 'OK' }
)
foreach ($c in $cases) {
    $actual = Get-DiskStatus -Percent $c.Percent -Limit $c.Limit
    $result = if ($actual -eq $c.Expected) { 'ok  ' } else { 'FAIL' }
    "$result - $($c.Name)"
}
ok   - below the limit
ok   - at the limit
ok   - custom limit

Home-made checks like these are a fine start. Test frameworks add what they lack: clear reports, isolation between tests, setup and teardown, and test doubles that replace real commands.

bats for Bash

bats (Bash Automated Testing System; the maintained version is bats-core, 1.14.0 as of October 2026) is a test framework for Bash and other command-line programs. A test file contains @test blocks; inside, ordinary commands and [ … ] tests either succeed or fail the test. The run helper runs a command and stores its exit status in $status and its combined output in $output, so you can check both without the test itself failing on a non-zero exit. bats is installed from the distribution's packages, npm, or its Git repository.

# test/check-disk.bats
setup() {
  source ./lib.sh
}

@test "warns at the limit" {
  run disk_status 90
  [ "$status" -eq 0 ]
  [ "$output" = "WARNING" ]
}

@test "script prints the status for /var" {
  run bash ./lib.sh 91
  [ "$output" = "/var: WARNING" ]
}

@test "custom limit" {
  [ "$(disk_status 91 95)" = "OK" ]
}
$ bats test/
check-disk.bats
 ✓ warns at the limit
 ✓ script prints the status for /var
 ✓ custom limit

3 tests, 0 failures

The optional helper libraries bats-support and bats-assert add readable assertions such as assert_success and assert_output "WARNING".

Pester for PowerShell

Pester is the standard test framework for PowerShell, used by Microsoft and most module authors. Tests are PowerShell scripts named *.Tests.ps1, with Describe and Context blocks to group them, It blocks for single tests, BeforeAll for setup, and Mock to replace a command with a test double. As of October 2026 the current version is Pester 6.2.0 (Pester 6 appeared in July 2026; the 5.x line still gets updates). Pester 6 recommends new, type-aware assertions such as Should-Be, while the classic Should -Be syntax still works. It needs Windows PowerShell 5.1 or PowerShell 7.4 or later.

# DiskTools.Tests.ps1
BeforeAll {
    . $PSScriptRoot/DiskTools.ps1
}

Describe 'Get-DiskStatus' {
    It 'returns OK below the limit' {
        Get-DiskStatus -Percent 89 | Should -Be 'OK'
    }
    It 'returns WARNING at <Percent>% with limit <Limit>' -ForEach @(
        @{ Percent = 90; Limit = 90 }
        @{ Percent = 99; Limit = 95 }
    ) {
        Get-DiskStatus -Percent $Percent -Limit $Limit | Should -Be 'WARNING'
    }
}

# DiskTools.ps1 here also contains Send-DiskReport, which calls Send-NocAlert.
Describe 'Send-DiskReport' {
    It 'alerts the NOC only when a disk is above the limit' {
        Mock Send-NocAlert { }
        Send-DiskReport -Percent 91
        Should -Invoke Send-NocAlert -Times 1 -Exactly
    }
}
PS> Invoke-Pester -Path ./DiskTools.Tests.ps1
Starting discovery in 1 files.
Discovery found 4 tests in 41ms.
Running tests.
[+] /home/amara/scripts/DiskTools.Tests.ps1 287ms (98ms|151ms)
Tests completed in 291ms
Tests Passed: 4, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0

Style

A style guide matters more for scripts than it seems: the next person to read your script is often reading it during an incident. Pick one guide per language, write it down, and let tools enforce what they can.

Bash: the Google Shell Style Guide

The most cited guide for Bash is Google's. Its main points: indent with 2 spaces and keep lines under 80 characters; use $(…), never backticks; prefer [[ … ]] over [ … ]; quote variables; use lowercase names for local variables and UPPERCASE for constants and exported variables; declare function variables with local, separately from an assignment that runs a command (SC2155); and put the main code in a main function called at the end with main "$@". It also draws a line: a script of more than about 100 lines, or with complicated control flow, should be rewritten in a more structured language (see Cross-Platform Scripting for choosing one).

PowerShell: verbs, full names, help

PowerShell commands are named Verb-Noun, and the verb should be an approved verb: one from the fixed list Microsoft publishes, grouped as Common, Data, Lifecycle, Diagnostic, Communications, Security and Other. Approved verbs make commands predictable: whoever knows Remove-Item guesses Remove-OldExport, never Delete-OldExport. Get-Verb shows the list, and importing a module with unapproved verbs prints a warning:

'Approved: ' + ((Get-Verb -Verb Get, Remove, Delete, Fetch).Verb -join ', ')
(Get-Verb -Verb Remove).Description

Set-Content -Path ./KestrelTools.psm1 -Value @'
function Delete-OldExport { 'deleted' }
function Remove-OldExport { 'removed' }
'@
Import-Module ./KestrelTools.psm1
Approved: Get, Remove
Deletes a resource from a container
WARNING: The names of some imported commands from the module 'KestrelTools' include unapproved verbs that might make them less discoverable. To find the commands with unapproved verbs, run the Import-Module command again with the Verbose parameter. For a list of approved verbs, type Get-Verb.

The other common rules, from the community PowerShell Practice and Style guide: PascalCase for function, parameter and public variable names; full command and parameter names in scripts, no aliases and no positional parameters (Get-ChildItem -Path C:\Logs, not gci C:\Logs), because aliases differ between platforms and are unknown to readers; 4-space indentation with the opening brace on the same line (One True Brace Style); and comment-based help at the top of every script and function, so Get-Help works on it:

function Remove-OldExport {
    <#
    .SYNOPSIS
    Deletes billing exports older than a number of days.
    .EXAMPLE
    Remove-OldExport -Path D:\Exports -Days 30 -WhatIf
    #>
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] [string] $Path,
        [ValidateRange(1, 3650)] [int] $Days = 90
    )
    # ...
}
Topic Bash (Google) PowerShell (PoshCode)
Indentation 2 spaces 4 spaces
Names snake_case, UPPER for constants Verb-Noun, PascalCase
Short forms No backticks; $(…) No aliases, no positional parameters
Structure Functions, main "$@" at the end Advanced functions, param() block
Documentation Header comment, comment per function Comment-based help (.SYNOPSIS, .EXAMPLE)

Running the checks automatically

Checks only help when they run every time. Store scripts in Git and let a CI pipeline run the linters and tests on every push and pull request, so a change that fails cannot be merged. A minimal GitHub Actions workflow (see your first workflow) for a repository with both kinds of scripts:

name: script-checks
on: [push, pull_request]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - name: ShellCheck
        run: shellcheck --severity=warning scripts/*.sh
      - name: PSScriptAnalyzer and Pester
        shell: pwsh
        run: |
          Install-PSResource -Name PSScriptAnalyzer, Pester -TrustRepository
          $findings = Invoke-ScriptAnalyzer -Path ./scripts -Recurse -Severity Error, Warning
          $findings | Format-Table -AutoSize
          if ($findings) { exit 1 }
          Invoke-Pester -Path ./tests -CI

Invoke-Pester -CI sets a non-zero exit code when a test fails and writes a test result file the CI system can display. Running the same commands locally before you push (or in a Git pre-commit hook) gives you the answer in seconds instead of minutes.