Add Performance Monitor to Your NUnit Tests

Tim Muir
6 min readJun 12, 2024

--

It’s difficult to see what’s going on inside Windows containers, presenting challenges when troubleshooting issues related to resource consumption. Browsers and other items needed to run tests can be resource hogs, but it’s difficult to pinpoint the cause of crashes or slowness without some insights. We can add system performance monitoring by taking advantage of .NET and C# features — and so can you!

This feature provides simple statistics of some important computing metrics, writing the results to standard output. This allows us to view results in Jenkins Blue Ocean logs and Allure Console.

Feature: System Performance Monitoring in NUnit Tests

Overview

This feature integrates system performance monitoring into NUnit tests to capture key metrics during test execution. It provides insights into CPU usage, disk space usage, memory usage, network speeds, and thread counts. These metrics are recorded throughout the test execution and reported upon test completion, offering valuable data for performance analysis and optimization.

Key Metrics Monitored

  • CPU Usage: Tracks the minimum, maximum, and average CPU utilization.
  • Disk Space Usage: Measures the disk space consumption, reporting minimum and maximum usage.
  • Memory Usage: Monitors memory consumption, reporting minimum and maximum usage.
  • Network Speeds: Measures the network bytes sent and received per second.
  • Thread Count: Tracks the minimum and maximum number of threads used by the process.

How It Works

  1. Setup: During test setup ([SetUp]), a PerformanceMonitor instance is created, and several System.Threading.Timer instances are initialized to periodically update the performance metrics.
  2. Monitoring: The PerformanceMonitor uses various System.Diagnostics.PerformanceCounter instances to gather metrics for CPU, memory, and network usage. Disk usage is monitored using System.IO.DriveInfo, and thread count is monitored using System.Diagnostics.Process.
  3. Null-Conditional Operator: Ensures safe disposal of resources (timer?.Dispose()) without explicit null checks, preventing potential NullReferenceException errors.
  4. Teardown: In the [TearDown] method, the timers are safely disposed of, and the PerformanceMonitor reports the collected metrics.

Benefits

  • Performance Insights: Provides detailed performance data, enabling optimization and identifying potential bottlenecks.
  • Robust and Safe: Utilizes modern C# features to ensure robustness and prevent runtime errors.
  • Flexible and Extensible: Easily integrates into existing NUnit test suites and can be extended to monitor additional metrics.

On to the code:

The service class contains the constructor and other methods that collect, process, and report the metrics. Include a namespace if applicable:


using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Management;
using System.Net.NetworkInformation;

public class PerformanceMonitor
{
private readonly PerformanceCounter cpuCounter;
private readonly PerformanceCounter memoryCounter;
private readonly PerformanceCounter networkSentCounter;
private readonly PerformanceCounter networkReceivedCounter;
private readonly DriveInfo driveInfo;
private readonly NetworkInterface networkInterface;
private readonly Process currentProcess;

private float minCpuUsage = float.MaxValue;
private float maxCpuUsage = float.MinValue;
private long minAvailableDiskSpace = long.MaxValue;
private long maxAvailableDiskSpace = long.MinValue;
private float minAvailableMemory = float.MaxValue;
private float maxAvailableMemory = float.MinValue;
private float minNetworkSentSpeed = float.MaxValue;
private float maxNetworkSentSpeed = float.MinValue;
private float minNetworkReceivedSpeed = float.MaxValue;
private float maxNetworkReceivedSpeed = float.MinValue;
private int minThreadCount = int.MaxValue;
private int maxThreadCount = int.MinValue;

private readonly long totalDiskSpace;
private readonly long totalMemory;
private readonly float maxNetworkSpeed; // Define a theoretical maximum network speed in Bytes/sec

private float cpuUsageSum = 0;
private int cpuUsageCount = 0;

public PerformanceMonitor(string driveLetter, string networkInterfaceName, float maxNetworkSpeed = 100000000) // 100 MB/s
{
cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
memoryCounter = new PerformanceCounter("Memory", "Available MBytes");
networkInterface = NetworkInterface.GetAllNetworkInterfaces()
.FirstOrDefault(ni => ni.Name == networkInterfaceName);

if (networkInterface != null)
{
networkSentCounter = new PerformanceCounter("Network Interface", "Bytes Sent/sec", networkInterface.Description);
networkReceivedCounter = new PerformanceCounter("Network Interface", "Bytes Received/sec", networkInterface.Description);
}

driveInfo = new DriveInfo(driveLetter);
totalDiskSpace = driveInfo.TotalSize;
totalMemory = GetTotalPhysicalMemory();
this.maxNetworkSpeed = maxNetworkSpeed;
currentProcess = Process.GetCurrentProcess();
}

private long GetTotalPhysicalMemory()
{
using (var searcher = new ManagementObjectSearcher("SELECT Capacity FROM Win32_PhysicalMemory"))
{
long totalCapacity = 0;
foreach (var wmiObj in searcher.Get())
{
totalCapacity += Convert.ToInt64(wmiObj["Capacity"]);
}
return totalCapacity;
}
}

public void UpdateCpuUsage()
{
float cpuUsage = cpuCounter.NextValue();
// Smoothing to avoid 0% and 100% extremes - adjust as necessary
if (cpuUsage > 1 && cpuUsage < 99){
if (cpuUsage < minCpuUsage) minCpuUsage = cpuUsage;
if (cpuUsage > maxCpuUsage) maxCpuUsage = cpuUsage;
}

// Smoothing to avoid 0% and 100% extremes - adjust as necessary
if (cpuUsage > 1 && cpuUsage < 99)
{
cpuUsageSum += cpuUsage;
cpuUsageCount++;
}
}

public void UpdateDiskUsage()
{
long availableDiskSpace = driveInfo.AvailableFreeSpace;
if (availableDiskSpace < minAvailableDiskSpace) minAvailableDiskSpace = availableDiskSpace;
if (availableDiskSpace > maxAvailableDiskSpace) maxAvailableDiskSpace = availableDiskSpace;
}

public void UpdateMemoryUsage()
{
float availableMemory = memoryCounter.NextValue();
if (availableMemory < minAvailableMemory) minAvailableMemory = availableMemory;
if (availableMemory > maxAvailableMemory) maxAvailableMemory = availableMemory;
}

public void UpdateNetworkUsage()
{
float networkSentSpeed = networkSentCounter?.NextValue() ?? 0;
float networkReceivedSpeed = networkReceivedCounter?.NextValue() ?? 0;
if (networkSentSpeed < minNetworkSentSpeed) minNetworkSentSpeed = networkSentSpeed;
if (networkSentSpeed > maxNetworkSentSpeed) maxNetworkSentSpeed = networkSentSpeed;
if (networkReceivedSpeed < minNetworkReceivedSpeed) minNetworkReceivedSpeed = networkReceivedSpeed;
if (networkReceivedSpeed > maxNetworkReceivedSpeed) maxNetworkReceivedSpeed = networkReceivedSpeed;
}

public void UpdateThreadCount()
{
int currentThreadCount = currentProcess.Threads.Count;
if (currentThreadCount < minThreadCount) minThreadCount = currentThreadCount;
if (currentThreadCount > maxThreadCount) maxThreadCount = currentThreadCount;
}

public void ReportMinMaxValues()
{
float avgCpuUsage = cpuUsageCount > 0 ? cpuUsageSum / cpuUsageCount : 0;
Console.WriteLine($"Min CPU Usage: {minCpuUsage}%");
Console.WriteLine($"Max CPU Usage: {maxCpuUsage}%");
Console.WriteLine($"Average CPU Usage: {avgCpuUsage}%");

if (totalDiskSpace > 0)
{
long minDiskUsage = totalDiskSpace - maxAvailableDiskSpace;
long maxDiskUsage = totalDiskSpace - minAvailableDiskSpace;
Console.WriteLine($"Min Disk Usage: {minDiskUsage / (1024 * 1024)} MB ({(double)minDiskUsage / totalDiskSpace:P2})");
Console.WriteLine($"Max Disk Usage: {maxDiskUsage / (1024 * 1024)} MB ({(double)maxDiskUsage / totalDiskSpace:P2})");
}

if (totalMemory > 0)
{
float minMemoryUsage = (totalMemory - maxAvailableMemory * 1024 * 1024);
float maxMemoryUsage = (totalMemory - minAvailableMemory * 1024 * 1024);
Console.WriteLine($"Min Memory Usage: {minMemoryUsage / (1024 * 1024)} MB ({minMemoryUsage / totalMemory:P2})");
Console.WriteLine($"Max Memory Usage: {maxMemoryUsage / (1024 * 1024)} MB ({maxMemoryUsage / totalMemory:P2})");
}

if (maxNetworkSpeed > 0)
{
Console.WriteLine($"Min Network Sent Speed: {minNetworkSentSpeed / 1024} KB/s ({minNetworkSentSpeed / maxNetworkSpeed:P2})");
Console.WriteLine($"Max Network Sent Speed: {maxNetworkSentSpeed / 1024} KB/s ({maxNetworkSentSpeed / maxNetworkSpeed:P2})");

Console.WriteLine($"Min Network Received Speed: {minNetworkReceivedSpeed / 1024} KB/s ({minNetworkReceivedSpeed / maxNetworkSpeed:P2})");
Console.WriteLine($"Max Network Received Speed: {maxNetworkReceivedSpeed / 1024} KB/s ({maxNetworkReceivedSpeed / maxNetworkSpeed:P2})");
}

Console.WriteLine($"Min Thread Count: {minThreadCount}");
Console.WriteLine($"Max Thread Count: {maxThreadCount}");
}
}

Instantiation of the monitor and timers are in the base abstract Test class, within the [SetUp] method. Safe disposal and final reporting are in the [TearDown]:

using NUnit.Framework;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Management;
using System.Net.NetworkInformation;
using System.Threading;

[Parallelizable] // Optional, if used
[TestFixture]
[AllureNUnit] // If used
[AllureDisplayIgnored]
public abstract class Test
{
protected PerformanceMonitor performanceMonitor;
protected Timer cpuTimer;
protected Timer diskTimer;
protected Timer memoryTimer;
protected Timer networkTimer;
protected Timer threadTimer;

[SetUp]
public void Setup()
{
performanceMonitor = new PerformanceMonitor("C", "Ethernet");
cpuTimer = new Timer(state => performanceMonitor.UpdateCpuUsage(), null, 0, 1000);
diskTimer = new Timer(state => performanceMonitor.UpdateDiskUsage(), null, 0, 10000);
memoryTimer = new Timer(state => performanceMonitor.UpdateMemoryUsage(), null, 0, 2000);
networkTimer = new Timer(state => performanceMonitor.UpdateNetworkUsage(), null, 0, 3000);
threadTimer = new Timer(state => performanceMonitor.UpdateThreadCount(), null, 0, 1000);
}

[TearDown]
public void Teardown()
{
cpuTimer?.Dispose();
diskTimer?.Dispose();
memoryTimer?.Dispose();
networkTimer?.Dispose();
threadTimer?.Dispose();

performanceMonitor?.ReportMinMaxValues();
}
}

Usage in a test is trivial, and no further code is needed, depending on how [SetUp] is implemented (such as in an inheriting Test class).

Results appear in Jenkins Allure Report Overview: Execution > Test Body > Console Output:

As well as Blue Ocean step console logs:

And not least, in your local Visual Studio Test Explorer or Resharper Unit Test Session results:

--

--