A .NET application can compile successfully on a developer's Windows machine and still fail when it runs on Linux or macOS. A file may not be found because its name uses different capitalization, a native library may be missing, or a test may depend on an operating-system feature that is not available on another platform.
These problems are easy to overlook when the entire development team uses the same operating system. They often appear later, when the application is deployed to a Linux container, hosted on a cloud service, or distributed to users running different operating systems.
Testing across multiple operating systems helps identify these problems before release. Instead of relying on developers to test manually on different machines, you can configure a continuous integration (CI) pipeline to build and test the application on several operating systems whenever code changes.
GitHub Actions makes this practical by providing hosted runners for Windows, Linux, and macOS. A single workflow can run the same test suite on multiple operating systems, report which environment failed, and prevent a change from being merged when required tests do not pass.
This article explains how to create a cross-platform test matrix for a .NET application, handle operating-system-specific behavior, diagnose CI failures, and keep the pipeline reliable without unnecessarily increasing build time.
Why Cross-Platform Testing Matters
.NET allows developers to write applications that run on multiple operating systems, but cross-platform support does not mean every dependency or operating-system interaction behaves identically.
The .NET runtime provides a consistent programming environment for many APIs. However, the application still interacts with the underlying operating system, file system, networking stack, native libraries, and environment configuration.
Consider an application that reads a configuration file named appsettings.json. On a Windows development machine, a reference to AppSettings.json may appear to work because the default file system is generally case-insensitive. On a typical Linux file system, those two names can refer to different files.
The application may work locally but fail in production because the file name does not match exactly.
Other common differences include:
File and directory paths, including directory separators and path normalization.
File-name capitalization and file-system behavior.
Native dependencies that must be installed separately.
Environment variables and shell commands.
Time-zone configuration and locale-sensitive formatting.
File permissions and executable flags.
Network behavior and operating-system-specific APIs.
Testing on the operating systems you support helps reveal these differences before they affect users.
Decide which operating systems you actually support
Before creating a test matrix, identify the operating systems and architectures your application is intended to support.
A web API deployed only in Linux containers may need Windows and Linux tests for development compatibility, but it may not need a full macOS deployment test. A desktop application that targets Windows requires a different test strategy because some UI and native APIs cannot run on Linux.
Also distinguish between a platform that can compile the application and a platform that can execute it correctly. Some projects use platform-specific frameworks or dependencies that prevent tests from running on every operating system.
The test matrix should reflect the application's real deployment and support requirements rather than testing every possible environment without a clear reason.
Create a Cross-Platform Test Matrix in GitHub Actions
A test matrix allows one workflow to execute the same job with multiple configuration values. For cross-platform testing, the matrix can specify different runner operating systems.
For example, the workflow can run on Windows, Ubuntu Linux, and macOS. Each job checks out the repository, installs the required .NET SDK, restores dependencies, builds the solution, and runs the tests.
Create a workflow file at:
.github/workflows/cross-platform-tests.yml
The following example uses GitHub-hosted runners and a .NET 11 project. Adjust the SDK version and project paths to match your repository and the .NET 11 build you are using.
name: Cross-Platform .NET Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- windows-latest
- ubuntu-latest
- macos-latest
steps:
- name: Check out source code
uses: actions/checkout@v4
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '11.0.x'
- name: Display SDK information
run: dotnet --info
- name: Restore dependencies
run: dotnet restore MySolution.sln
- name: Build solution
run: dotnet build MySolution.sln --no-restore
- name: Run tests
run: dotnet test MySolution.sln --no-build
This workflow is an illustrative starting point. Verify that the selected runner images, SDK versions, and setup action support the exact .NET 11 release or preview you intend to test.
Understand how the matrix works
GitHub Actions creates a separate job for each operating system in the matrix. The Windows job runs on a Windows runner, the Ubuntu job runs on Linux, and the macOS job runs on a macOS runner.
Each job gets its own environment. Packages restored on one runner are not automatically available in another runner's local package cache.
The workflow installs the SDK, restores dependencies, builds the solution, and runs the tests independently on each operating system.
The setting fail-fast: false is useful when you want to see all platform results, even if one job fails early. Without it, GitHub Actions may cancel other in-progress matrix jobs after a failure, depending on the strategy configuration.
This makes it easier to determine whether a change affects only one operating system or causes a broader failure.
Keep Restore, Build, and Test Steps Separate
Separating restore, build, and test operations makes CI failures easier to diagnose.
A restore failure usually points to package availability, network access, dependency configuration, or SDK compatibility. A build failure often indicates compilation errors, missing references, or platform-specific code that does not compile. A test failure means the project built successfully, but one or more checks did not pass.
This separation provides a clearer failure boundary.
Restore dependencies
The restore step resolves the project's NuGet dependencies and prepares the assets needed for compilation.
Run restore separately so that package-resolution problems do not get confused with compilation errors. If the project uses private package feeds, make sure the runner has the required credentials and access.
Do not store feed credentials directly in the workflow file. Use GitHub Actions secrets or another approved credential-management mechanism, and grant only the permissions required by the workflow.
Build the solution
The build step compiles the solution using the restored dependencies. It can reveal platform-specific compilation problems, missing framework references, or conditional compilation issues.
If the application contains multiple projects, build the complete solution rather than testing only one library. A cross-platform library might compile successfully while the application that consumes it fails because of an operating-system-specific dependency.
Run the tests
The test step executes the test projects against the compiled assemblies.
The example uses --no-build because the solution has already been built in the preceding step. If your test process requires a separate build configuration, custom properties, or test-specific compilation, adjust the commands accordingly.
For large repositories, you may want to test individual test projects separately. This can make failures easier to locate and allows the pipeline to apply different operating-system requirements to different parts of the application.
Write Tests That Reveal Operating-System Differences
A matrix is only useful if the tests exercise the behavior that might differ across platforms.
A test that checks a simple mathematical calculation will usually produce the same result everywhere. A test that reads files, starts processes, or loads native dependencies is more likely to reveal environmental differences.
Test file paths correctly
Use Path.Combine or other appropriate path APIs instead of hard-coding path separators.
For example:
using Xunit;
public class FilePathTests
{
[Fact]
public void PathCombine_CreatesPathForCurrentPlatform()
{
string path = Path.Combine(
"data",
"reports",
"summary.json");
Assert.EndsWith(
"summary.json",
path,
StringComparison.Ordinal);
}
}
This test illustrates a platform-neutral path construction pattern. It does not prove that a file exists or that the application uses the correct directory.
For file-based integration tests, create temporary directories, write files using the expected names, and verify that the application can read them. Avoid relying on a developer's local directory structure.
Also test file-name capitalization explicitly if your application runs on case-sensitive file systems. Use the exact file names that will be deployed, and avoid tests that pass only because the local file system ignores capitalization differences.
Avoid relying on the current working directory
The current working directory may differ between a developer's machine, a test runner, and a deployed application.
If a test assumes that the process always starts in the repository root, it may fail when the test runner starts in a different directory.
Use a deliberate base path for test data. For files copied into the test output directory, locate them relative to AppContext.BaseDirectory or configure the test project to copy the required files.
For example:
string testFilePath = Path.Combine(
AppContext.BaseDirectory,
"TestData",
"sample.json");
This approach is more predictable than assuming that the current working directory will always point to the same location.
Test native dependencies
Applications that use native libraries need additional checks. A package may contain a managed .NET wrapper but still require a native binary that must be available for the current operating system and architecture.
Test the actual operation that loads or calls the native library. A successful restore does not prove that the native dependency can be loaded.
If the application supports only certain platforms, document that requirement and use appropriate conditional test execution. Do not mark a genuine failure as an expected skip simply because it occurs on an operating system that the application claims to support.
Handle Platform-Specific Tests Deliberately
Some tests are valid only on particular operating systems.
For example, a Windows desktop application may depend on Windows-specific APIs that are unavailable on Linux. It would be inappropriate to require that test to pass on every runner.
Instead, separate cross-platform tests from platform-specific tests. Use test categories, separate projects, or operating-system conditions to control where each group executes.
The following example shows a test that runs only on Windows:
using Xunit;
public class PlatformSpecificTests
{
[Fact]
public void WindowsSpecificOperation_RunsOnWindows()
{
if (!OperatingSystem.IsWindows())
{
return;
}
// Execute the Windows-specific behavior here.
}
}
This example illustrates a platform check, but an early return can make the test appear successful even though the relevant behavior was never tested. For production test suites, use a proper skip mechanism or separate the Windows-specific test into a Windows-only test project or CI job.
The distinction matters because a test report should clearly show whether a test passed, failed, or was not applicable.
For application code that uses platform-specific APIs, prefer explicit platform checks and appropriate platform annotations where supported. This makes platform requirements easier for developers and analyzers to understand.
Test Different .NET Configurations Without Duplicating Everything
Operating system is not the only dimension that can affect compatibility. Applications may target multiple .NET frameworks, build configurations, or architectures.
For example, a library may target both a newer .NET version and an older supported framework. A CI matrix can combine operating systems with target frameworks, but the number of jobs grows quickly.
Three operating systems and two target frameworks create six combinations before adding any other dimension.
Use a larger matrix only when those combinations represent real support requirements. Otherwise, split the tests into tiers.
A practical approach is:
Run fast, essential tests on every supported operating system for each pull request.
Run broader compatibility tests on selected target frameworks.
Run expensive integration and performance tests on a schedule or before a release.
Keep platform-specific tests in the environments where they can actually execute.
This reduces unnecessary CI time while preserving meaningful coverage.
Diagnose Failures in the CI Matrix
When one operating system fails and the others pass, compare the failing job's logs with the successful jobs.
Start by identifying the stage that failed: SDK setup, restore, build, test discovery, or test execution.
Then check the environment details. The SDK version, installed native libraries, environment variables, permissions, and file-system behavior can all differ between runners.
For example, if a test fails only on Ubuntu with a file-not-found exception, inspect the exact file name and path. If the same test passes on Windows, check whether capitalization or path assumptions explain the difference.
If a native library fails to load, confirm that the correct binary exists for the operating system and architecture. If restore fails only in CI, inspect the package feed configuration and authentication settings.
Avoid immediately adding operating-system-specific workarounds. First reproduce the problem locally or in a controlled test environment and identify the underlying cause.
Keep failure logs useful
When a test fails, the log should identify the test name, the relevant input, and the reason for the failure.
Avoid printing secrets, tokens, or sensitive production data in test output. If the test uses an external service, log a request correlation ID and safe diagnostic details instead of the complete payload.
For long test suites, consider generating test-result files and uploading them as workflow artifacts. This helps developers review failed tests even when the job's console output is lengthy.
Common Mistakes in Cross-Platform CI
Testing only on the operating system used by the team
A team that develops on Windows may miss Linux-specific file-system problems. Include the environments where the application is deployed or officially supported.
Assuming successful compilation means successful execution
A project can compile while failing to load a native library, locate a file, or interact with the operating system correctly. Run tests on each relevant runner.
Skipping unsupported tests without reporting the reason
A skipped test should have a clear explanation. Otherwise, the team may mistake missing coverage for a successful validation.
Using different commands on every operating system
If the build process differs unnecessarily between platforms, failures become harder to compare. Keep restore, build, and test commands consistent where possible, and isolate the genuinely platform-specific steps.
Expanding the matrix without controlling cost
Testing every combination can create long queues and expensive workflows. Begin with supported operating systems and add more combinations when they provide meaningful coverage.
Best Practices for Production-Ready Cross-Platform Testing
Define the operating systems and architectures your application supports, and maintain a CI matrix that reflects those commitments. Use the same SDK version and build commands across environments wherever possible.
Write tests that exercise file access, path handling, native dependencies, configuration, and other operating-system-sensitive behavior. Use explicit platform checks for tests that cannot run everywhere, and report skips clearly.
Keep test data inside the repository or create it during the test. Avoid dependencies on a developer's local machine. Protect credentials used by integration tests and keep sensitive values out of logs.
Finally, review the matrix results before merging. A green Windows job is not enough if the application is also intended to run on Linux. The required platform jobs should be treated as part of the application's compatibility contract.
Advantages and Disadvantages of Cross-Platform CI
Advantages
Finds operating-system-specific bugs before release.
Confirms that the application builds and tests successfully in supported environments.
Reduces dependence on manual testing across multiple machines.
Makes platform compatibility part of the normal development workflow.
Helps teams detect missing native dependencies and incorrect environment assumptions.
Disadvantages
Matrix jobs increase CI execution time and resource consumption.
Different runners may have different installed tools and system libraries.
Some tests require specialized hardware, services, or operating-system features.
Platform-specific failures may require additional investigation.
Larger test matrices can become expensive if every combination runs for every change.
Summary
Testing a .NET application across multiple operating systems in CI helps detect problems that may remain hidden on a developer's machine. GitHub Actions provides a practical way to run the same build and test workflow on Windows, Linux, and macOS, while a matrix makes the results easy to compare.
Start with the operating systems your application actually supports. Separate restore, build, and test steps, and write tests that exercise file paths, native dependencies, environment configuration, and other platform-sensitive behavior. Handle platform-specific tests explicitly rather than allowing them to appear successful when they never ran.
The goal is to make cross-platform compatibility a repeatable part of development. When every required operating system is tested before a change is merged, teams can identify problems earlier and release applications with greater confidence.
Join the conversation! Your thoughts help the community grow.