Testing Framework (unittest)¶
Sharpy provides a Pythonic testing API that compiles to xUnit infrastructure. Tests are written with @test decorators and assert statements, and run via dotnet test.
@test Decorator¶
The @test decorator marks a function or method as a test case. The compiler transforms it into a [Fact] attribute for xUnit discovery.
@test
def test_addition():
assert 1 + 1 == 2
@test("verifies string concatenation")
def test_concat():
assert "hello " + "world" == "hello world"
Generated C#:
[Xunit.FactAttribute]
public void TestAddition()
{
Xunit.Assert.Equal(2, 1 + 1);
}
[Xunit.FactAttribute(DisplayName = "verifies string concatenation")]
public void TestConcat()
{
Xunit.Assert.Equal("hello world", "hello " + "world");
}
Syntax¶
@test— no arguments, test name derived from function name@test("description")— setsDisplayNameon the[Fact]attribute
Validation Rules¶
| Rule | Diagnostic |
|---|---|
@test on a class, struct, interface, enum, property, or event |
SPY0448 (error) |
@test combined with @abstract, @virtual, or @static |
SPY0449 (error) |
@test on a dunder method (__init__, __str__, etc.) |
SPY0448 (error) |
@test with non-string or multiple arguments |
SPY0469 (warning) |
Assert Rewriting¶
Inside @test functions, assert statements are rewritten to xUnit assertions for rich error messages. Outside @test functions, assert lowers to a real runtime check that raises AssertionError (see assert_statement.md) (#1070).
| Sharpy | xUnit C# |
|---|---|
assert a == b |
Xunit.Assert.Equal(b, a) |
assert a != b |
Xunit.Assert.NotEqual(b, a) |
assert a > b |
Xunit.Assert.True(a > b) |
assert a < b |
Xunit.Assert.True(a < b) |
assert a >= b |
Xunit.Assert.True(a >= b) |
assert a <= b |
Xunit.Assert.True(a <= b) |
assert a is None |
Xunit.Assert.Null(a) |
assert a is not None |
Xunit.Assert.NotNull(a) |
assert a is b |
Xunit.Assert.Same(b, a) |
assert a is not b |
Xunit.Assert.NotSame(b, a) |
assert a in b |
Xunit.Assert.Contains(a, b) |
assert a not in b |
Xunit.Assert.DoesNotContain(a, b) |
assert isinstance(a, T) |
Xunit.Assert.IsType<T>(a) |
assert s.startswith(p) |
Xunit.Assert.StartsWith(p, s) (only when s is typed str) |
assert s.endswith(p) |
Xunit.Assert.EndsWith(p, s) (only when s is typed str) |
assert x == approx(y) |
Xunit.Assert.Equal(y, x, 7) (approximate float equality) |
assert not expr |
Xunit.Assert.False(expr) |
assert expr (fallback) |
Xunit.Assert.True(expr) |
When an assert has a message (assert expr, "message"), it is passed as the last argument where xUnit supports it.
startswith / endswith (type-gated)¶
assert s.startswith(p) and assert s.endswith(p) rewrite to Xunit.Assert.StartsWith /
Xunit.Assert.EndsWith only when the receiver s is typed as str (per SemanticInfo) and
the call has exactly one positional argument:
@test
def test_prefix():
name: str = "hello world"
assert name.startswith("hello")
assert name.endswith("world")
Generated C#:
[Xunit.FactAttribute]
public void TestPrefix()
{
string name = "hello world";
Xunit.Assert.StartsWith("hello", name);
Xunit.Assert.EndsWith("world", name);
}
Multi-argument forms (s.startswith(p, start)), tuple-prefix forms, and receivers of
user-defined types that happen to define a startswith/endswith method are not rewritten —
they fall through to the Xunit.Assert.True(...) fallback so there is no surprising behavior for
non-str receivers.
approx — approximate float equality¶
Use approx() on either side of an == comparison inside an assert for floating-point
comparisons with tolerance. It mirrors assert_almost_equal defaults (places=7).
from unittest import approx
@test
def test_float():
assert 0.1 + 0.2 == approx(0.3) # default: 7 decimal places
assert 0.1 + 0.2 == approx(0.3, places=10) # explicit precision
assert 0.1 + 0.2 == approx(0.3, abs=1e-9) # absolute tolerance
assert approx(0.3) == 0.1 + 0.2 # approx on either side
Generated C#:
Xunit.Assert.Equal(0.3, 0.1 + 0.2, 7);
Xunit.Assert.Equal(0.3, 0.1 + 0.2, 10);
Xunit.Assert.Equal(0.3, 0.1 + 0.2, 1e-9);
Xunit.Assert.Equal(0.3, 0.1 + 0.2, 7);
- The argument to
approx(...)is theexpectedvalue; the other operand of==isactual. places=n(anint) selectsXunit.Assert.Equal(double, double, int precision).abs=d(adouble) selectsXunit.Assert.Equal(double, double, double tolerance).- If both
placesandabsare supplied,abswins (matchingassert_almost_equal'sdelta-over-placesprecedence). - Only the
==form is rewritten;assert x != approx(y)falls through toNotEqual.
Note:
approx(..., abs=d)lowers to xUnit'sAssert.Equal(expected, actual, tolerance)overload (richer positional failure output). This differs from the olderassert_almost_equaldelta=lowering, which emitsXunit.Assert.True(System.Math.Abs(actual - expected) <= delta).rel=(relative tolerance) is not supported — xUnit has no native overload for it.
Module-Level Test Functions¶
Module-level @test functions are collected into a generated test class (separate from the module class). This is required because xUnit discovers tests as instance methods on public classes.
Generated C#:
public static class MyModule
{
public static int X = 42;
}
public class MyModuleTests
{
[Xunit.FactAttribute]
public void TestValue()
{
Xunit.Assert.Equal(42, MyModule.X);
}
}
TestCase Base Class¶
For test classes with shared setup/teardown, inherit from unittest.TestCase:
from unittest import TestCase
class TestCalculator(TestCase):
def setup(self):
self.value = 0
def teardown(self):
pass
@test
def test_initial_value(self):
assert self.value == 0
Lifecycle Synthesis¶
The compiler transforms TestCase subclasses into xUnit-compatible test classes:
| Sharpy | C# |
|---|---|
class TestX(TestCase) |
public class TestX : IDisposable (if teardown present) |
def setup(self): |
Constructor body calls Setup() |
def teardown(self): |
Dispose() calls Teardown() |
@test methods |
[Fact] public void ...() |
TestCase is a marker type in Sharpy.Core — it has no xUnit dependency. The compiler synthesizes all xUnit integration during code generation.
assert_raises Context Manager¶
Use assert_raises to verify that code raises a specific exception:
from unittest import assert_raises
@test
def test_division_by_zero():
with assert_raises(ZeroDivisionError):
x = 1 / 0
Generated C#:
bool __raised_0 = false;
try
{
var x = (double)(1) / 0;
}
catch (ZeroDivisionError)
{
__raised_0 = true;
}
if (!__raised_0)
throw new global::Sharpy.AssertionError("Expected ZeroDivisionError to be raised, but no exception was raised");
assert_raises is a codegen transform — the compiler replaces the entire with block. The
lowering names no test framework, so the form is available in any function, not only a @test
one (#1413). The raised flag is not decoration: a naive try { body; throw AssertionError }
catch (E) { } self-swallows whenever E is AssertionError or a base of it, such as Exception,
and a body that raised nothing would pass silently. The flag is read after the try, where no
catch can reach it. An exception of the wrong type is deliberately not caught — it propagates with
its own message and stack, which says more than a wrapper would.
Capturing the exception (as)¶
with assert_raises(E) as exc: captures the thrown exception into a local so its attributes can
be asserted after the block:
@test
def test_message():
with assert_raises(ValueError) as exc:
raise ValueError("bad input")
assert exc.message == "bad input"
Generated C#:
ValueError exc = null!;
bool __raised_1 = false;
try
{
throw new global::Sharpy.ValueError("bad input");
}
catch (ValueError __caught_2)
{
__raised_1 = true;
exc = __caught_2;
}
if (!__raised_1)
throw new global::Sharpy.AssertionError("Expected ValueError to be raised, but no exception was raised");
Xunit.Assert.Equal("bad input", exc.Message);
The capture is declared ahead of the try and assigned in the catch, so it stays visible to the
statements that follow the with. Without an as name nothing reads the caught exception, and the
catch clause does not name it at all.
Matching the exception message¶
assert_raises accepts an optional second argument that asserts the exception's message matches a
regular expression. Semantics follow Python's pytest.raises(match=...) / re.search — the
pattern matches anywhere in the exception's .Message property (not anchored). This mirrors
pytest, which matches against str(exc) — equivalent to .Message for standard exceptions
(note that .NET's Exception.ToString(), by contrast, includes the type name and stack trace):
Generated C#:
ValueError __ex_3 = null!;
bool __raised_4 = false;
try
{
throw new global::Sharpy.ValueError("bad input");
}
catch (ValueError __caught_5)
{
__raised_4 = true;
__ex_3 = __caught_5;
}
if (!__raised_4)
throw new global::Sharpy.AssertionError("Expected ValueError to be raised, but no exception was raised");
if (!(global::System.Text.RegularExpressions.Regex.IsMatch(__ex_3.Message, "bad.*input")))
throw new global::Sharpy.AssertionError("Expected the raised ValueError's message to match " + "bad.*input" + ", but it was: " + __ex_3.Message);
Note the argument order: Regex.IsMatch(input, pattern) is the reverse of the
Assert.Matches(pattern, actual) this replaced.
Spelling: the pytest-style keyword form
assert_raises(ValueError, match="bad.*input")is also accepted by code generation, butmatchis currently a reserved keyword in the parser, so the keyword-argument spelling does not yet parse (tracked by issue #872). Use the positional form shown above for now.
The match argument combines with as: the captured name is reused for the message check.
with assert_raises(ValueError, "bad") as exc:
raise ValueError("bad input")
assert exc.message == "bad input"
Generated C#:
ValueError exc = null!;
bool __raised_0 = false;
try
{
throw new global::Sharpy.ValueError("bad input");
}
catch (ValueError __caught_1)
{
__raised_0 = true;
exc = __caught_1;
}
if (!__raised_0)
throw new global::Sharpy.AssertionError("Expected ValueError to be raised, but no exception was raised");
if (!(global::System.Text.RegularExpressions.Regex.IsMatch(exc.Message, "bad")))
throw new global::Sharpy.AssertionError("Expected the raised ValueError's message to match " + "bad" + ", but it was: " + exc.Message);
assert_almost_equal¶
For floating-point comparisons with precision:
from unittest import assert_almost_equal
@test
def test_float_precision():
assert_almost_equal(0.1 + 0.2, 0.3, places=10)
Generated C#:
assert_count_equal¶
assert_count_equal(a, b) asserts that two collections contain the same elements regardless of
order, respecting multiplicity (matching Python's unittest.TestCase.assertCountEqual —
[1, 2, 2] is not equal to [1, 2]):
from unittest import assert_count_equal
@test
def test_same_elements():
assert_count_equal([3, 1, 2], [1, 2, 3])
assert_count_equal([1, 2, 2], [2, 1, 2])
Generated C#:
Xunit.Assert.Equal(global::Sharpy.Builtins.Sorted([1, 2, 3]), global::Sharpy.Builtins.Sorted([3, 1, 2]));
Xunit.Assert.Equal(global::Sharpy.Builtins.Sorted([2, 1, 2]), global::Sharpy.Builtins.Sorted([1, 2, 2]));
The lowering sorts both operands and compares them with Xunit.Assert.Equal, which produces a
rich positional sequence diff on failure. Because the rewrite sorts the elements, they must be
comparable — Builtins.Sorted carries no compile-time constraint, so a collection of
non-comparable elements compiles but throws at runtime (the same failure mode as the sorted()
builtin).
assert_regex¶
assert_regex(text, pattern) asserts that text matches a regular expression (Python's
assertRegex argument order — text first):
from unittest import assert_regex
@test
def test_format():
assert_regex("2026-06-09", r"\d{4}-\d{2}-\d{2}")
Generated C#:
Note the argument swap: Sharpy follows Python's (text, pattern) order, while
Xunit.Assert.Matches takes (pattern, actual).
Capturing Output (captured_output)¶
captured_output() is a context manager that redirects Console.Out to an in-memory buffer so a
test can verify what code prints. getvalue() returns the accumulated text (mirroring
io.StringIO.getvalue). It restores the previous writer on exit.
from unittest import captured_output
@test
def test_print():
with captured_output() as output:
print("hello")
assert output.getvalue() == "hello\n"
Generated C# (the compilation unit imports the module via using static global::Sharpy.Unittest;,
so the factory call is unqualified):
[Xunit.FactAttribute]
public void TestPrint()
{
using (var output = CapturedOutput())
{
global::Sharpy.Builtins.Print("hello");
Xunit.Assert.Equal("hello\n", output.Getvalue());
}
}
captured_output is a plain Sharpy.Stdlib runtime type (Sharpy.Unittest.CapturedOutput,
implementing System.IDisposable); it lowers through the standard with → using path with no
special codegen.
Parallelism caveat:
Console.Outis process-global. xUnit parallelizes across test collections, so a test usingcaptured_outputcan race with another collection that also prints. If this matters, serialize affected tests into a single@test.collection.
Fixtures (@test.fixture)¶
A @test.fixture function provides a reusable, set-up value to tests that declare a parameter of
the same name. The compiler turns the fixture function into a public C# class and injects it into
consuming test classes via xUnit's IClassFixture<T> (shared once per test class).
@test.fixture
def greeting() -> str:
return "hello"
@test
def test_uses_greeting(greeting: str):
assert greeting == "hello"
Generated C#:
public class GreetingFixture
{
public string Value { get; private set; } = default!;
public GreetingFixture()
{
Value = "hello";
}
}
public partial class MyModuleTests : Xunit.IClassFixture<GreetingFixture>
{
private readonly GreetingFixture _greetingFixture;
public MyModuleTests(GreetingFixture greetingFixture)
{
_greetingFixture = greetingFixture;
}
[Xunit.FactAttribute]
public void TestUsesGreeting()
{
string greeting = _greetingFixture.Value;
Xunit.Assert.Equal("hello", greeting);
}
}
- The fixture function's
returnexpression becomes theValueproperty (return annotation is required). - A test parameter is matched to a fixture by name; the parameter is stripped from the
emitted
[Fact]signature and replaced with aT name = _nameFixture.Value;prelude. - Fixture consumption applies to module-level
@testfunctions (notTestCaseclasses).
Yield-based fixtures (setup / teardown)¶
A fixture that yields exposes the yielded value and runs the statements after the yield as
teardown. The generated class implements System.IDisposable; teardown runs in Dispose():
Generated C#:
public class CounterFixture : global::System.IDisposable
{
public Sharpy.List<int> Value { get; private set; } = default!;
private global::System.Action? _teardown;
public CounterFixture()
{
Sharpy.List<int> data = new Sharpy.List<int>() { 0 };
Value = data;
_teardown = () =>
{
data.Clear();
};
}
public void Dispose()
{
_teardown?.Invoke();
}
}
Async fixtures (IAsyncLifetime)¶
An async def @test.fixture emits a fixture class implementing xUnit's
Xunit.IAsyncLifetime instead of System.IDisposable. Setup (including awaits) moves into
InitializeAsync; teardown after the yield runs in DisposeAsync:
@test.fixture
async def db() -> Connection:
conn = await open_connection()
yield conn
await conn.close()
Generated C#:
public class DbFixture : Xunit.IAsyncLifetime
{
public Connection Value { get; private set; } = default!;
private System.Func<System.Threading.Tasks.Task>? _teardown;
public async System.Threading.Tasks.Task InitializeAsync()
{
Connection conn = await OpenConnection();
Value = conn;
_teardown = async () =>
{
await conn.Close();
};
}
public async System.Threading.Tasks.Task DisposeAsync()
{
if (_teardown != null)
{
await _teardown();
}
}
}
- The constructor stays empty; xUnit drives
InitializeAsync/DisposeAsyncautomatically throughIClassFixture<T>. - A return-based async fixture (no
yield) emitsInitializeAsyncwithValue = ...;and aDisposeAsyncreturningSystem.Threading.Tasks.Task.CompletedTask. - Consuming test classes are unchanged from the sync case.
tmp_path — per-test temporary directory¶
A module-level @test function that declares a tmp_path parameter receives the path of a fresh
temporary directory, created before the test and recursively deleted afterward (best-effort).
This mirrors pytest's tmp_path — the directory is per test, not shared:
import os
@test
def test_writes_file(tmp_path: str):
target: str = os.path.join(tmp_path, "data.txt")
with open(target, "w") as f:
f.write("content")
assert os.path.exists(target)
Generated C#:
public partial class MyModuleTests : System.IDisposable
{
private readonly global::Sharpy.TmpPathFixture _tmpPathFixture = new global::Sharpy.TmpPathFixture();
[Xunit.FactAttribute]
public void TestWritesFile()
{
string tmpPath = _tmpPathFixture.Value;
// ... test body ...
}
public void Dispose()
{
_tmpPathFixture.Dispose();
}
}
- No import is required —
tmp_pathis recognized by parameter name (the same convention as user fixtures). - Unlike user fixtures,
tmp_pathis not anIClassFixture<T>(which would be shared across the class); instead the test class holds aTmpPathFixtureinstance field and implementsSystem.IDisposable. xUnit constructs the test class once per test method and disposes it after each, giving exactly pytest's per-test lifecycle. - A user-defined
@test.fixture def tmp_path() -> str:overrides the built-in (the fixture registry is consulted first). TmpPathFixturecreates a unique directory under the system temp path and swallowsIOException/UnauthorizedAccessExceptionduring cleanup, so a cleanup failure never fails the test.
Project Setup¶
Test projects need xUnit packages in their .spyproj:
<Project>
<PropertyGroup>
<RootNamespace>MyTests</RootNamespace>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<SpyFile Include="**/*.spy" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
</ItemGroup>
</Project>
The compiler resolves <PackageReference> elements from the NuGet global cache and generates a test runner scaffold for dotnet test discovery.
SDK requirement
<PackageReference> support requires the .NET SDK on PATH. The dotnet tool install
path (sharpyc) always has it. Self-contained archive distributions do not include the
SDK and cannot restore NuGet packages.
Axiom Alignment¶
| Axiom | How Testing Aligns |
|---|---|
| Axiom 1 (.NET) | Compiles to standard xUnit [Fact] — full .NET test tooling compatibility |
| Axiom 2 (Python) | @test, assert, setup/teardown — Pythonic testing vocabulary |
| Axiom 3 (Types) | Assert rewriting is type-aware — assert a == b uses Assert.Equal for proper type dispatch |