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 executable

This 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:

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 behavior

The 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.Tests

Add a reference to the application project:

dotnet add MyApplication.Tests reference ../MyApplication/MyApplication.csproj

The 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.cs

Install 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 test

This 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=true

For an ARM64 deployment:

dotnet publish \
    -c Release \
    -r linux-arm64 \
    -p:PublishAot=true

The runtime identifier should match the environment where the executable will run.

After publishing, execute the resulting binary:

./MyApplication

Then run application-level smoke tests against it.

For an API, that could include:

GET  /health
POST /api/orders
GET  /api/orders/10

This 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-restore

The 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:

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=true

Look 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 ./MyApplication

This 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

  1. Keep the normal MSTest suite. Native AOT testing adds coverage. It does not replace ordinary unit and integration tests.

  2. Test AOT-sensitive behavior explicitly. Reflection, serialization, dependency injection, and dynamic code deserve dedicated tests.

  3. Treat AOT warnings as actionable. Do not hide them with broad warning suppression.

  4. Publish for the real target architecture. linux-x64 and linux-arm64 are different deployment targets.

  5. Test the published executable. A successful compilation is not enough.

  6. Use production configuration in integration tests. Test the actual dependency injection, serialization, and application startup configuration.

  7. Include Native AOT publishing in CI. Catch publishing failures before a release reaches production.

  8. Test on a clean environment. This helps expose missing native libraries and deployment assumptions.

Advantages and Disadvantages

Advantages

Disadvantages

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.