Native AOT changes how a .NET application is compiled and deployed. Instead of shipping IL that the .NET runtime compiles at execution time, Native AOT produces a native executable ahead of time.
That change can expose problems that do not appear when the same application runs normally with the .NET runtime.
Reflection, dynamic code generation, trimming, serialization, dependency injection, and runtime-specific behavior deserve extra attention. Testing the application only in its regular managed configuration is therefore not enough.
MSTest 4.4 adds support that makes it practical to test applications and libraries that use Native AOT. The important part is understanding what should be compiled with Native AOT, what should remain a normal test project, and which behaviors need dedicated tests.
What Native AOT Changes
A normal .NET application is commonly compiled into assemblies containing Intermediate Language (IL). At runtime, the .NET runtime loads those assemblies and uses the JIT compiler to generate native machine code.
Native AOT changes that model.
The application is compiled ahead of time into native code:
C# source
|
v
.NET compiler
|
v
Native AOT compiler
|
v
Native executableThis can provide faster startup, smaller deployment requirements, and a deployment model that does not require the normal .NET runtime.
It also means that code relying on runtime discovery needs more careful testing.
For example:
var type = Type.GetType("MyApplication.Services.EmailService");Code like this may require additional configuration or annotations when trimming and Native AOT are involved.
A test that passes against the normal managed application does not automatically prove that the Native AOT executable will behave the same way.
Why Native AOT Needs Dedicated Testing
Native AOT introduces constraints around:
Reflection
Dynamic code generation
Trimming
Runtime type discovery
Serialization
Dependency injection
Native library dependencies
Platform-specific behavior
Consider an application that registers services using assembly scanning:
var services = new ServiceCollection();
foreach (var type in assembly.GetTypes())
{
if (typeof(IHandler).IsAssignableFrom(type))
{
services.AddTransient(typeof(IHandler), type);
}
}This can behave differently after trimming because the linker may remove types that it cannot determine are required.
The issue is not that Native AOT randomly breaks working code. The problem is that Native AOT needs the application's dependencies and runtime behavior to be statically understood wherever possible.
Tests should verify those assumptions.
MSTest 4.4 and Native AOT
MSTest 4.4 provides Native AOT support for test projects.
This matters because a test suite can now be used to validate code paths under an AOT-compatible execution model rather than relying only on tests running through the normal managed runtime.
A useful way to think about the setup is:
┌──────────────────┐
│ MSTest tests │
└────────┬─────────┘
|
┌───────────┴───────────┐
| |
v v
Normal .NET execution Native AOT execution
| |
└───────────┬───────────┘
v
Application behaviorThe normal test suite remains valuable. Native AOT testing adds another validation layer for applications that will actually be deployed as native binaries.
Create a Test Project
Start with a normal MSTest project:
dotnet new mstest -n MyApplication.TestsAdd a reference to the application project:
dotnet add MyApplication.Tests reference ../MyApplication/MyApplication.csprojThe exact project structure will depend on the application.
A typical solution might look like:
MyApplication.sln
|
+-- src
| |
| +-- MyApplication
| +-- MyApplication.csproj
|
+-- tests
|
+-- MyApplication.Tests
+-- MyApplication.Tests.csproj
+-- ServicesTests.csInstall the required MSTest packages using the versions approved for your project.
The test project should remain easy to execute using the normal test workflow:
dotnet testThis gives you a fast feedback loop before adding Native AOT-specific validation.
Write Tests Around Observable Behavior
Native AOT testing is not a reason to rewrite every test.
Focus on behavior that can be affected by AOT compilation.
For example, suppose the application uses a JSON serializer:
public sealed class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}A test can verify serialization and deserialization:
[TestClass]
public class CustomerSerializationTests
{
[TestMethod]
public void Customer_CanBeSerializedAndDeserialized()
{
var customer = new Customer
{
Id = 10,
Name = "John"
};
var json = JsonSerializer.Serialize(customer);
var result = JsonSerializer.Deserialize<Customer>(json);
Assert.IsNotNull(result);
Assert.AreEqual(customer.Id, result.Id);
Assert.AreEqual(customer.Name, result.Name);
}
}The important point is the behavior being tested.
If the production application depends on source-generated JSON metadata for AOT compatibility, test the actual production serialization configuration rather than creating a separate test-only serializer.
Test Reflection-Heavy Code Carefully
Reflection is one of the areas where Native AOT can expose hidden assumptions.
For example:
public object CreateInstance(Type type)
{
return Activator.CreateInstance(type)
?? throw new InvalidOperationException();
}A test might verify that the expected type can be created:
[TestMethod]
public void Service_CanBeCreated()
{
var service = CreateInstance(typeof(MyService));
Assert.IsNotNull(service);
Assert.IsInstanceOfType(service, typeof(MyService));
}But the test should be run against the actual AOT-compatible configuration.
If the application depends on runtime type discovery, review whether the types need trimming annotations, source generation, or another AOT-compatible design.
A passing test under the normal runtime does not prove that the linker will preserve every type needed by the Native AOT application.
Test Dependency Injection
Dependency injection is another area worth testing because applications sometimes use reflection or assembly scanning during registration.
Suppose the application has:
public interface IMessageSender
{
Task SendAsync(string message);
}
public sealed class EmailMessageSender : IMessageSender
{
public Task SendAsync(string message)
{
return Task.CompletedTask;
}
}The test should verify the actual service registration:
[TestMethod]
public void MessageSender_IsRegistered()
{
var services = new ServiceCollection();
services.AddSingleton<IMessageSender, EmailMessageSender>();
using var provider = services.BuildServiceProvider();
var sender = provider.GetService<IMessageSender>();
Assert.IsNotNull(sender);
Assert.IsInstanceOfType(sender, typeof(EmailMessageSender));
}
Join the conversation! Your thoughts help the community grow.