Load Testing Using PowerShell

Background

I was tasked with doing a load test for a client. At the start of work on the ticket I found that Telerik Load Testing wasn’t finding my dynamic target. This is because it was looking at query-string parameters but I needed to target values sent via a POST with a multi-part form as its body content. So, I took some code I used for another project where I used the HttpClient from the .Net 4.5 library in PowerShell.

Getting the Required Assembly

The code we will cover uses a .Net 4.5 dynamic link library (.dll). You can get this by installing .Net 4.5 (or higher) SDK. I have also added them to the sample project at the end of the article. Ss a convenience, I like to copy the .dll file(s) I need to the same directory to the PowerShell script (.ps1 file).

Understanding the Basics

A virtual user will make a series of URL requests, often with wait times (called “Think Time”) peppered in to simulate an actual users interaction with the server). We will use the HttpClient for the virtual user’s session as it is thread safe. It needs to be thread safe because we will use PowerShell RunSpaces and Jobs to spin up concurrent users. The calculator for finding the number of concurrent users can be found at webperfomance.com. Note that the number of concurrent users you can actually use is limited by the hardware the script is executing on as we are spinning up multiple instances of PowerShell.

Multi-Threading  PowerShell

The code for multi-threading comes from TheSurleyAdmin.com. The code works like this: Each thread is started up and given the task within the $ScriptBlock. Once the jobs are all done, the results are spit out into a GridView.

# http://thesurlyadmin.com/2013/02/11/multithreading-powershell-scripts/
cls

$Throttle = 5
$ScriptBlock = {
    Param (
        [int]$RunNumber
    )
    $RanNumber = Get-Random -Minimum 1 -Maximum 10
    Start-Sleep -Seconds $RanNumber
    $RunResult = New-Object PSObject -Property @{
        RunNumber = $RunNumber
        Sleep = $RanNumber
    }
    Return $RunResult
}

$RunspacePool = [RunspaceFactory]::CreateRunspacePool(1, $Throttle)
$RunspacePool.Open()
$Jobs = @()

1..20 | % {
    $Job = ::Create().AddScript($ScriptBlock).AddArgument($_)
    $Job.RunspacePool = $RunspacePool
    $Jobs += New-Object PSObject -Property @{
        RunNum = $_
        Pipe = $Job
        Result = $Job.BeginInvoke()
    }
}

Write-Host "Waiting.." -NoNewline
Do {
    Write-Host "." -NoNewline
    Start-Sleep -Seconds 1
} While ( $Jobs.Result.IsCompleted -contains $false)
Write-Host "All jobs completed!"

$Results = @()
ForEach ($Job in $Jobs)
{   
    $Results += $Job.Pipe.EndInvoke($Job.Result)
}

$Results | Out-GridView

Adding in the HttpClient

Now that we have our base, we’ll need to modify it to suit our needs. The first thing we’ll need to add is the HttpClient. Because we are firing up multiple instances of PowerShell, the import will need to happen in the $ScriptBlock.

$ScriptBlock = {
    ...
    # Get the current working directory
    $pwd = pwd
    # Import the .Net Http Dynamic Link Library
    Add-Type -Path "$pwd\System.Net.Http.dll"
    # Instantiate a new http client, using the imported library
    $client = New-Object -TypeName System.Net.Http.Httpclient
    ...
}

Driving the Test Using a Data-Bound CSV File

Instead of having a list of URLs in the script, I like to use a .csv file as the PowerShell cmdlet is amazingly simple to use. In this CSV I have just a few columns; ID, METHOD, and URL. Method was needed so that when a POST was encountered, I could create the body dynamically. More on that in a moment.

$ScriptBlock = {
    ...
    # Define the path to the CSV file that holds all the URLs to be tested
    $pathToCsv = "$pwd\URLs.csv"
    # Import the CSV (defined above)
    $csv = Import-csv -path $pathToCsv
    ...
}

Handling POSTs

For my load test, the POSTs needed to have unique email addresses. For this I use one of my favorite tricks: the Gmail “+” trick and UnixTimeStamp. With Gmail you can add + to make a unique email address that routes back to the original. As an example, “tim+1@gmail.com” is actually “tim@gmail.com”. So after the “+” I just throw in a UnixTimeStamp. I found that this wasn’t unique enough so I also added the thread number to the string. Also, notice the use of MultipartFormDataContent. That is the bit that creates the form to be used as the POST’s body content.

$ScriptBlock = {
    ...
    # For Loop to run through each row of data in the CSV
    foreach($line in $csv) {
        $startTime = Get-Date
        $email = ''
        $statusCode = ''
        # If the request is a POST, then we need to set a unique email address
        if ($line.Method -eq "POST")  {
            $mfdc = New-Object -TypeName System.Net.Http.MultipartFormDataContent
            $pathToFormDataCsv = "$pwd\Form-Data.csv"
            $formData = Import-csv -path $pathToFormDataCsv
            foreach ($row in $formData)
            {
                $key = $row.Key
                $value = New-Object -TypeName System.Net.Http.StringContent $row.Value
                if ($key -eq "email") {
                    $email = "tim+$RunNumber{0:G}@spiredigital.com" -f [int][double]::Parse((Get-Date -UFormat %s))
                    $value = New-Object -TypeName System.Net.Http.StringContent $email
                }
                $mfdc.Add($value, $key)
            }
            $response = $client.PostAsync($line.URL, $mfdc).Result
            # Get the status code
            $statusCode = $response.StatusCode
        }
        # Otherwise, just make the request
        else {
            $request = New-Object -TypeName System.Net.Http.HttpRequestMessage
            $request.RequestUri = $line.URL
            $request.Method = $line.Method
            $response = $client.SendAsync($request).Result
            # Get the status code
            $statusCode = $response.StatusCode
        }
        $endTime = Get-Date
        ...
    }
}

Handling Results

After each request, I log the time it took and add my test data to a PowerShell Object using Add-Member to add properties to the generic object. That object is returned as the result of the job. Then after all jobs are complete, the results are aggregated and spit out in a GridView (same as the original script from TheSurleyAdmin).

$ScriptBlock = {
    ...
    # For Loop to run through each row of data in the CSV
    foreach($line in $csv) {
        ...
        $runTime = New-TimeSpan -Start $startTime -End $endTime
        # Save the test data to a PowerShell object
        $iterationResult = New-Object PSObject
        $iterationResult | Add-Member -NotePropertyName "Thread" -NotePropertyValue $RunNumber
        $iterationResult | Add-Member -NotePropertyName "StatusCode" -NotePropertyValue $statusCode
        $iterationResult | Add-Member -NotePropertyName "URL" -NotePropertyValue $line.URL
        $iterationResult | Add-Member -NotePropertyName "Time" -NotePropertyValue $runTime
        # Add the new object to the test result
        $threadResults += $iterationResult
    }
    # Return the results array for this thread
    Return $threadResults
}

Putting It All Together

Here is the final script:

# Set the number of threads we want to run concurrently
$threads = 5
# The script block is the code to be executed by each thread. Threads do not share data so if a library needs to be imported, then each thread needs to do so.
$ScriptBlock = {
    # Parameter - https://technet.microsoft.com/en-us/magazine/jj554301.aspx
    Param ( [int]$RunNumber )
    # Get the current working directory
    $pwd = pwd
    # Import the .Net Http Dynamic Link Library
    Add-Type -Path "$pwd\System.Net.Http.dll"
    # Instantiate a new http client, using the imported library
    $client = New-Object -TypeName System.Net.Http.Httpclient
    # Define the path to the CSV file that holds all the URLs to be tested
    $pathToCsv = "$pwd\URLs.csv"
    # Import the CSV (defined above)
    $csv = Import-csv -path $pathToCsv
    # Create an array to hold iteration results
    $threadResults = @()
    # For Loop to run through each row of data in the CSV
    foreach($line in $csv) {
        $startTime = Get-Date
        $email = ''
        $statusCode = ''
        # If the request is a POST, then we need to set a unique email address
        if ($line.Method -eq "POST")  {
            $mfdc = New-Object -TypeName System.Net.Http.MultipartFormDataContent
            $pathToFormDataCsv = "$pwd\Form-Data.csv"
            $formData = Import-csv -path $pathToFormDataCsv
            foreach ($row in $formData)
            {
                $key = $row.Key
                $value = New-Object -TypeName System.Net.Http.StringContent $row.Value
                if ($key -eq "email") {
                    $email = "tim+$RunNumber{0:G}@spiredigital.com" -f [int][double]::Parse((Get-Date -UFormat %s))
                    $value = New-Object -TypeName System.Net.Http.StringContent $email
                }
                $mfdc.Add($value, $key)
            }
            $response = $client.PostAsync($line.URL, $mfdc).Result
            # Get the status code
            $statusCode = $response.StatusCode
        }
        # Otherwise, just make the request
        else {
            $request = New-Object -TypeName System.Net.Http.HttpRequestMessage
            $request.RequestUri = $line.URL
            $request.Method = $line.Method
            $response = $client.SendAsync($request).Result
            # Get the status code
            $statusCode = $response.StatusCode
        }
        $endTime = Get-Date
        $runTime = New-TimeSpan -Start $startTime -End $endTime
        # Save the test data to a PowerShell object
        $iterationResult = New-Object PSObject
        $iterationResult | Add-Member -NotePropertyName "Thread" -NotePropertyValue $RunNumber
        $iterationResult | Add-Member -NotePropertyName "StatusCode" -NotePropertyValue $statusCode
        $iterationResult | Add-Member -NotePropertyName "URL" -NotePropertyValue $line.URL
        $iterationResult | Add-Member -NotePropertyName "Time" -NotePropertyValue $runTime
        # Add the new object to the test result
        $threadResults += $iterationResult
    }
    # Return the results array for this thread
    Return $threadResults
}
# Create a pool of powershell instances
$RunspacePool = [RunspaceFactory]::CreateRunspacePool(1, $threads)
$RunspacePool.Open()
# Create an Jobs array and invoke each job as they are added to the array
$Jobs = @()
1..$threads | % {
    $Job = ::Create().AddScript($ScriptBlock).AddArgument($_)
    $Job.RunspacePool = $RunspacePool
    $Jobs += New-Object PSObject -Property @{
        RunNum = $_
        Pipe = $Job
        Result = $Job.BeginInvoke()
    }
}
# Clear screen
cls
# Write a status message to the console, until all jobs finish
Write-Host "Working.." -NoNewline
Do {
    Write-Host "." -NoNewline
    Start-Sleep -Seconds 1
} While ( $Jobs.Result.IsCompleted -contains $false)
Write-Host "All jobs completed!"
# Package all threads results into one object
$Results = @()
ForEach ($Job in $Jobs) {   
    $Results += $Job.Pipe.EndInvoke($Job.Result)
}
# Display the results in a table
$Results | Out-GridView

gridview-example

Download the Sample

Note that I have removed the URLs as they are subject to a confidentiality agreement with the client.

Load Test (compressed via .zip).

Performance Enhancements and Load Testing Tips

Testing – What to Look For

Enhancements – Low Hanging Fruits

  1. Optimize images
    1. Images should not exceed 1MB if at all possible
    2. Should be optimized
      1. https://imageoptim.com/ – for Mac OSX
      2. https://tinypng.com/ – Online tool
    3. Large images that are resized via html/js should be that size in the first place
  2. Minify all JS/CSS
  3. Do not load videos on page load (YouTube videos like to buffer)
    1. Instead, use an image, that when clicked will load the video
  4. Enable gzip compression (server setting)
  5. Limit API calls
    1. For auto-complete search:
      1. Do not submit until at least 2 or 3 characters are entered
      2. And/or use a timer to send a request only after the user has stopped typing for 2 seconds

Enhancements – Other Possible Enhancements

  1. Set up a caching mechanism
    1. Caching will respond to a request with a previous response if the requests are the same
      1. WordPress has tons of plug-ins for this
      2. http://memcached.org/ – *nix
      3. Many more options exist
  2. Set up a CDN
    1. Put assets on another server to reduce load on the server that is handling requests
      1. AWS has a service called CloudFront
      2. Many more options exist