AnyUnit.Style.Expecto 1.2.2

dotnet add package AnyUnit.Style.Expecto --version 1.2.2
                    
NuGet\Install-Package AnyUnit.Style.Expecto -Version 1.2.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="AnyUnit.Style.Expecto" Version="1.2.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AnyUnit.Style.Expecto" Version="1.2.2" />
                    
Directory.Packages.props
<PackageReference Include="AnyUnit.Style.Expecto" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add AnyUnit.Style.Expecto --version 1.2.2
                    
#r "nuget: AnyUnit.Style.Expecto, 1.2.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package AnyUnit.Style.Expecto@1.2.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=AnyUnit.Style.Expecto&version=1.2.2
                    
Install as a Cake Addin
#tool nuget:?package=AnyUnit.Style.Expecto&version=1.2.2
                    
Install as a Cake Tool

AnyUnit.Style.Expecto

Expecto-style F# tests for AnyUnit: testList/testCase values and the Expect assertion vocabulary, close enough that an existing Expecto suite's call sites usually don't change.

module MyTests
open AnyUnit.Style.Expecto

[<Tests>]
let tests =
    testList "math" [
        testList "addition" [
            testCase "handles negatives" <| fun () ->
                Expect.equal (-2 + -3) -5 "negatives should add"
        ]

        testCaseAsync "async work" <| async {
            let! v = async { return 42 }
            Expect.equal v 42 "should round-trip"
        }

        ptestCase "not ready yet" <| fun () ->
            failtest "reported Ignored, never run"
    ]

Add the assembly-level opt-in once, anywhere in the project:

[<assembly: AnyUnit.Style.Expecto.Discovery.ExpectoStyle>]
do ()

Then run it with any AnyUnit runner - anyunit-runner, dotnet test via AnyUnit.TestingPlatform, or browser-wasm.

Porting notes

Argument order is Expecto's, including the required message last: Expect.equal actual expected message. That is preserved literally, even where it reads backwards to someone arriving from NUnit or xUnit.

open Expecto becomes open AnyUnit.Style.Expecto. The builders are [<AutoOpen>], so that single line is usually the whole edit.

The project becomes a library. An Expecto test project is an Exe with its own entry point (or one generated by YoloDev.Expecto.TestSdk); AnyUnit's runners load a library. Switch OutputType to Library, drop the Expecto/test-SDK packages, and add <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> - a library doesn't copy FSharp.Core.dll beside its output the way an exe does, and the runner needs it there.

Expecto.Logging has a minimal stand-in under AnyUnit.Style.Expecto.Logging: Log.create, logSimple, the info/warn/... members, LogLevel, and Message.event/eventX. It is Expecto's own recommended way to write output from a test, so real suites use it. Output goes into the running test's log - so it appears in that test's results next to its assertions - or to the console when called outside a test (creating a logger at module scope is normal and must not throw). None of Expecto's targets, formatting or sinks.

Nesting is flattened, because AnyUnit's model is a fixed Assembly → Fixture → Test. The root testList names the fixture; every leaf keeps the rest of its path, joined with / - which is how Expecto prints them anyway:

fixture "math"
  test "addition/handles negatives"

Two bindings in one module become two fixtures, so [<Tests>] is the grouping unit rather than the module.

What's covered

testList, testCase, testCaseAsync, testCaseTask, and the ptest* pending forms of each. [<Tests>] discovery.

Expect: equal, notEqual, same, isTrue, isFalse, isNull, isNotNull, isSome, isNone, isOk, isError, wantOk, wantError, wantSome, isEmpty, isNonEmpty, contains, hasLength, hasCountOf, isGreaterThan, isLessThan, stringContains, sequenceEqual, all, throws, throwsT. Top-level failtest/failtestf, and Tests.failtest/Tests.failtestf for suites that build their own helpers on them.

What's not

Focused tests (ftestCase, ftestList, [<FTests>]) are deliberately absent. Focusing is a hand-editing debugging aid that is a bug once committed - Expecto itself ships --fail-on-focused-tests to catch exactly that - so a stray one here is a compile error rather than a silent change in which tests run.

testProperty needs FsCheck, which the core deliberately doesn't depend on. Expect.isFasterThan and the rest of Expecto.Performance are out for the same reason.

runTestsWithCLIArgs and Expecto's own CLI have no equivalent - AnyUnit has its own runners, which is the point of the exercise.

Parallel execution. AnyUnit's engine runs tests sequentially by design, so Expecto's default parallelism (and Sequenced/ParallelWith) has nothing to configure.

Custom helpers, Expect.pass, and an escape hatch

Expecto's idiom for a custom assertion is "throw on failure, do nothing on success":

let hasOkValue v x =
    match x with
    | Ok x when x = v -> ()
    | Ok x -> Tests.failtestf "Expected Ok(%A), was Ok(%A)." v x
    | Error x -> Tests.failtestf "Expected Ok, was Error(%A)." x

Expecto has no assert count, so that's fine there. Under AnyUnit, a success path that does nothing is indistinguishable from a test that asserted nothing, and the test reports NoError rather than Success. In a real port (FsToolkit.ErrorHandling, below) that was 511 of 1,461 tests.

The fix is one line on each helper's success path:

    | Ok x when x = v -> Expect.pass ()

Expect.pass () registers a successful assertion without checking anything - an AnyUnit addition with no Expecto counterpart. In that port, 11 such lines in the suite's one helper file brought the 511 down to 45, and those 45 genuinely assert nothing (they are compile-shape tests: fun _ -> ignore Result.ignore).

The escape hatch, for a port that wants Expecto's verdicts first and that cleanup later:

[<assembly: AnyUnit.Style.Expecto.Discovery.ExpectoStyle(CompletionIsPass = true)>]

or per binding, [<Tests; CompletionIsPass>]. A test that completes without throwing then reports Success even with no Expect call. The cost is the NoError signal, for the whole assembly or that binding - which is a real loss: it has found genuinely assertion-free tests in every real suite ported so far. It's off by default for that reason, and it only ever upgrades NoError; a failing test still fails.

One more AnyUnit addition

requires has no Expecto counterpart. Attribute-based styles declare a platform requirement with [RequiresCapability(...)]; a value-based style has no method or class to attach an attribute to, so the requirement composes into the tree instead:

requires AnyUnit.Run.TestCapabilities.AsyncYield (
    testCaseAsync "genuinely suspends" <| async {
        do! Async.Sleep 1
        Expect.isTrue true "resumes where the platform can yield"
    })

Wrapped around a testList, it applies to every leaf beneath it. Tests whose awaits all complete synchronously - the overwhelmingly common case

  • need nothing.

How assertions reach AnyUnit

Expect.equal actual expected "msg" is a free function with no receiver, and keeping that call site is the whole point of the style. It asserts through the running test (AnyUnit.Run.AmbientTest, which the engine sets around every test body), so the counts are real: a test that makes no assertion at all still reports NoError rather than a false Success, which is the distinction AnyUnit's instance-scoped Assert exists to preserve.

It is deliberately not Assert.GlobalStyle, which hands back a throwaway Assert whose count reaches nobody and flips a process-wide flag degrading that same distinction for every other style in the run.

The practical consequence: Expect only works inside a running test. Calling it from module initialisation, or from a helper invoked outside a test body, raises a descriptive InvalidOperationException rather than quietly doing nothing.

Validated against a real suite

Ported cwtools' CWToolsTests - 54 tests across 21 [<Tests>] bindings, a real parser/validator suite with fixture files, ptestCase, nested testLists, and Expecto.Logging wired into the library's own log hooks. Its CI is green; locally on macOS it runs 47 pass / 3 fail / 4 skip under Expecto (the 3 are a locale-dependent type initialiser in the code under test, not the tests), with Expecto.parallel=false as its CI uses.

After the port, on AnyUnit: the same 54 tests - the same 3 failing, the same 4 skipped - reported as 43 Success, 4 NoError, 3 Error, 4 Ignore.

The whole port was 5 files, 12 lines in, 12 out, plus a 4-line file for the assembly attribute:

Edit Count
open Expectoopen AnyUnit.Style.Expecto (and .Logging, .Logging.Message) 6 lines
One fully-qualified Expecto.Logging.Log.create → the new namespace 1 line
ExeLibrary, CopyLocalLockFileAssemblies, package → project swap csproj
[<assembly: ExpectoStyle>] 1 new file

No test body changed. Every testList, testCase, ptestCase and Expect call compiled and ran as written.

Found by the port, now fixed

Expect.contains, isNonEmpty, hasLength and hasCountOf were missing, and so was Expecto.Logging entirely. All added.

Two differences that are the point, not problems

The 3 failures report as Error, not Fail: they are an AggregateException from the code under test, not a failed expectation. Expecto calls both "Failed"; AnyUnit tells them apart.

4 tests report NoError - they assert nothing (a match whose success arm is ()). Expecto reports them as passing, and cannot tell them from a real pass. That distinction is what the ambient assert here was designed to preserve - see "How assertions reach AnyUnit" above.

Validated against a second suite

FsToolkit.ErrorHandling's main test project: 1,461 tests, about 1,180 of them testCaseAsync/testCaseTask, with its own Expect helper module built on Tests.failtestf. CI green; locally under Expecto: 1,453 passed, 8 ignored, 0 failed.

After the port, with CompletionIsPass = true: 1,453 Success, 8 Ignore - identical. Without it, after the 11 Expect.pass edits above: 1,408 Success, 45 NoError, 8 Ignore - and the 45 are the compile-shape tests.

The port: open Expecto swapped in 40 files, the entry point removed (the [<Tests>] root it already carried is the discovery hook), ExeLibrary with CopyLocalLockFileAssemblies, the Expecto and test-SDK packages swapped for this one, and one unused ftestCaseTask helper deleted from the suite's own shim - focus is deliberately absent here, so it failed to compile rather than silently changing behaviour.

Found by this port, now fixed

  • Expect.equal compared arrays by reference. It used Object.Equals; Expecto's is F#'s structural =. 20 tests failed with a message that printed the same array twice. Now =.
  • Expect.wantOk/wantError/wantSome, Expect.same, and Tests.failtest/failtestf were missing.
  • Every F# style package demanded FSharp.Core >= 10.1.x - the SDK's implicit reference, carried into the nuspec as a hard floor. This suite pins 9.0.300 and could not restore at all. All F# projects in this repo now build against 6.0.1, the conventional floor for a netstandard2.0 F# library, and the test payloads run on it in CI so the floor is known to work rather than merely compile.
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.2 42 9/16/2026
1.2.1 34 9/16/2026
1.2.0 45 9/16/2026