Memory Usage Optimization tricks for C# automation tests - Garbage Collector and others
1. Types of languages - compiled and interpreted
Now on our planet we have 2 categories of languages - compiled and interpreted.
Compiled languages are languages where the source code is first translated (compiled) by a special program called a compiler into machine code (an executable file). This machine code can then be executed directly by the processor.
Examples of compiled languages: C, C++, Rust, Go.
Interpreted languages are languages where the source code is executed line by line or command by command by a special program called an interpreter.
Examples of interpreted languages: Python, JavaScript, PHP, Ruby.
Also, all software development languages can be divided into 2 groups: High-Level and Low-Level.
2. Types of languages - high and low level
What is the difference between High-Level and Low-Level Languages?
High-level programming languages are closer to human language. They abstract away hardware details and allow programmers to focus on program logic rather than memory management or CPU structure.
Examples: Python, Java, C#, JavaScript.
Low-level languages are closer to machine code. Programmers using them work directly with memory, CPU registers, and hardware resources.
Examples: Assembler and C/C++ partially.
3. What main languages are used for automation testing?
- JavaScript
- TypeScript
- Python
- JAVA
- C#
- Ruby
- SWIFT / Objective C (for MAC applications)
Let's sort these languages via categories:
| Category | Compiled | Interpreted |
|---|---|---|
| High Level | C#, JAVA, SWIFT, Objective C | Java Script, TypeScript, Python, RUBY |
| Low Level | N/A | N/A |
Two key conclusions:
- "There are no languages which are Interpreted and Low Level".
- "There are no languages which are Low Level and commonly used for automation testing".
The main question of this article - how we can provide memory optimization on compiled and high-level languages in automation test development (we will use C# for example)?
The second question of this article - do we need to provide this memory optimization?
4. When we need to pay attention on memory optimization on C# Automation tests?
Imagine you have a Desktop application, and you need to check what it will be if you launch separate copies of this application. Your RAM will be fully loaded, and it would be great to save some RAM usage on your automation tests.
Imagine creating automation tests for Web applications which use a lot of 3D Graphics, Videos, Images, Sound effects etc. It will be great to save some RAM usage on automation tests.
MAIN THING - you have no ability to increase hardware resources on your virtual machine on CI system.
You are creating automation tests for 3D applications made via Unity, Unreal engine etc.
When you are writing automation tests in C# it is important to think not only about correctness but also about memory efficiency. This is especially relevant in long running or performance tests, where memory leaks can slow down the entire test suite.
5. "New" keyword and Garbage Collector
In C# if you are trying to create some class instance using "new" keyword, you allocate memory on the heap.
Var listOfItems = new List<string>();
But in C# you have no opposite operator. There is no any "delete" operator in C# which can work similarly to other languages.
Var listOfItems = new List<string>();
deleteFromMemory(listOfItems);
In C#, in the .NET framework we have a Garbage Collector.
Main thing - Garbage Collector will clear all objects from memory which was created by "new" C# keyword.
The Garbage Collector (GC) on the .NET helps automatically manage memory by reclaiming unused objects — and with the right approach, it can be used to optimize test stability and performance.
How the Garbage Collector Works?
The GC tracks objects in the managed heap and frees memory used by objects that are no longer referenced. It runs automatically in the background, but in specific test scenarios, developers can trigger it manually to stabilize memory usage between test runs.
6. Some Typical Advice for Automation Tests
6.1 Log your RAM usage
First of all, try to log your RAM usage. Measure before optimizing.
How to do it:
- Integrate profilers: Use dotMemory (JetBrains) or Visual Studio Diagnostic Tools to analyze memory during tests.
- Run tests with the --diag flag in .NET tests for tracing.
6.2 Clearing Lists
You can clear any list! At the end of tests, on TearDown - you can clear your lists, it can help you to save memory.
| No! | Yes! |
|---|---|
|
|
6.3 Avoid boxing and unboxing
Int x = 200;
Object a = x;
i= (int)a;
Boxing floods the heap with lots of small objects and puts additional pressure on GC.
6.4 StringBuilder
Do not concatenate the strings. String methods never modify the original string they make a copy and return results. Use StringBuilder methods instead.
using System.Text;
var sb = new StringBuilder("Title: ");
Console.WriteLine(sb); // Title:
Console.WriteLine($"Length: {sb.Length}"); // 10
Console.WriteLine($"Capacity: {sb.Capacity}"); // 16
sb.Append(" Manual");
Console.WriteLine(sb); // Title: Manual
Console.WriteLine($"Length: {sb.Length}"); // 22
Console.WriteLine($"Capacity: {sb.Capacity}"); // 32
sb.Append(" in C#");
Console.WriteLine(sb); // Title: C# Guide
Console.WriteLine($"Length: {sb.Length}"); //
Console.WriteLine($"Capacity: {sb.Capacity}"); // 32
6.5 Correct using LINQ
Do not call .ToList() in LINQ expressions. Avoid calling ToList() method in LINQ expressions as it allocates memory for the entire collection.
int[] numbersArray = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Filtering even numbers and converting to List
List<int> evenNumbersList = numbersArray.Where(n => n % 2 == 0).ToList();
// Modifying the List
evenNumbersList.Add(12);
// Displaying results
Console.WriteLine("Using ToList():");
Console.WriteLine("Even Numbers List: " + string.Join(", ", evenNumbersList));
6.6 Test Parallelization
If you need memory optimization - remove parallelization of tests. Running tests in parallel increases memory consumption significantly.
/// <summary>
/// Verify the links that navigate to counts
/// </summary>
[Test]
[Parallelizable]
public void NavigationToDocumentCounts()
{
try
{
NavigationToDocumentCountsPrivate();
LogTestCase(GetCurrentMethod());
}
catch (Exception ex)
{
LogTestCase(GetCurrentMethod(), ex.Message, ex);
}
}
6.7 Quit and Dispose methods in WebDriver / Playwright
For Web ui tests use Quit and Dispose methods in TearDown section for Selenium/Playwright.
[SetUp]
public void SetupTest()
{
Driver = WebDriverFactory.GetDriver();
}
[TearDown]
public void TearDown()
{
if (Driver != null)
Driver.Quit();
}
6.8 Using "using" keyword
Release resources explicitly: Use "using" statements for objects that implement IDisposable (e.g., HttpClient, Stream, DbContext). This will ensure memory is released timely.
using (var client = new HttpClient()) {
// Your test
} // Automatically calls Dispose()
Additional best practices:
- Call GC.Collect() in tests: At the end of tests or after heavy operations, manually trigger garbage collection, especially in tests with large data sets. However, use sparingly, as GC is optimized in .NET.
[TestCleanup]
public void Cleanup()
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
- Avoid holding references: Don't keep large collections (lists, dictionaries) in tests longer than necessary. Clean them up after use: myLargeList.Clear(); myLargeList = null;
6.9 Optimizing HTTP Clients and API Requests
- Reuse HttpClient: Don't create a new instance in each test, this leads to socket and memory leaks. Use a static or singleton HttpClient.
public class TestBase
{
protected static readonly HttpClient Client =
new HttpClient();
}
- Add client.Dispose(): to teardown if necessary.
- Limit response size: In tests, request only the necessary data (use query parameters for pagination or filters). Avoid downloading huge JSON responses in their entirety—parse them with a stream using JsonSerializer to System.Text.Json.
- Use IAsyncDisposable: For asynchronous tests, use async using for clients and streams.
6.10 Working with Data and Mocks
- Minimize test data: Generate only the minimum data necessary for the test. Use libraries like Bogus or AutoFixture to create small objects instead of real large datasets.
- Mocks instead of real calls: For unit tests, use Moq or NSubstitute to avoid real API calls and data loading. This will save memory on network buffers.
var mock = new Mock<IService>();
mock.Setup(x => x.GetData()).Returns(new
SmallDataObject());
- Database cleanup: If tests use in-memory DB (SQLite) or testcontainers, reset them after each test: context.Database.EnsureDeleted();
6.11 Use 64-bit mode
Run tests in 64-bit mode: Make sure tests are compiled as AnyCPU with prefer 64-bit—this will allow for efficient use of RAM.
Benefits of 64-bit mode:
- Larger address space.
- More Efficient Garbage Collection
- Better handling of Large Objects
- No pointer size limitation
- JIT Optimizations
7. When Manual GC Makes Sense?
Manual garbage collection (GC.Collect()) should be used only in special cases, such as:
- After large tests that allocate many temporary objects.
- Before performance or memory measurement tests.
- When running in low-memory or containerized environments.
Note: Overusing GC.Collect() can hurt performance, as it pauses all threads during collection.
Best Practices:
- Use GC.Collect() only at key points, not in every test.
- Use GC.GetTotalMemory() to monitor memory usage before and after tests.
- Include GC.WaitForPendingFinalizers() to ensure finalizers finish.
- Combine with memory profiling tools like dotMemory or VS Diagnostic Tools.
Example in NUnit Test
using NUnit.Framework;
using System;
namespace MemoryOptimizationTests
{
[TestFixture]
public class GarbageCollectorTests
{
[Test]
public void TestMemoryOptimization()
{
// Setup: create a lot of temporary objects
for (int i = 0; i < 1_000_000; i++)
{
var temp = new byte[1024];
}
// Force GC to clean up
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
// Measure memory usage
long memoryUsed = GC.GetTotalMemory(forceFullCollection:
false);
Console.WriteLine($"Memory after GC: {memoryUsed / 1024 /
1024} MB");
Assert.Less(memoryUsed, 200 * 1024 * 1024, "Memory usage is
too high after GC");
}
}
}
8. Span collections in C#
Span<T> is a structure that allows you to work with a continuous region of memory without allocating new arrays.
It can point to:
- an array (T[]),
- a portion (slice) of an array,
- stack memory (stackalloc),
- part of a string (ReadOnlySpan<char>).
The main goal: reduce heap allocations and speed up data operations.
Creating a Span from an array
No copying — just direct memory view.
int[] numbers = { 1, 2, 3, 4, 5 };
// Span referencing the whole array
Span<int> span = numbers;
// Create a slice without copying
Span<int> slice = span.Slice(1, 3); // elements 2,3,4
slice[0] = 99; // modifies the original array!
Console.WriteLine(string.Join(", ", numbers));
// Output: 1, 99, 3, 4, 5
Working with parts of an array without allocations. You can manipulate parts of an array directly without creating new ones.
byte[] buffer = new byte[100];
Span<byte> header = buffer.AsSpan(0, 10);
Span<byte> body = buffer.AsSpan(10, 90);
Using stackalloc
Creates memory on the stack, which is very fast and GC-free:
Span<int> stackSpan = stackalloc int[5] { 10,
20, 30, 40, 50 };
int sum = 0;
foreach (var x in stackSpan) sum += x;
Console.WriteLine(sum); // 150
Stack memory is automatically freed when the method ends — safe and efficient.
Parsing substrings without allocations (ReadOnlySpan<char>)
string data = "Name: Viktor; Age: 35";
// Without Span:
string namePart = data.Substring(6, 6); // allocates a
new string
// With ReadOnlySpan:
ReadOnlySpan<char> span = data.AsSpan();
ReadOnlySpan<char> nameSlice = span.Slice(6, 6);
Console.WriteLine(nameSlice.ToString()); // Viktor
Finding characters in a string:
string text = "key=value;id=123";
ReadOnlySpan<char> span = text.AsSpan();
int equalsIndex = span.IndexOf('=');
var key = span.Slice(0, equalsIndex);
var value = span.Slice(equalsIndex + 1);
Console.WriteLine($"Key: {key.ToString()}, Value:
{value.ToString()}");
You can parse strings quickly, without allocations and without Split. This is particularly useful for high-performance parsing scenarios.
Copying data without allocations
int[] source = { 1, 2, 3, 4, 5 };
int[] destination = new int[5];
Span<int> srcSpan = source;
Span<int> destSpan = destination;
srcSpan.CopyTo(destSpan);
Working with binary data:
byte[] packet = new byte[8];
Span<byte> span = packet;
// Write an int (4 bytes) into the beginning
BitConverter.TryWriteBytes(span, 12345);
// Read it back
int value = BitConverter.ToInt32(span);
Console.WriteLine(value); // 12345
When working with binary data or performing bulk operations, Span<T> allows you to copy data between regions of memory without creating temporary arrays.
Using Memory<T> together with Span<T>
If you need to hold Span-like data beyond the current method, use Memory<T> — it provides a .Span property:
Memory<int> memory = new int[5];
Span<int> span = memory.Span;
span[0] = 42;
Console.WriteLine(memory.Span[0]); // 42
Example in a test
[Test]
public void Should_Parse_Csv_Line_Efficiently()
{
string line = "Viktor,35,Ukraine";
ReadOnlySpan<char> span = line.AsSpan();
int firstComma = span.IndexOf(',');
var name = span.Slice(0, firstComma);
int secondComma = span.Slice(firstComma +
1).IndexOf(',') + firstComma + 1;
var age = span.Slice(firstComma + 1, secondComma -
firstComma - 1);
var country = span.Slice(secondComma + 1);
Assert.AreEqual("Viktor", name.ToString());
Assert.AreEqual("35", age.ToString());
Assert.AreEqual("Ukraine", country.ToString());
}
This test parses a CSV line with zero memory allocations, making it highly efficient for performance-critical automation scenarios.
9. Conclusion
Proper use of the Garbage Collector, Span collections, clearing the collections, disposing methods, and attentive monitoring of memory usage in C# Automation tests help maintain memory stability, avoid leaks, and ensure consistent performance.
However, manual GC invocation should be applied carefully and purposefully, keeping in mind the natural flow of the .NET runtime.
By implementing these optimization techniques strategically in your automation test suite, you can significantly reduce memory overhead, improve test execution speed, and ensure your tests run reliably even in resource-constrained CI environments.