C# examples

Copy the smallest complete shape.

Start with the native PascalCase lifecycle, place it inside ordinary MSTest methods when useful, then build that same assembly into the browser AppBundle.

Basic passing and failing cases

using VanillaTest;

var test = new VanillaTestSuite();

test.Expects("2 + 2 equals 4");
if (2 + 2 == 4)
{
    test.Pass();
}
else
{
    test.Fail();
}
test.Done();

test.Expects("uppercase preserves the word");
if ("csharp".ToUpperInvariant() == "CSHARP")
{
    test.Pass();
}
else
{
    test.Fail();
}
test.Done();

TestResult result = test.Report();
Console.WriteLine(result.Report);
return result.Ok ? 0 : 1;

The first decision for a case wins. Done() moves the completed numbered row into Passed or Failed, and Report() seals the suite.

Inspect the immutable result

TestResult result = test.Report();

Console.WriteLine($"Total: {result.Total}");
Console.WriteLine($"Failures: {result.FailureCount}");
Console.WriteLine($"OK: {result.Ok}");

foreach (string row in result.Passed)
{
    Console.WriteLine($"PASS {row}");
}

foreach (string row in result.Failed)
{
    Console.Error.WriteLine($"FAIL {row}");
}

TestResult cached = test.Report();
Console.WriteLine(ReferenceEquals(result, cached)); // True

Passed and Failed are read-only snapshots. Each value includes its one-based case number and the .expects label, such as 1) .expects addition preserves the total.

Handle typed lifecycle errors

var test = new VanillaTestSuite();
test.Expects("only one active case");

try
{
    test.Expects("blocked while the first is active");
}
catch (VanillaTestException error)
    when (error.Error == VanillaTestError.TestAlreadyActive)
{
    Console.WriteLine(error.Message);
}

test.Pass();
test.Done();
Console.WriteLine(test.Report().Report);

The rejected transition leaves the suite unchanged. That is why the active case can still pass, finish, and report normally after the exception.

Use the lifecycle inside MSTest

using Microsoft.VisualStudio.TestTools.UnitTesting;
using VanillaTest;

namespace Sample.Tests;

[TestClass]
public sealed class CalculationTests
{
    [TestMethod]
    public void AdditionPreservesTheTotal()
    {
        var test = new VanillaTestSuite();
        test.Expects("addition preserves the total");
        if (1 + 2 == 3) test.Pass(); else test.Fail();
        test.Done();

        TestResult result = test.Report();
        Assert.IsTrue(result.Ok, result.Report);
        Assert.AreEqual(1, result.Total);
    }

    [TestMethod]
    public async Task AsyncWorkIsSupported()
    {
        await Task.Yield();
        Assert.IsTrue(new VanillaTestSuite().Report().Ok);
    }
}
dotnet test --configuration Release

These public, parameterless methods also fit the browser host. Standard native MSTest remains available for methods that need data rows, injected runner context, filters, or other adapter services.

The repository's seven-method core inventory

The live C# browser example does not maintain a second test copy. It publishes these same seven methods from VanillaTest.Tests:

MethodContract covered
EmptyReportIsPassingCachedAndFinalEmpty pass, cached result, sealed suite.
LifecyclePreservesFirstDecisionsOrderAndReportFirst decision, undecided failure, ordering, exact report.
GivenWhenThenUserFlowReportsSuccessInStepOrderBehavioral user journey, successful ordered steps, exact report.
InvalidTransitionsAreTypedAndDoNotAdvanceNumberingStable lifecycle errors and unchanged state.
SuitesIsolateUnicodeDescriptionsAndOutcomesSuite isolation, exact Unicode descriptions.
ErrorMessagesAndNullContractAreStableError messages, codes, and null handling.
ResultCollectionsAreReadOnlySnapshotsImmutable result collections and cached identity.

Build the same MSTest assembly for the browser

dotnet workload install wasm-tools
dotnet tool install --global vanilla-test.tool --version 2.1.0 --add-source ./packages

dotnet vanilla-test --browser \
  --project tests/Sample.Tests/Sample.Tests.csproj \
  --out-dir dist/vanilla-test

The tool emits a .NET 10 browser-wasm AppBundle. Deploy all of dist/vanilla-test/, including its _framework/ directory; this is intentionally not a single-file artifact.

Use a compatible custom browser page

<!doctype html>
<html lang="en" data-csharp-browser="running">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Dashboard browser tests</title>
</head>
<body>
    <h1
      data-csharp-browser-site-check="dashboard renders its heading"
      data-csharp-browser-expected="Dashboard browser tests"
    >Dashboard browser tests</h1>

    <p>Runtime: <code data-csharp-browser-target>browser-wasm</code></p>
    <p data-csharp-browser-result role="status" aria-live="polite">Starting…</p>
    <strong data-csharp-browser-score>0/?</strong>
    <ol data-csharp-browser-checks></ol>
    <pre data-csharp-browser-console role="log"></pre>

    <script type="module" src="./browser.js"></script>
</body>
</html>
dotnet vanilla-test --browser \
  --project tests/Sample.Tests/Sample.Tests.csproj \
  --page tests/browser.html

The result, score, checks, target, console, and module markers connect the custom page to the generated adapter. The visible heading marker adds one exact browser-site assertion before .NET starts.

Run the native and browser benchmark harness

dotnet run \
  --project dotnet/benchmarks/VanillaTest.Benchmarks/VanillaTest.Benchmarks.csproj \
  --configuration Release

dotnet vanilla-test --browser --bench \
  --project dotnet/benchmarks/VanillaTest.Benchmarks/VanillaTest.Benchmarks.csproj

Both commands execute the exact same fixed workload: 1,000,000 cases across 1,000 fresh suites, with the resolved runner-entry invocation, arithmetic/checksum work, lifecycle calls, report materialization, per-suite checks, final validation, and runtime-managed work inside each sample. Each runtime initializes first, discards one warmup, and records five samples; startup, module loading, initial module compilation or instantiation, runtime initialization, coverage, memory probes, encoding, file output, and teardown remain outside every timer. This is the same boundary used by the JavaScript and Rust rows. Inspect both raw sample sets and their provenance or run the browser lane here.