This article is part of the 2025 Advent of C# Code Calendar, which publishes 2 C# articles every day in December leading up to 25 December.
NOTE: Originally targeted xUnit v2; now updated to xUnit.v3.
Introduction
One of my favorite new features in .NET 10 is C# file-based apps (or file-based C# programs if you prefer). With this feature, we can create individual .cs files and then run them using dotnet run <file.cs>. On Unix OSes you can go even further and mark the files as executable and include a shebang (#!) directive as the first line to tell the OS what to run it with and then you can run the files directly without even calling dotnet run.
First line of file.cs:
1
|
#!/usr/local/share/dotnet/dotnet run
|
Now you can just run the file:
1
2
|
> ./file.cs
(it runs)
|
This is all very cool stuff and there are many many uses for it. Let’s explore how we can build tests with these files.
Building xUnit Tests as File-Based C# Programs
If you want to run some tests in a single file-based app, you’ll need a testing framework with assertions and a test runner, as well as the code you want to test and the tests themselves (we’ll look at how to test other projects below). This can be very useful if you’re just trying to learn an API or some part of the .NET framework itself, and instead of creating a console app and printing out stuff as you go, you would rather have a series of tests that demonstrate how things work in a more organized fashion.
First, you’ll need a testing framework and a test runner, so let’s pull in xunit. To add a package reference to a file-based app you use this syntax:
1
2
3
|
// Package references use the #:package syntax:
#:package xunit.v3@3.2.1
#:package xunit.v3.runner.inproc.console@3.2.1
|
Add these near the top of your file.
Next, you’ll want to write some code basically wraps the xUnit runner and responds when it issues callbacks like OnTestFailed and OnTestPassed. Since these are using top level statements, the code needs to be next in the file, before you define any other classes for tests.
Here’s the code to do so - it’s kind of boilerplate but we’ll clean that up in a moment:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
|
using Xunit;
using Xunit.Runner.Common;
using Xunit.Runner.InProc.SystemConsole;
using Xunit.Sdk;
using System.Reflection;
// ============================================================================
// Test Runner (Top-level statements must come before type declarations)
// ============================================================================
// Use Environment.ProcessPath for single-file apps (Assembly.Location returns empty string)
var processPath = Environment.ProcessPath!;
// For dotnet run, the executable is a .dll or .exe with corresponding .dll
string assemblyPath;
if (processPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
assemblyPath = processPath;
}
else if (processPath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
// Convert .exe to .dll path (dotnet produces both)
assemblyPath = Path.ChangeExtension(processPath, ".dll");
}
else
{
// Single-file compilation - try to find a dll with same name
assemblyPath = processPath + ".dll";
}
Console.WriteLine("Discovering and running tests...\n");
int passed = 0, failed = 0, skipped = 0;
var startTime = DateTime.Now;
var consoleLock = new object();
// Track test display names using test unique IDs
var testNameMap = new Dictionary<string, string>();
var sink = new TestMessageSink();
// Track test names as they start
sink.Execution.TestStartingEvent += args =>
{
testNameMap[args.Message.TestUniqueID] = args.Message.TestDisplayName;
};
sink.Execution.TestFailedEvent += args =>
{
lock (consoleLock)
{
failed++;
var testName = testNameMap.TryGetValue(args.Message.TestUniqueID, out var name) ? name : "Unknown test";
Console.ForegroundColor = ConsoleColor.Red;
Console.Write(" [FAIL] ");
Console.ResetColor();
Console.WriteLine(testName);
if (args.Message.Messages != null && args.Message.Messages.Length > 0 && args.Message.Messages[0] != null)
{
Console.WriteLine($" {args.Message.Messages[0]}");
}
if (args.Message.StackTraces != null && args.Message.StackTraces.Length > 0 && args.Message.StackTraces[0] != null && !string.IsNullOrEmpty(args.Message.StackTraces[0]))
{
foreach (var line in args.Message.StackTraces[0]!.Split('\n'))
{
Console.WriteLine($" {line}");
}
}
}
};
sink.Execution.TestPassedEvent += args =>
{
lock (consoleLock)
{
passed++;
var testName = testNameMap.TryGetValue(args.Message.TestUniqueID, out var name) ? name : "Unknown test";
Console.ForegroundColor = ConsoleColor.Green;
Console.Write(" [PASS] ");
Console.ResetColor();
Console.WriteLine(testName);
}
};
sink.Execution.TestSkippedEvent += args =>
{
lock (consoleLock)
{
skipped++;
var testName = testNameMap.TryGetValue(args.Message.TestUniqueID, out var name) ? name : "Unknown test";
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write(" [SKIP] ");
Console.ResetColor();
Console.WriteLine($"{testName} - {args.Message.Reason}");
}
};
// Run tests using ConsoleRunnerInProcess
var cts = new CancellationTokenSource();
#pragma warning disable IL2026 // Assembly.LoadFrom is required for test discovery
var assembly = Assembly.LoadFrom(assemblyPath);
#pragma warning restore IL2026
// Create XunitProjectAssembly for the test assembly
var project = new XunitProject();
var targetFramework = assembly.GetCustomAttribute<System.Runtime.Versioning.TargetFrameworkAttribute>()?.FrameworkName
?? $".NETCoreApp,Version=v{Environment.Version.Major}.{Environment.Version.Minor}";
var metadata = new AssemblyMetadata(3, targetFramework); // xUnit v3
var projectAssembly = new XunitProjectAssembly(project, assemblyPath, metadata)
{
Assembly = assembly,
ConfigFileName = null,
};
await ConsoleRunnerInProcess.Run(sink, sink.Diagnostics, projectAssembly, cts);
|
Now that we have that in place, we can display the results in a pretty fashion:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
var duration = DateTime.Now - startTime;
// Display results
Console.WriteLine($"\nTest run completed in {duration.TotalSeconds:F2}s");
Console.WriteLine($"Total tests: {passed + failed + skipped}");
if (passed > 0)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write($" Passed: {passed}");
Console.ResetColor();
Console.WriteLine();
}
if (failed > 0)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Write($" Failed: {failed}");
Console.ResetColor();
Console.WriteLine();
}
if (skipped > 0)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write($" Skipped: {skipped}");
Console.ResetColor();
Console.WriteLine();
}
// Exit with appropriate code for CI/CD integration
return failed > 0 ? 1 : 0;
|
And finally, we’re ready to actually write some tests - here’s just a small example. You can view the full sample in my SingleFileTests repo here.
1
2
3
4
5
6
7
8
|
public class IntAdditionOperator
{
[Fact]
public void ReturnsCorrectSumGivenTwoIntegers()
{
Assert.Equal(4, 2 + 2);
}
}
|
Now save this file as tests.cs and you can run it with dotnet run tests.cs. You will see output like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
Discovering and running tests...
[PASS] IntAdditionOperator.ReturnsCorrectSumGivenTwoIntegers
[PASS] IntMultiplicationOperator.ReturnsSameNumberWhenMultipliedByOne
[PASS] IntModulo2GivenOddNumbers.ReturnsTrue(value: 5)
[PASS] IntModulo2GivenOddNumbers.ReturnsTrue(value: 7)
[PASS] IntMultiplicationOperator.ReturnsCorrectProductGivenTwoPositiveIntegers
[PASS] IntModulo2GivenOddNumbers.ReturnsTrue(value: 3)
[PASS] IntMultiplicationOperator.ReturnsZeroWhenMultipliedByZero
[PASS] StringContains.ReturnsSubstringWhenPresent
Test run completed in 0.18s
Total tests: 8
Passed: 8
|
Referencing Other Projects
Being able to run tests with a runner, the tests, and the system under test (SUT) all in one file is nice, but has limited utility for real applications. What about running tests against other projects? Well, that’s easily done as well. Just add a reference to the project using this syntax:
1
2
3
4
5
6
|
// Single-file xUnit v3 test suite for Money value object
// Run with: dotnet run moneytests.cs
//
#:package xunit.v3@3.2.1
#:package xunit.v3.runner.inproc.console@3.2.1
#:project ../src/ValueObjects // reference projects with #:project directive
|
In my github sample repository I have a small library called ValueObjects.csproj which includes a Money.cs type. I wrote some tests for it in /samples/mponeytests.cs that look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
// same boilerplate as above omitted
public class MoneyConstruction
{
[Fact]
public void CreatesMoneyWithAmountAndCurrency()
{
var money = new Money(100.50m, "USD");
Assert.Equal(100.50m, money.Amount);
Assert.Equal("USD", money.Currency);
}
[Fact]
public void NormalizesCurrencyToUpperCase()
{
var money = new Money(50m, "eur");
Assert.Equal("EUR", money.Currency);
}
[Fact]
public void FactoryMethodsCreateCorrectCurrency()
{
var usd = Money.USD(100);
var eur = Money.EUR(200);
var gbp = Money.GBP(300);
Assert.Equal("USD", usd.Currency);
Assert.Equal("EUR", eur.Currency);
Assert.Equal("GBP", gbp.Currency);
}
[Fact]
public void ZeroCreatesZeroAmountWithCurrency()
{
var zero = Money.Zero("USD");
Assert.Equal(0m, zero.Amount);
Assert.Equal("USD", zero.Currency);
Assert.True(zero.IsZero);
}
[Fact]
public void ThrowsWhenCurrencyIsNullOrEmpty()
{
Assert.Throws<ArgumentNullException>(() => new Money(100, null!));
Assert.Throws<ArgumentException>(() => new Money(100, ""));
Assert.Throws<ArgumentException>(() => new Money(100, " "));
}
}
// mote tests omitted
|
We can run these tests from the CLI like so:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
dotnet run .\moneytests.cs
Discovering and running tests...
[PASS] MoneyArithmetic.MultipliesByDecimal
[PASS] MoneyConstruction.CreatesMoneyWithAmountAndCurrency
[PASS] MoneyHelpers.IsZeroPositiveNegativeReturnCorrectValues(amount: -50, isZero: False, isPositive: False, isNegative: True)
[PASS] MoneyEquality.NotEqualWhenDifferentCurrency
[PASS] MoneyComparison.ComparesAmountsWithSameCurrency
[PASS] MoneyArithmetic.SubtractsTwoMoneyValuesWithSameCurrency
[PASS] MoneyHelpers.IsZeroPositiveNegativeReturnCorrectValues(amount: 100, isZero: False, isPositive: True, isNegative: False)
[PASS] MoneyHelpers.IsZeroPositiveNegativeReturnCorrectValues(amount: 0, isZero: True, isPositive: False, isNegative: False)
[PASS] MoneyArithmetic.DividesByDecimal
[PASS] MoneyComparison.ThrowsWhenComparingDifferentCurrencies
[PASS] MoneyConstruction.NormalizesCurrencyToUpperCase
[PASS] MoneyArithmetic.DecrementSubtractsOne
[PASS] MoneyEquality.EqualWhenSameAmountAndCurrency
[PASS] MoneyConstruction.ZeroCreatesZeroAmountWithCurrency
[PASS] MoneyArithmetic.AddsTwoMoneyValuesWithSameCurrency
[PASS] MoneyConstruction.FactoryMethodsCreateCorrectCurrency
[PASS] MoneyHelpers.ToStringFormatsCorrectly
[PASS] MoneyArithmetic.ThrowsWhenAddingDifferentCurrencies
[PASS] MoneyHelpers.AbsReturnsAbsoluteValue
[PASS] MoneyArithmetic.IncrementAddsOne
[PASS] MoneyHelpers.RoundsToSpecifiedDecimals
[PASS] MoneyConstruction.ThrowsWhenCurrencyIsNullOrEmpty
[PASS] MoneyArithmetic.UnaryMinusNegatesAmount
[PASS] MoneyEquality.HashCodeEqualForEqualMoney
[PASS] MoneyEquality.NotEqualWhenDifferentAmount
Test run completed in 0.18s
Total tests: 25
Passed: 25
|
Of course, we can also run these tests in our CI/CD pipeline. Just add a bit of YAML like this:
1
2
3
4
5
6
7
8
|
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
dotnet-quality: 'preview'
- name: Run template tests
run: dotnet run samples/moneytests.cs
|
Cleaning Up the Boilerplate Code
We can pull out the boilerplate test runner code using a static helper. I’ve added one in the repo here. I can reference this library just like I do the SUT library, and then I can use the static helper method to run the tests. The top of the /samples/moneytests2.cs file then looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// Single-file xUnit test suite for Money value object
// Run with: dotnet run moneytests2.cs
//
// This version uses the TestFile library which provides the test runner plumbing.
// The xunit packages are pulled in transitively through TestFile.
//
#:project ../src/TestFile
#:project ../src/ValueObjects
using TestFile;
using ValueObjects;
using Xunit;
return await TestRunner.RunTestsAsync();
// test classes omitted
|
That’s much cleaner! Of course, I probably don’t want to have to haul around a separate project just to hold my test runner code - that’s what NuGet packages are for!
Using the Ardalis.SingleFileTestRunner NuGet Package
Naturally I’ve already published a NuGet package designed to make running xUnit tests in single file-based C# programs as easy as possible. It’s called Ardalis.SingleFileTestRunner.xUnitV3 and it basically just includes the helper method I used above. Using this nuget package, the /samples/moneytests3.cs file looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// Single-file xUnit v3 test suite for Money value object
// Run with: dotnet run moneytests3.cs
//
// This version uses the Ardalis.SingleFileTestRunner.xUnitV3 NuGet package
// which provides the test runner plumbing.
//
#:package Ardalis.SingleFileTestRunner.xUnitV3@1.1.0
#:project ../src/ValueObjects
using Ardalis.SingleFileTestRunner;
using ValueObjects;
using Xunit;
return await TestRunner.RunTestsAsync();
// test classes omitted
|
Note that you don’t even need to include any references to xUnit. I’m pulling those in for you with the NuGet package and making them available transitively. The result is minimal boilerplate code needed in your single page test file.
Benefits of File-Based Tests
There are many potential benefits to using file-based tests:
- Zero project scaffolding - No need to create a separate test project, add NuGet references, or configure build settings
- Self-contained and portable - A single file contains everything needed to run the tests, making it easy to share or store alongside documentation
- Fast iteration - Skip the full build cycle; just edit and run with
dotnet run test.cs
- Perfect for learning and exploration - Quickly test out a new API, library, or .NET feature without polluting your solution with throwaway projects
- Isolated by default - Each test file is its own compilation unit, preventing accidental coupling between test suites
- CI/CD friendly - Returns proper exit codes (0 for pass, 1 for fail) for easy integration into pipelines
- Great for documentation - Executable examples that prove your documentation is accurate
- Low barrier to entry - New team members or contributors can run tests without understanding your full project structure
- Parallelizable - Run separate files in parallel using multiple processes to get a performance boost
Conclusion
There are plenty of reasons why the new file-based programs in C# are cool. Writing tests in single files is just one example, and as you’ve seen above it can have many advantages. If you found this useful, I hope you’ll leave a star on the GitHub repo(s) below and consider sharing on your social media platform of choice.
Keep Improving!
Resources
Another package that focuses on easily running tests in single file apps is TimeWarp.Jaribu. It uses its own runner and test syntax, rather than relying on xUnit, but has a very clean syntax as well. I chose to wrap xUnit because of its ubiquity which is why I published my own separate NuGet package.