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 continues to emit Debug.Assert().
| 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#:
assert_raises is a codegen transform — the compiler replaces the entire with block with Assert.Throws<T>(() => { ... }), which is the correct xUnit idiom for exception assertions.
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#:
var exc = Xunit.Assert.Throws<ValueError>((global::System.Action)(() =>
{
throw new global::Sharpy.ValueError("bad input");
}));
Xunit.Assert.Equal("bad input", exc.Message);
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#:
var __ex_0 = Xunit.Assert.Throws<ValueError>((global::System.Action)(() =>
{
throw new global::Sharpy.ValueError("bad input");
}));
Xunit.Assert.Matches("bad.*input", __ex_0.Message);
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 Assert.Matches call.
with assert_raises(ValueError, "bad") as exc:
raise ValueError("bad input")
assert exc.message == "bad input"
Generated C#:
var exc = Xunit.Assert.Throws<ValueError>((global::System.Action)(() =>
{
throw new global::Sharpy.ValueError("bad input");
}));
Xunit.Assert.Matches("bad", 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.
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 |