Native AOT can reduce application startup time, lower deployment dependencies, and produce self-contained native executables. Trimming can remove unused code from an application and its dependencies to reduce the published output size. However, both features introduce compatibility considerations when a .NET application relies on reflection, dynamic type discovery, or runtime code generation.

These concerns also affect automated testing. A test project that works under the normal .NET runtime may behave differently when published with trimming or Native AOT enabled. Reflection-heavy test infrastructure, dynamically discovered test cases, and libraries that assume runtime code generation can expose compatibility problems.

MSTest provides a testing framework for .NET applications, but running tests against a Native AOT application is not the same as compiling the test runner itself into a native executable. A reliable testing strategy distinguishes between validating an AOT-published application and attempting to AOT-compile the testing infrastructure.

This article explains how to structure MSTest tests for trimmed and Native AOT applications, publish the application with the relevant settings, and identify compatibility problems before deployment.

Understand What You Are Testing

Before changing your test project, separate three different goals.

Goal

What it validates

Test an application using MSTest

Business logic, application behavior, and expected results

Test a trimmed application

Whether removing unused code affects required runtime behavior

Test a Native AOT application

Whether the compiled native executable behaves correctly

These goals overlap, but they are not interchangeable.

Running dotnet test against a normal build does not prove that a trimmed application will work. Similarly, publishing an application with Native AOT enabled does not automatically mean the MSTest test host itself is AOT-compatible.

A practical approach is to keep the test project on a supported test-host configuration, publish the application under the same trimming and AOT settings used for deployment, and execute tests against that published output.

Create an MSTest Project

Start with an application project and a separate MSTest project.

For example, the solution might contain:

AotSample/
├── AotSample.sln
├── AotSample/
│   ├── AotSample.csproj
│   └── PriceCalculator.cs
└── AotSample.Tests/
    ├── AotSample.Tests.csproj
    └── PriceCalculatorTests.cs

The application contains the logic you want to validate:

namespace AotSample;

public static class PriceCalculator
{
    public static decimal CalculateTotal(
        decimal unitPrice,
        int quantity)
    {
        if (unitPrice < 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(unitPrice));
        }

        if (quantity < 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(quantity));
        }

        return unitPrice * quantity;
    }
}

The test project references the application project and the MSTest packages supported by your target .NET SDK.

A typical test looks like this:

using AotSample;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace AotSample.Tests;

[TestClass]
public sealed class PriceCalculatorTests
{
    [TestMethod]
    public void CalculateTotal_ReturnsExpectedAmount()
    {
        decimal total =
            PriceCalculator.CalculateTotal(19.99m, 3);

        Assert.AreEqual(59.97m, total);
    }

    [TestMethod]
    public void CalculateTotal_RejectsNegativeQuantity()
    {
        Assert.ThrowsExactly<ArgumentOutOfRangeException>(
            () => PriceCalculator.CalculateTotal(10m, -1));
    }
}

The exact assertion APIs available depend on the MSTest version referenced by the project. If your installed version does not provide Assert.ThrowsExactly, use the supported equivalent, such as Assert.ThrowsException where appropriate.

Run the normal test suite first:

dotnet test AotSample.sln

This establishes a baseline before introducing publish-specific behavior.

Configure the Application for Trimming and Native AOT

Native AOT and trimming settings belong in the application project when that project is the executable you intend to publish.

A typical project file for a compatible .NET target looks like this:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>

    <PublishTrimmed>true</PublishTrimmed>
    <PublishAot>true</PublishAot>
  </PropertyGroup>

</Project>

Use the target framework supported by your installed SDK and deployment environment. The configuration above is an example for a .NET 10 application, not a requirement that every application use that target.

PublishTrimmed enables trimming during publication. PublishAot enables Native AOT compilation for supported application types and deployment targets.

Native AOT publishing is generally platform-specific. For example, a Windows x64 executable and a Linux x64 executable require different runtime identifiers.

Publish for a specific target:

dotnet publish AotSample/AotSample.csproj \
  -c Release \
  -r linux-x64

On Windows, use the appropriate runtime identifier for the deployment environment, such as win-x64.

Native AOT requires the relevant native toolchain and platform dependencies. Review the build output and diagnostics rather than assuming that a successful normal build guarantees a successful AOT publish.

Run MSTest Against the Published Executable

For applications that expose behavior through a command-line interface, a practical strategy is to run the published executable from an integration test.

For example, the application might accept a price and quantity as command-line arguments and print the calculated total. An integration test can launch that executable and verify its output.

The important distinction is that the test validates the published native application rather than simply calling the managed application assembly loaded into the MSTest process.

A simplified process-launching pattern looks like this:

using System.Diagnostics;

static async Task<(int ExitCode, string Output)> RunAsync(
    string executablePath,
    string arguments)
{
    var startInfo = new ProcessStartInfo
    {
        FileName = executablePath,
        Arguments = arguments,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false
    };

    using var process = new Process
    {
        StartInfo = startInfo
    };

    process.Start();

    Task<string> outputTask =
        process.StandardOutput.ReadToEndAsync();

    Task<string> errorTask =
        process.StandardError.ReadToEndAsync();

    await process.WaitForExitAsync();

    string output = await outputTask;
    string error = await errorTask;

    if (process.ExitCode != 0)
    {
        throw new InvalidOperationException(
            $"Application failed: {error}");
    }

    return (process.ExitCode, output);
}

This helper illustrates the process-launching pattern. A production test helper should also support cancellation and timeouts so a hung executable cannot stall the entire test suite.

The test can then validate the output:

[TestMethod]
public async Task PublishedApplication_ReturnsExpectedTotal()
{
    var result = await RunAsync(
        executablePath: GetPublishedExecutablePath(),
        arguments: "19.99 3");

    Assert.AreEqual(0, result.ExitCode);
    Assert.AreEqual("59.97", result.Output.Trim());
}

GetPublishedExecutablePath() is intentionally left as a project-specific helper. Its implementation depends on the runtime identifier, operating system, build configuration, and where your pipeline places published artifacts.

Avoid hard-coding a path that works only on a developer's machine. Configure the test to receive the artifact path from the build pipeline or derive it from a known test configuration.

For a web application, the equivalent strategy is to launch the published application, wait for its health endpoint to become available, execute HTTP requests, and verify responses before shutting the process down.

Understand Trimming Warnings

Trimming removes code that the analysis determines is unnecessary. Problems can arise when code accesses types or members dynamically in ways the trimmer cannot reliably analyze.

Common risk areas include:

Consider code that discovers a type by name:

Type? handlerType = Type.GetType(
    "AotSample.CustomerHandler");

If the application relies on a type being discoverable only through a string, the trimmer may not recognize that the type must remain available.

Prefer statically analyzable patterns when possible. Where reflection is necessary, use the appropriate annotations, explicit registrations, or preservation mechanisms supported by the framework and library involved.

Review warnings from the publish process carefully. Do not suppress a warning simply because the application works in a normal development build.

Test Serialization and Reflection-Heavy Code Explicitly

Serialization is a common source of trimming and AOT compatibility problems.

For example, applications that use System.Text.Json can often improve compatibility by using source-generated serialization metadata rather than relying entirely on reflection-based metadata discovery.

A source-generated context might look like this:

using System.Text.Json.Serialization;

[JsonSerializable(typeof(Customer))]
internal partial class AppJsonContext
    : JsonSerializerContext
{
}

public sealed record Customer(
    int Id,
    string Name);

Application code can then use the generated metadata:

using System.Text.Json;

Customer customer = new(42, "Taylor");

string json = JsonSerializer.Serialize(
    customer,
    AppJsonContext.Default.Customer);

Customer? restored = JsonSerializer.Deserialize(
    json,
    AppJsonContext.Default.Customer);

This makes the serialization contract explicit and reduces dependence on runtime reflection for the registered type.

The same principle applies to dependency injection, plugin registration, and other dynamically configured components: identify the types required at runtime and prefer mechanisms that the compiler and linker can analyze.

Tests should cover the actual serialization and discovery paths used by the published application, not just simple methods that avoid these features.

Build a CI Pipeline That Tests the Published Artifact

A robust pipeline should validate both ordinary test behavior and deployment-specific behavior.

A straightforward sequence is:

  1. Restore the solution.

  2. Build the application and test projects.

  3. Run the MSTest suite.

  4. Publish the application with trimming and Native AOT enabled.

  5. Execute integration tests against the published artifact.

  6. Inspect publish warnings and fail the pipeline for unresolved compatibility issues according to your team's policy.

Example commands:

dotnet restore AotSample.sln

dotnet test AotSample.sln \
  -c Release \
  --no-restore

dotnet publish AotSample/AotSample.csproj \
  -c Release \
  -r linux-x64 \
  --no-restore

The commands assume that restore has already prepared the assets required by the selected runtime identifier and that the project is configured for Native AOT.

For reliable CI, run the published executable on an environment compatible with the target runtime identifier. A Linux native executable should not be assumed to run on a Windows build agent.

Keep unit tests fast and deterministic, and reserve process-launching tests for integration or deployment-validation stages. This separation provides quick feedback without sacrificing confidence in the published application.

Common Mistakes to Avoid

Assuming normal tests prove AOT compatibility. Unit tests executed by the standard test host may never exercise the published native executable.

Enabling Native AOT on every project. The application and the testing infrastructure have different runtime requirements. Configure AOT on the deployable application unless you have a separate, verified reason to publish the test runner itself with AOT.

Ignoring trimming warnings. Reflection-related problems may appear only in the published output. Investigate warnings before deployment.

Testing only simple business logic. Include serialization, dependency injection, configuration, and other features that depend on runtime metadata.

Hard-coding published paths. Use pipeline-provided artifact locations or a consistent path convention so tests work across supported environments.

Confusing a successful publish with a successful execution. A native build can succeed while runtime behavior still fails. Launch the executable and test representative workflows.

Summary

MSTest can remain the primary testing framework for applications that use trimming and Native AOT, but the test strategy must validate the published application rather than relying exclusively on a normal managed build.

Keep the test infrastructure separate from the native deployment artifact, publish the application with the intended settings, investigate trimming and AOT diagnostics, and execute integration tests against the resulting executable.

The most reliable approach combines fast unit tests with targeted tests of the actual published application. That catches compatibility problems before deployment and provides stronger evidence that the production artifact behaves as expected.