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));
}For production applications, test the application's real IServiceCollection configuration instead of duplicating it inside the test.
This catches missing registrations and runtime activation problems.
Test Trimming-Sensitive Code
Native AOT relies heavily on trimming.
Unused code can be removed from the final application. That is useful for reducing the deployment footprint, but it can expose code that relies on runtime discovery.
Watch for patterns such as:
Assembly.GetExecutingAssembly()Type.GetType(...)Activator.CreateInstance(...)MethodInfo.Invoke(...)and runtime-generated code.
The presence of reflection is not automatically a problem. The important question is whether the AOT compiler and linker can determine what the application needs.
When a library reports trimming or AOT warnings, treat them as migration work rather than suppressing them immediately.
Test the Published Native Binary
A successful test run is useful, but you should also test the actual published application.
Publish the application for the target runtime and architecture:
dotnet publish \
-c Release \
-r linux-x64 \
-p:PublishAot=trueFor an ARM64 deployment:
dotnet publish \
-c Release \
-r linux-arm64 \
-p:PublishAot=trueThe runtime identifier should match the environment where the executable will run.
After publishing, execute the resulting binary:
./MyApplicationThen run application-level smoke tests against it.
For an API, that could include:
GET /health
POST /api/orders
GET /api/orders/10This verifies the complete deployment path instead of testing only the source project.
Native AOT Testing in CI
Native AOT should be part of CI when it is part of the production deployment.
A simplified workflow can look like this:
name: Native AOT Tests
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 11.x
- name: Restore
run: dotnet restore
- name: Run tests
run: dotnet test --configuration Release --no-restore
- name: Publish Native AOT application
run: |
dotnet publish \
src/MyApplication/MyApplication.csproj \
-c Release \
-r linux-x64 \
-p:PublishAot=true \
--no-restoreThe important distinction is that dotnet test and Native AOT publishing validate different parts of the system.
You want both.
Test Native Dependencies
Native AOT produces a native executable, so external native dependencies need attention.
Check:
P/Invoke calls
Native database drivers
Image libraries
Compression libraries
Cryptography libraries
Platform-specific native packages
Custom
.so,.dylib, or.dlldependencies
For example:
[DllImport("nativehelper")]
private static extern int ProcessData();A normal .NET test might pass because the development machine has the required native library.
The published application may fail on a clean deployment environment.
Test the final binary in an environment that resembles production.
Native AOT vs Normal .NET Testing
Area | Normal .NET testing | Native AOT testing |
|---|---|---|
Execution | Managed runtime | Native executable |
JIT | Available | Not used for application execution |
Reflection | More flexible | Requires additional AOT consideration |
Trimming | Usually less restrictive | Important |
Dynamic code | More options | May not be supported |
Native dependencies | Still relevant | More visible during publishing and deployment |
Startup | Tests managed startup | Tests native startup behavior |
Deployment | Runtime-based | Native binary |
The two approaches complement each other.
Native AOT testing should not replace the normal test suite.
Common Problems
The Application Builds but Native AOT Publishing Fails
Check the warnings produced by:
dotnet publish -c Release -r linux-x64 -p:PublishAot=trueLook for trimming, dynamic-code, reflection, and native dependency warnings.
Do not immediately suppress them.
Find out which part of the application generated the warning.
Reflection Works in Tests but Fails in Production
The normal test environment may preserve metadata that is unavailable after trimming.
Review the reflection path and use AOT-compatible patterns such as source generation or the appropriate runtime annotations.
A Native Library Cannot Be Loaded
Verify that the native library exists in the target environment and supports the target architecture.
For Linux:
ldd ./MyApplicationThis can help identify missing shared-library dependencies.
The Test Suite Passes but the Native Binary Fails
This usually means the test suite did not execute the same deployment configuration used by production.
Add a publish-and-smoke-test stage to CI.
Best Practices
Keep the normal MSTest suite. Native AOT testing adds coverage. It does not replace ordinary unit and integration tests.
Test AOT-sensitive behavior explicitly. Reflection, serialization, dependency injection, and dynamic code deserve dedicated tests.
Treat AOT warnings as actionable. Do not hide them with broad warning suppression.
Publish for the real target architecture.
linux-x64andlinux-arm64are different deployment targets.Test the published executable. A successful compilation is not enough.
Use production configuration in integration tests. Test the actual dependency injection, serialization, and application startup configuration.
Include Native AOT publishing in CI. Catch publishing failures before a release reaches production.
Test on a clean environment. This helps expose missing native libraries and deployment assumptions.
Advantages and Disadvantages
Advantages
Finds trimming and AOT compatibility problems earlier.
Validates the application in a deployment model closer to production.
Helps identify unsupported dynamic behavior.
Exposes native dependency problems before deployment.
Makes Native AOT publishing part of the regular development workflow.
Disadvantages
Native AOT builds take additional CI time.
Some libraries require changes for AOT compatibility.
Reflection-heavy applications may need redesign or additional metadata.
Tests must cover both normal .NET execution and the native deployment path.
Cross-platform applications may require separate validation for each target architecture.
Conclusion
Testing a Native AOT application requires more than running dotnet test.
MSTest 4.4 makes it easier to include Native AOT in the testing workflow, but the test strategy still matters. Start with normal unit and integration tests, then add coverage for the parts of the application most affected by AOT, especially reflection, trimming, serialization, dependency injection, and native dependencies.
Most importantly, publish and execute the actual Native AOT binary during CI. That final step catches problems that a normal managed test run cannot see.
For applications deployed with Native AOT, the goal is straightforward: the same code that passes the test suite should also survive AOT compilation, publishing, and execution in the target production environment.

Join the conversation! Your thoughts help the community grow.