Testing Visual Basic Code

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every test project and test shown here was built with warnings treated as errors and Option Strict On, and run with dotnet test in the .NET 10 SDK container (Linux, arm64); the test counts and messages are the ones the tools printed. Container paths such as /w/... are shortened in the output blocks. The reference scenario is the shelf (Isbn, Book, Magazine, Loan, ICatalogStore, Catalog); the library under test has the root namespace Shelf.Core and the three test projects are Shelf.Tests.MSTest, Shelf.Tests.XUnit and Shelf.Tests.NUnit.

Visual Basic has first-class support in all three .NET test frameworks. The framework packages are language-neutral libraries, the dotnet new templates exist for VB, and dotnet test does not care which language the tests are written in. What changes from C# is the spelling: attributes use angle brackets, lambdas are Sub() and Function(), Is is a keyword that NUnit’s constraint syntax collides with, Async Sub tests are rejected, and a few things that C# test code takes for granted (await using, tuple deconstruction, an async method returning ValueTask) do not exist in VB. This page shows the complete working programs for the three frameworks, then explains each topic across the three at once. The shared .NET background (what a test runner is, the pyramid, naming, isolation) is on the C# page Testing, which this page does not repeat; here the focus is the VB syntax, the VB-specific traps and the tooling that is verified to work from VB.

Choosing a framework

MSTest xUnit.net NUnit

Package (as built here)

MSTest 4.0.2 (a metapackage: framework, analyzers, adapter, test SDK)

xunit 2.9.3 with xunit.runner.visualstudio 3.1.4 and Microsoft.NET.Test.Sdk 17.14.1

NUnit 4.3.2 with NUnit3TestAdapter 5.0.0 and Microsoft.NET.Test.Sdk 17.14.1

VB template in the .NET 10 SDK

dotnet new mstest -lang VB

dotnet new xunit -lang VB

dotnet new nunit -lang VB

Test class marker

<TestClass> is required

none: any Public Class with <Fact> or <Theory> methods

<TestFixture> is optional

Test method marker

<TestMethod>

<Fact>

<Test>

Instance per test

a new instance for every test

a new instance for every test

one instance per fixture, shared by its tests

Setup for each test

<TestInitialize> (and the constructor)

the constructor

<SetUp>

Style of assertions

Assert.AreEqual(expected, actual) and friends

Assert.Equal(expected, actual) and friends

constraint model Assert.That(actual, [Is].EqualTo(expected))

Who maintains it

Microsoft (the testfx repository)

the xUnit.net project

the NUnit project

All three run under dotnet test, Visual Studio Test Explorer and Visual Studio Code’s test explorer, and all three work with the same mocking and coverage tools. There is no technical reason to prefer one for VB: pick the one the team already uses, and use the same one for a whole solution. The Unit testing Visual Basic tutorials on Microsoft Learn use MSTest, NUnit and xUnit and a small PrimeService class; this page uses the shelf instead so that it fits the other pages of this section.

Source: Testing in .NET

Creating a test project

The SDK installs a VB template for each of the three frameworks. Running them into empty folders (this is the real output, in the .NET 10 SDK container) gives:

dotnet new mstest -lang VB -o Shelf.Tests.mstest
dotnet new xunit -lang VB -o Shelf.Tests.xunit
dotnet new nunit -lang VB -o Shelf.Tests.nunit
The template "MSTest Test Project" was created successfully.
Restore succeeded.
The template "xUnit Test Project" was created successfully.
Restore succeeded.
The template "NUnit Test Project" was created successfully.
Restore succeeded.

(The restore lines are abbreviated.) The generated project files differ more than the commands suggest:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="MSTest" Version="4.0.2" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
  </ItemGroup>

</Project>
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <RootNamespace>Shelf.Tests.xunit</RootNamespace>
    <TargetFramework>net10.0</TargetFramework>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
  </ItemGroup>

</Project>
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
    <PackageReference Include="NUnit" Version="4.3.2" />
    <PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
    <PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
  </ItemGroup>

</Project>

The template output shows the points to adjust in a VB test project:

  • The templates do not turn Option Strict on. The SDK default for VB is OptionStrict off, so the test projects of a strict solution need <OptionStrict>On</OptionStrict> in the project or, better, in a Directory.Build.props for the whole solution (see Projects, MSBuild and the dotnet CLI and Option Statements). The shelf solution uses OptionStrict, TreatWarningsAsErrors and NuGetAudit set in one Directory.Build.props, so every file below starts with Option Strict On and every analyzer warning is an error.

  • Only the MSTest template sets LangVersion, ImplicitUsings and Nullable. They are C#-centred settings: Nullable has no effect on VB, and ImplicitUsings only adds a few namespace imports.

  • The MSTest template adds MSTestSettings.vb, an assembly-level Parallelize attribute, because the MSTest analyzers demand a decision about parallelism (shown below).

  • The xUnit and NUnit templates add coverlet.collector for code coverage, and the NUnit one adds NUnit.Analyzers.

  • The template folder name becomes the project name. A hyphenated name such as Shelf-Tests has no valid root namespace, so set RootNamespace (BC30113 otherwise; see Program Structure and Code Conventions).

  • There is no xUnit v3 template in the SDK’s list. dotnet new list -lang VB lists xunit (the v2 template shown above) and no xunit3, and neither does dotnet new list test for any language in the 10.0.402 SDK. The 11.0.100-rc.2 SDK image lists the same VB test templates. A VB project can still use xUnit v3 by hand; see Microsoft.Testing.Platform and xUnit v3.

The code under test

The tests use Shelf.Core, whose domain types are the shelf types used throughout this section, plus one small file added for this page, LoanDesk.vb, that gives the tests something to mock (IBookLookup), something time-dependent (IsOverdue) and something Friend (ComputeFine). Its project file declares which test assemblies may see the Friend members:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <RootNamespace>Shelf.Core</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <InternalsVisibleTo Include="Shelf.Tests.MSTest" />
    <InternalsVisibleTo Include="Shelf.Tests.XUnit" />
    <InternalsVisibleTo Include="Shelf.Tests.NUnit" />
  </ItemGroup>
</Project>
Option Strict On

Public Interface IBookLookup
    Function FindTitleAsync(isbn As Isbn) As Task(Of String)
End Interface

Public Class LoanDesk
    Private ReadOnly _lookup As IBookLookup
    Private ReadOnly _clock As TimeProvider

    Public Sub New(lookup As IBookLookup, clock As TimeProvider)
        _lookup = lookup
        _clock = clock
    End Sub

    Public Async Function DescribeAsync(isbn As Isbn) As Task(Of String)
        Dim title = Await _lookup.FindTitleAsync(isbn)
        Return If(title, "unknown title")
    End Function

    Public Function IsOverdue(loan As Loan) As Boolean
        Return _clock.GetLocalNow().Date > loan.DueOn.Date
    End Function

    Friend Shared Function ComputeFine(dueOn As Date, returnedOn As Date) As Decimal
        Dim lateDays = Math.Max(0, (returnedOn.Date - dueOn.Date).Days)
        Return lateDays * 0.25D
    End Function
End Class

The domain types (Isbn.Parse, Isbn.TryParse, Catalog.Search, Catalog.Lend, Loan.Return) are the ones defined on Classes and Objects and Structures and Value Types. Isbn.Parse throws FormatException with the message 'text' is not a valid ISBN-13. for a bad check digit, Loan.Return throws InvalidOperationException the second time it is called, and Catalog.Search is a case-insensitive title search.

Design for testability pays for itself here: LoanDesk receives a TimeProvider and an IBookLookup through its constructor, and Catalog receives its ICatalogStore. No test needs the real clock, the network or a database. The TimeProvider abstraction is described on Dates, Times and Formatting.

A hand-written fake of the store, identical in the three test projects, keeps the catalog tests free of the data project:

Option Strict On

Imports Shelf.Core

Public Class FakeStore
    Implements ICatalogStore

    Private ReadOnly _items As New List(Of CatalogItem)

    Public Sub Add(item As CatalogItem) Implements ICatalogStore.Add
        item.Id = _items.Count + 1
        _items.Add(item)
    End Sub

    Public Function GetAll() As IReadOnlyList(Of CatalogItem) Implements ICatalogStore.GetAll
        Return _items
    End Function

    Public Function Find(id As Integer) As CatalogItem Implements ICatalogStore.Find
        Return _items.FirstOrDefault(Function(i) i.Id = id)
    End Function
End Class

MSTest

The project

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <RootNamespace>Shelf.Tests.MSTest</RootNamespace>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="MSTest" Version="4.0.2" />
    <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
    <PackageReference Include="Moq" Version="4.20.72" />
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <ProjectReference Include="..\Shelf.Core\Shelf.Core.vbproj" />
  </ItemGroup>
</Project>
Imports Microsoft.VisualStudio.TestTools.UnitTesting

<Assembly: Parallelize(Workers:=0, Scope:=ExecutionScope.MethodLevel)>

The tests

Option Strict On

Imports Microsoft.VisualStudio.TestTools.UnitTesting
Imports Moq
Imports Microsoft.Extensions.Time.Testing
Imports Shelf.Core

<TestClass>
Public Class CatalogTests
    Private _catalog As Catalog = Nothing

    Public Property TestContext As TestContext

    <TestInitialize>
    Public Sub SetUp()
        _catalog = New Catalog(New FakeStore())
        _catalog.Add(New Book("Dune", 1965, Isbn.Parse("9780441172719")))
        _catalog.Add(New Magazine("Byte", 1981, 7))
    End Sub

    <TestCleanup>
    Public Sub TearDown()
        TestContext.WriteLine("cleanup after " & TestContext.TestName)
    End Sub

    <TestMethod>
    Public Sub Search_IgnoresCase()
        Dim found = _catalog.Search("DUN").ToList()
        Assert.HasCount(1, found)
        Assert.AreEqual("Dune", found(0).Title)
    End Sub

    <TestMethod>
    Public Sub Isbn_RejectsBadCheckDigit()
        Dim ex = Assert.ThrowsExactly(Of FormatException)(Sub() Isbn.Parse("9780441172718"))
        StringAssert.Contains(ex.Message, "not a valid ISBN-13")
    End Sub

    <TestMethod>
    <DataRow("9780441172719", True)>
    <DataRow("9780441172718", False)>
    <DataRow("not an isbn", False)>
    Public Sub Isbn_ChecksDigit(text As String, expected As Boolean)
        Dim parsed As Isbn = Nothing
        Assert.AreEqual(expected, Isbn.TryParse(text, parsed))
    End Sub

    Public Shared ReadOnly Property FineCases As IEnumerable(Of Object())
        Get
            Return {
                New Object() {New Date(2026, 10, 1), New Date(2026, 10, 1), 0D},
                New Object() {New Date(2026, 10, 1), New Date(2026, 10, 5), 1D},
                New Object() {New Date(2026, 10, 5), New Date(2026, 10, 1), 0D}
            }
        End Get
    End Property

    <TestMethod>
    <DynamicData(NameOf(FineCases))>
    Public Sub Fine_IsQuarterPerLateDay(dueOn As Date, returnedOn As Date, expected As Decimal)
        Assert.AreEqual(expected, LoanDesk.ComputeFine(dueOn, returnedOn))
    End Sub

    <TestMethod>
    <TestCategory("Async")>
    Public Async Function Describe_UsesLookup() As Task
        Dim lookup As New Mock(Of IBookLookup)()
        lookup.Setup(Function(l) l.FindTitleAsync(It.IsAny(Of Isbn)())).ReturnsAsync("Dune")
        Dim desk As New LoanDesk(lookup.Object, TimeProvider.System)

        Dim text = Await desk.DescribeAsync(Isbn.Parse("9780441172719"))

        Assert.AreEqual("Dune", text)
        lookup.Verify(Function(l) l.FindTitleAsync(It.IsAny(Of Isbn)()), Times.Once)
    End Function

    <TestMethod>
    Public Sub Loan_BecomesOverdueWhenClockAdvances()
        Dim clock As New FakeTimeProvider(New DateTimeOffset(2026, 10, 1, 9, 0, 0, TimeSpan.Zero))
        clock.SetLocalTimeZone(TimeZoneInfo.Utc)
        Dim desk As New LoanDesk(Mock.Of(Of IBookLookup)(), clock)
        Dim loan = _catalog.Lend(_catalog.Search("Dune").First(), "Ada", clock.GetLocalNow().Date)

        Assert.IsFalse(desk.IsOverdue(loan))
        clock.Advance(TimeSpan.FromDays(15))
        Assert.IsTrue(desk.IsOverdue(loan))
    End Sub

    <TestMethod>
    Public Sub Return_Twice_Throws()
        Dim loan = _catalog.Lend(_catalog.Search("Dune").First(), "Ada", #10/1/2026#)
        loan.Return()
        Assert.ThrowsExactly(Of InvalidOperationException)(Sub() loan.Return())
    End Sub

    <TestMethod>
    Public Async Function Describe_PropagatesLookupFailure() As Task
        Dim lookup As New Mock(Of IBookLookup)()
        lookup.Setup(Function(l) l.FindTitleAsync(It.IsAny(Of Isbn)())).ThrowsAsync(New InvalidOperationException("offline"))
        Dim desk As New LoanDesk(lookup.Object, TimeProvider.System)

        Dim ex = Await Assert.ThrowsExactlyAsync(Of InvalidOperationException)(
            Function() desk.DescribeAsync(Isbn.Parse("9780441172719")))

        Assert.AreEqual("offline", ex.Message)
    End Function

    <TestMethod>
    Public Sub Add_ForwardsToStore()
        Dim store As New Mock(Of ICatalogStore)()
        Dim catalog As New Catalog(store.Object)
        Dim magazine As New Magazine("Byte", 1981, 7)

        catalog.Add(magazine)

        store.Verify(Sub(s) s.Add(magazine), Times.Once)
        store.VerifyNoOtherCalls()
    End Sub
End Class

Running the project (dotnet test takes the project path as a positional argument, the same in every framework):

dotnet test Shelf.Tests.MSTest
Test run for Shelf.Tests.MSTest.dll (.NETCoreApp,Version=v10.0)
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:    15, Skipped:     0, Total:    15, Duration: 41 ms - Shelf.Tests.MSTest.dll (net10.0)

Notes on the MSTest file:

  • <TestClass> is mandatory; without it the methods are silently not discovered. <TestMethod> methods are Public Sub or Public Function ... As Task, never Async Sub.

  • Public Property TestContext As TestContext is filled in by the framework before each test; a property of that exact type is all it needs, and the TestInitialize/TestCleanup methods can use it (TestContext.TestName, WriteLine).

  • MSTest 4 has Assert.ThrowsExactly(Of T)(Sub() ...) for the exact type and Assert.Throws(Of T) for the type or a subclass. Both return the exception, so the message can be asserted (the test Isbn_RejectsBadCheckDigit uses StringAssert.Contains). Assert.ThrowsExactlyAsync(Of T)(Function() ...) is the async version.

  • Assert.HasCount(1, found) is the collection-size assertion that replaced Assert.AreEqual(1, found.Count); the analyzer insists on it (see Analyzers and what they do in VB).

  • The attribute-based expected exception and the old assertion are gone. MSTest 4 has no ExpectedException attribute and no Assert.ThrowsException: the former gives BC30002 (Type 'ExpectedException' is not defined.) and the latter BC30456 ('ThrowsException' is not a member of 'Assert'.). Tutorials and books written for MSTest 1 and 2 use both.

xUnit.net

The project

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <RootNamespace>Shelf.Tests.XUnit</RootNamespace>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
    <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
    <PackageReference Include="NSubstitute" Version="5.3.0" />
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <ProjectReference Include="..\Shelf.Core\Shelf.Core.vbproj" />
  </ItemGroup>
</Project>

The tests

Option Strict On

Imports Xunit
Imports Xunit.Abstractions
Imports NSubstitute
Imports Microsoft.Extensions.Time.Testing
Imports Shelf.Core

Public Class CatalogTests
    Implements IDisposable

    Private ReadOnly _catalog As Catalog
    Private ReadOnly _output As ITestOutputHelper

    Public Sub New(output As ITestOutputHelper)
        _output = output
        _catalog = New Catalog(New FakeStore())
        _catalog.Add(New Book("Dune", 1965, Isbn.Parse("9780441172719")))
        _catalog.Add(New Magazine("Byte", 1981, 7))
    End Sub

    Public Sub Dispose() Implements IDisposable.Dispose
        _output.WriteLine("dispose after each test")
    End Sub

    <Fact>
    Public Sub Search_IgnoresCase()
        Dim found = _catalog.Search("DUN").ToList()
        Dim only = Assert.Single(found)
        Assert.Equal("Dune", only.Title)
    End Sub

    <Fact>
    Public Sub Isbn_RejectsBadCheckDigit()
        Dim ex = Assert.Throws(Of FormatException)(Sub() Isbn.Parse("9780441172718"))
        Assert.Contains("not a valid ISBN-13", ex.Message)
    End Sub

    <Theory>
    <InlineData("9780441172719", True)>
    <InlineData("9780441172718", False)>
    <InlineData("not an isbn", False)>
    Public Sub Isbn_ChecksDigit(text As String, expected As Boolean)
        Dim parsed As Isbn = Nothing
        Assert.Equal(expected, Isbn.TryParse(text, parsed))
    End Sub

    Public Shared ReadOnly Property FineCases As TheoryData(Of Date, Date, Decimal)
        Get
            Dim data As New TheoryData(Of Date, Date, Decimal)()
            data.Add(New Date(2026, 10, 1), New Date(2026, 10, 1), 0D)
            data.Add(New Date(2026, 10, 1), New Date(2026, 10, 5), 1D)
            data.Add(New Date(2026, 10, 5), New Date(2026, 10, 1), 0D)
            Return data
        End Get
    End Property

    <Theory>
    <MemberData(NameOf(FineCases))>
    Public Sub Fine_IsQuarterPerLateDay(dueOn As Date, returnedOn As Date, expected As Decimal)
        Assert.Equal(expected, LoanDesk.ComputeFine(dueOn, returnedOn))
    End Sub

    <Fact>
    <Trait("Category", "Async")>
    Public Async Function Describe_UsesLookup() As Task
        Dim lookup = Substitute.For(Of IBookLookup)()
        lookup.FindTitleAsync(Arg.Any(Of Isbn)()).Returns(Task.FromResult("Dune"))
        Dim desk As New LoanDesk(lookup, TimeProvider.System)

        Dim text = Await desk.DescribeAsync(Isbn.Parse("9780441172719"))

        Assert.Equal("Dune", text)
        Await lookup.Received(1).FindTitleAsync(Arg.Any(Of Isbn)())
    End Function

    <Fact>
    Public Sub Loan_BecomesOverdueWhenClockAdvances()
        Dim clock As New FakeTimeProvider(New DateTimeOffset(2026, 10, 1, 9, 0, 0, TimeSpan.Zero))
        clock.SetLocalTimeZone(TimeZoneInfo.Utc)
        Dim desk As New LoanDesk(Substitute.For(Of IBookLookup)(), clock)
        Dim loan = _catalog.Lend(_catalog.Search("Dune").First(), "Ada", clock.GetLocalNow().Date)

        Assert.False(desk.IsOverdue(loan))
        clock.Advance(TimeSpan.FromDays(15))
        Assert.True(desk.IsOverdue(loan))
    End Sub

    <Fact>
    Public Sub Return_Twice_Throws()
        Dim loan = _catalog.Lend(_catalog.Search("Dune").First(), "Ada", #10/1/2026#)
        loan.Return()
        Assert.Throws(Of InvalidOperationException)(Sub() loan.Return())
    End Sub

    <Fact>
    Public Async Function Describe_PropagatesLookupFailure() As Task
        Dim lookup = Substitute.For(Of IBookLookup)()
        lookup.FindTitleAsync(Arg.Any(Of Isbn)()).Returns(Task.FromException(Of String)(New InvalidOperationException("offline")))
        Dim desk As New LoanDesk(lookup, TimeProvider.System)

        Dim ex = Await Assert.ThrowsAsync(Of InvalidOperationException)(
            Function() desk.DescribeAsync(Isbn.Parse("9780441172719")))

        Assert.Equal("offline", ex.Message)
    End Function

    <Fact>
    Public Sub Add_ForwardsToStore()
        Dim store = Substitute.For(Of ICatalogStore)()
        Dim catalog As New Catalog(store)
        Dim magazine As New Magazine("Byte", 1981, 7)

        catalog.Add(magazine)

        store.Received(1).Add(magazine)
    End Sub
End Class
dotnet test Shelf.Tests.XUnit
Test run for Shelf.Tests.XUnit.dll (.NETCoreApp,Version=v10.0)
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:    15, Skipped:     0, Total:    15, Duration: 48 ms - Shelf.Tests.XUnit.dll (net10.0)

Notes on the xUnit file:

  • There is no class attribute. xUnit finds every <Fact> and <Theory> in any public class, and creates a new instance of the class for every test, which is why the constructor is the setup and IDisposable.Dispose is the teardown. Implements IDisposable on a VB class needs the Implements IDisposable.Dispose clause on the method.

  • ITestOutputHelper (from Xunit.Abstractions) is injected through the constructor to write to the test output; Console.WriteLine is not captured.

  • Assert.Single(found) asserts that the collection has exactly one element and returns it, which Dim only = Assert.Single(found) captures.

  • Assert.Throws(Of T) requires the exact type, Assert.ThrowsAny(Of T) accepts a subclass.

  • The generic data holder TheoryData(Of Date, Date, Decimal) makes the MemberData source type-checked. The file fills it with Add calls; the collection initializer New TheoryData(Of Date, Date, Decimal) From { {New Date(2026, 10, 1), New Date(2026, 10, 1), 0D} } (one brace group per row) also compiles.

  • Assert.Equal(Of T) on Decimal compares by value, so 0D equals 0.00D.

NUnit

The project

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <RootNamespace>Shelf.Tests.NUnit</RootNamespace>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="NUnit" Version="4.3.2" />
    <PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
    <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
    <PackageReference Include="NSubstitute" Version="5.3.0" />
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <ProjectReference Include="..\Shelf.Core\Shelf.Core.vbproj" />
  </ItemGroup>
</Project>

The tests

Option Strict On

Imports NUnit.Framework
Imports NSubstitute
Imports Microsoft.Extensions.Time.Testing
Imports Shelf.Core

<TestFixture>
Public Class CatalogTests
    Private _catalog As Catalog = Nothing

    <SetUp>
    Public Sub SetUp()
        _catalog = New Catalog(New FakeStore())
        _catalog.Add(New Book("Dune", 1965, Isbn.Parse("9780441172719")))
        _catalog.Add(New Magazine("Byte", 1981, 7))
    End Sub

    <TearDown>
    Public Sub TearDown()
        TestContext.Out.WriteLine("teardown after " & TestContext.CurrentContext.Test.Name)
    End Sub

    <Test>
    Public Sub Search_IgnoresCase()
        Dim found = _catalog.Search("DUN").ToList()
        Assert.That(found, Has.Count.EqualTo(1))
        Assert.That(found(0).Title, [Is].EqualTo("Dune"))
    End Sub

    <Test>
    Public Sub Isbn_RejectsBadCheckDigit()
        Dim ex = Assert.Throws(Of FormatException)(Sub() Isbn.Parse("9780441172718"))
        Assert.That(ex.Message, Does.Contain("not a valid ISBN-13"))
    End Sub

    <TestCase("9780441172719", True)>
    <TestCase("9780441172718", False)>
    <TestCase("not an isbn", False)>
    Public Sub Isbn_ChecksDigit(text As String, expected As Boolean)
        Dim parsed As Isbn = Nothing
        Assert.That(Isbn.TryParse(text, parsed), [Is].EqualTo(expected))
    End Sub

    Public Shared ReadOnly Property FineCases As IEnumerable(Of TestCaseData)
        Get
            Return {
                New TestCaseData(New Date(2026, 10, 1), New Date(2026, 10, 1), 0D),
                New TestCaseData(New Date(2026, 10, 1), New Date(2026, 10, 5), 1D),
                New TestCaseData(New Date(2026, 10, 5), New Date(2026, 10, 1), 0D)
            }
        End Get
    End Property

    <TestCaseSource(NameOf(FineCases))>
    Public Sub Fine_IsQuarterPerLateDay(dueOn As Date, returnedOn As Date, expected As Decimal)
        Assert.That(LoanDesk.ComputeFine(dueOn, returnedOn), [Is].EqualTo(expected))
    End Sub

    <Test>
    <Category("Async")>
    Public Async Function Describe_UsesLookup() As Task
        Dim lookup = Substitute.For(Of IBookLookup)()
        lookup.FindTitleAsync(Arg.Any(Of Isbn)()).Returns(Task.FromResult("Dune"))
        Dim desk As New LoanDesk(lookup, TimeProvider.System)

        Dim text = Await desk.DescribeAsync(Isbn.Parse("9780441172719"))

        Assert.That(text, [Is].EqualTo("Dune"))
        Await lookup.Received(1).FindTitleAsync(Arg.Any(Of Isbn)())
    End Function

    <Test>
    Public Sub Loan_BecomesOverdueWhenClockAdvances()
        Dim clock As New FakeTimeProvider(New DateTimeOffset(2026, 10, 1, 9, 0, 0, TimeSpan.Zero))
        clock.SetLocalTimeZone(TimeZoneInfo.Utc)
        Dim desk As New LoanDesk(Substitute.For(Of IBookLookup)(), clock)
        Dim loan = _catalog.Lend(_catalog.Search("Dune").First(), "Ada", clock.GetLocalNow().Date)

        Assert.That(desk.IsOverdue(loan), [Is].False)
        clock.Advance(TimeSpan.FromDays(15))
        Assert.That(desk.IsOverdue(loan), [Is].True)
    End Sub

    <Test>
    Public Sub Return_Twice_Throws()
        Dim loan = _catalog.Lend(_catalog.Search("Dune").First(), "Ada", #10/1/2026#)
        loan.Return()
        Assert.Throws(Of InvalidOperationException)(Sub() loan.Return())
    End Sub

    <Test>
    Public Sub Describe_PropagatesLookupFailure()
        Dim lookup = Substitute.For(Of IBookLookup)()
        lookup.FindTitleAsync(Arg.Any(Of Isbn)()).Returns(Task.FromException(Of String)(New InvalidOperationException("offline")))
        Dim desk As New LoanDesk(lookup, TimeProvider.System)

        Dim ex = Assert.ThrowsAsync(Of InvalidOperationException)(
            Function() desk.DescribeAsync(Isbn.Parse("9780441172719")))

        Assert.That(ex.Message, [Is].EqualTo("offline"))
    End Sub

    <Test>
    Public Sub Add_ForwardsToStore()
        Dim store = Substitute.For(Of ICatalogStore)()
        Dim catalog As New Catalog(store)
        Dim magazine As New Magazine("Byte", 1981, 7)

        catalog.Add(magazine)

        store.Received(1).Add(magazine)
    End Sub
End Class
dotnet test Shelf.Tests.NUnit
Test run for Shelf.Tests.NUnit.dll (.NETCoreApp,Version=v10.0)
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:    15, Skipped:     0, Total:    15, Duration: 36 ms - Shelf.Tests.NUnit.dll (net10.0)

Notes on the NUnit file:

  • Is is a Visual Basic keyword, so NUnit’s constraint entry point must be written [Is]. Assert.That(x, Is.EqualTo(1)) does not parse; Assert.That(x, [Is].EqualTo(1)) does. Has, Does, Throws and Contains are ordinary identifiers and need no brackets. Likewise [Is].False, [Is].True and [Is].Null in the clock test.

  • NUnit 4 removed the classic assertions: Assert.AreEqual(1, 1) gives BC30456 ('AreEqual' is not a member of 'Assert'.) in a VB project exactly as it does in C#. The ClassicAssert class keeps them for old code, and the constraint model is what current documentation shows.

  • Assert.ThrowsAsync(Of T)(Function() ...) blocks until the task completes and returns the exception, so the test method can stay a Sub.

  • <TestCase> arguments are attribute arguments, so they must be constants (see Data-driven tests), and the shared data source for non-constants is <TestCaseSource(NameOf(...))>.

  • One fixture instance is shared by all the tests of the class, so fields that tests mutate are reset in <SetUp>, which is what _catalog is doing.

Attributes at a glance

Concept MSTest xUnit.net NUnit

A test

<TestMethod>

<Fact>

<Test>

Parameterised test, constant arguments

<TestMethod> + <DataRow(...)>

<Theory> + <InlineData(...)>

<TestCase(...)>

Parameterised test, computed arguments

<DynamicData(NameOf(Source))>

<MemberData(NameOf(Source))>

<TestCaseSource(NameOf(Source))>

Before every test

<TestInitialize>

constructor

<SetUp>

After every test

<TestCleanup>

Dispose or DisposeAsync

<TearDown>

Once per class

<ClassInitialize> / <ClassCleanup> (Shared, take a TestContext for the first)

IClassFixture(Of T), with IAsyncLifetime on the fixture

<OneTimeSetUp> / <OneTimeTearDown>

Once per assembly

<AssemblyInitialize> / <AssemblyCleanup> (Shared)

no attribute (collection fixtures share across classes)

<SetUpFixture> class with <OneTimeSetUp>

Category for filtering

<TestCategory("Async")>

<Trait("Category", "Async")>

<Category("Async")>

Turn parallelism off for a class

<DoNotParallelize>

put the classes in one <Collection>

<NonParallelizable>

Assembly-wide parallel setting

<Assembly: Parallelize(...)>

xunit.runner.json

<Assembly: Parallelizable(...)>

(The Collection, NonParallelizable, xunit.runner.json and NUnit Parallelizable entries describe documented features that this page does not exercise; the other entries were built and run here.)

Assertions

The three assertion libraries do the same job with different vocabulary. The shelf tests above use these:

Check MSTest xUnit.net NUnit

Equality

Assert.AreEqual(expected, actual)

Assert.Equal(expected, actual)

Assert.That(actual, [Is].EqualTo(expected))

Boolean

Assert.IsTrue(x) / IsFalse

Assert.True(x) / False

Assert.That(x, [Is].True)

Null

Assert.IsNull(x) / IsNotNull

Assert.Null(x) / NotNull

Assert.That(x, [Is].Null)

Collection size

Assert.HasCount(1, items)

Assert.Single(items) or Assert.Equal(1, items.Count)

Assert.That(items, Has.Count.EqualTo(1))

Text contains

StringAssert.Contains(text, part)

Assert.Contains(part, text)

Assert.That(text, Does.Contain(part))

Exception

Assert.ThrowsExactly(Of T)(Sub() ...)

Assert.Throws(Of T)(Sub() ...)

Assert.Throws(Of T)(Sub() ...)

Async exception

Await Assert.ThrowsExactlyAsync(Of T)(Function() ...)

Await Assert.ThrowsAsync(Of T)(Function() ...)

Assert.ThrowsAsync(Of T)(Function() ...)

A rule for VB specifically: the lambda given to Throws is a single-line Sub() Isbn.Parse("x") when it is one statement and a multi-line Sub() ... End Sub when it needs several statements; in the async versions it is Function() desk.DescribeAsync(...), a function that returns the task without awaiting it, which is what the framework then awaits. In MSTest 4, Assert.Throws(Of Exception)(Sub() ...) also accepts a derived exception type (built and run).

What a failure looks like

A test that fails is the proof the tool is checking something. The following deliberately wrong tests exist in a scratch copy of the projects, with the same assertions in each framework; the text is the Error Message the runner printed (stack frames are left out because they contain container paths).

' MSTest: the expected value is "Dune", the actual one is "Dune II"
Dim title = String.Concat("Dune", " II")
Assert.AreEqual("Dune", title)
' xUnit:   Assert.Equal("Dune", "Dune II")
' NUnit:   Assert.That("Dune", [Is].EqualTo("Dune II"))   (the actual value comes first, so "Dune" is the actual one here)
MSTest:
  Assert.AreEqual failed. Expected string length 4 but was 7. 'expected' expression: '"Dune"', 'actual' expression: 'title'.
  Expected: "Dune"
  But was:  "Dune II"
  ---------------^

xUnit.net:
  Assert.Equal() Failure: Strings differ
  Expected: "Dune"
  Actual:   "Dune II"
               ↑ (pos 4)

NUnit:
     Assert.That("Dune", [Is].EqualTo("Dune II"))
  Expected string length 7 but was 4. Strings differ at index 4.
  Expected: "Dune II"
  But was:  "Dune"
  ---------------^

A wrong exception type is reported by naming both types, and the inner exception’s message is kept:

MSTest:
  Assert.ThrowsExactly failed. Expected exception type:<System.ArgumentException>. Actual exception type:<System.FormatException>. 'action' expression: 'Sub() Isbn.Parse("x")'.

xUnit.net:
  Assert.Throws() Failure: Exception type was not an exact match
  Expected: typeof(System.ArgumentException)
  Actual:   typeof(System.FormatException)
  ---- System.FormatException : 'x' is not a valid ISBN-13.

NUnit:
  Assert.That(caughtException, expression)
  Expected: <System.ArgumentException>
  But was:  <System.FormatException: 'x' is not a valid ISBN-13.

(The MSTest messages quote the argument expressions of the VB call, 'action' expression: 'Sub() Isbn.Parse("x")', because the assertions take their argument text from the compiler; the caller-info attributes are on Attributes, Caller Info and Reflection.)

Setup, teardown and fixtures

The frameworks differ most in how often setup code runs and how many instances of the test class exist. The next three small classes each have two tests, First and Second, and every hook appends a line to a list that is written to a file when the last hook runs; the files below are what the tests recorded. (The Trace module in each file is a thread-safe list; the three files are Lifecycle.vb in the three test projects.)

Option Strict On

Imports System.IO
Imports Microsoft.VisualStudio.TestTools.UnitTesting

Friend Module Trace
    Friend ReadOnly Steps As New List(Of String)
    Friend Sub Add(text As String)
        SyncLock Steps
            Steps.Add(text)
        End SyncLock
    End Sub
End Module

<TestClass>
<DoNotParallelize>
Public Class LifecycleTests
    Public Sub New()
        Trace.Add("constructor")
    End Sub

    <AssemblyInitialize>
    Public Shared Sub AssemblyStart(context As TestContext)
        Trace.Add("AssemblyInitialize")
    End Sub

    <ClassInitialize>
    Public Shared Sub ClassStart(context As TestContext)
        Trace.Add("ClassInitialize")
    End Sub

    <TestInitialize>
    Public Sub BeforeEach()
        Trace.Add("TestInitialize")
    End Sub

    <TestMethod>
    Public Sub First()
        Trace.Add("First")
    End Sub

    <TestMethod>
    Public Sub Second()
        Trace.Add("Second")
    End Sub

    <TestCleanup>
    Public Sub AfterEach()
        Trace.Add("TestCleanup")
    End Sub

    <ClassCleanup>
    Public Shared Sub ClassEnd()
        Trace.Add("ClassCleanup")
    End Sub

    <AssemblyCleanup>
    Public Shared Sub AssemblyEnd()
        Trace.Add("AssemblyCleanup")
        File.WriteAllLines(Path.Combine(AppContext.BaseDirectory, "lifecycle-mstest.txt"), Trace.Steps)
    End Sub
End Class
AssemblyInitialize
ClassInitialize
constructor
TestInitialize
First
TestCleanup
constructor
TestInitialize
Second
TestCleanup
ClassCleanup
AssemblyCleanup
Option Strict On

Imports System.IO
Imports Xunit

Friend Module Trace
    Friend ReadOnly Steps As New List(Of String)
    Friend Sub Add(text As String)
        SyncLock Steps
            Steps.Add(text)
        End SyncLock
    End Sub
End Module

Public Class LifecycleFixture
    Implements IAsyncLifetime, IDisposable

    Public Sub New()
        Trace.Add("fixture constructor")
    End Sub

    Public Function InitializeAsync() As Task Implements IAsyncLifetime.InitializeAsync
        Trace.Add("fixture InitializeAsync")
        Return Task.CompletedTask
    End Function

    Public Function DisposeAsync() As Task Implements IAsyncLifetime.DisposeAsync
        Trace.Add("fixture DisposeAsync")
        Return Task.CompletedTask
    End Function

    Public Sub Dispose() Implements IDisposable.Dispose
        Trace.Add("fixture Dispose")
        File.WriteAllLines(Path.Combine(AppContext.BaseDirectory, "lifecycle-xunit.txt"), Trace.Steps)
    End Sub
End Class

Public Class LifecycleTests
    Implements IClassFixture(Of LifecycleFixture), IDisposable

    Public Sub New(fixture As LifecycleFixture)
        Trace.Add("test class constructor")
    End Sub

    <Fact>
    Public Sub First()
        Trace.Add("First")
    End Sub

    <Fact>
    Public Sub Second()
        Trace.Add("Second")
    End Sub

    Public Sub Dispose() Implements IDisposable.Dispose
        Trace.Add("test class Dispose")
    End Sub
End Class
fixture constructor
fixture InitializeAsync
test class constructor
First
test class Dispose
test class constructor
Second
test class Dispose
fixture DisposeAsync
fixture Dispose
Option Strict On

Imports System.IO
Imports NUnit.Framework

Friend Module Trace
    Friend ReadOnly Steps As New List(Of String)
    Friend Sub Add(text As String)
        SyncLock Steps
            Steps.Add(text)
        End SyncLock
    End Sub
End Module

<SetUpFixture>
Public Class AssemblyHooks
    <OneTimeSetUp>
    Public Sub Start()
        Trace.Add("SetUpFixture OneTimeSetUp")
    End Sub

    <OneTimeTearDown>
    Public Sub [End]()
        Trace.Add("SetUpFixture OneTimeTearDown")
        File.WriteAllLines(Path.Combine(AppContext.BaseDirectory, "lifecycle-nunit.txt"), Trace.Steps)
    End Sub
End Class

<TestFixture>
Public Class LifecycleTests
    Public Sub New()
        Trace.Add("constructor")
    End Sub

    <OneTimeSetUp>
    Public Sub ClassStart()
        Trace.Add("OneTimeSetUp")
    End Sub

    <SetUp>
    Public Sub BeforeEach()
        Trace.Add("SetUp")
    End Sub

    <Test>
    Public Sub First()
        Trace.Add("First")
    End Sub

    <Test>
    Public Sub Second()
        Trace.Add("Second")
    End Sub

    <TearDown>
    Public Sub AfterEach()
        Trace.Add("TearDown")
    End Sub

    <OneTimeTearDown>
    Public Sub ClassEnd()
        Trace.Add("OneTimeTearDown")
    End Sub
End Class
SetUpFixture OneTimeSetUp
constructor
OneTimeSetUp
SetUp
First
TearDown
SetUp
Second
TearDown
OneTimeTearDown
SetUpFixture OneTimeTearDown

What the three traces show:

MSTest xUnit.net NUnit

Instances of the test class

two: the constructor runs before every test

two: the constructor and Dispose bracket every test

one: the constructor ran once, SetUp and TearDown ran twice

Once-per-class hook

ClassInitialize before the first test, ClassCleanup after the last

the fixture (IClassFixture) is constructed before the first test class instance and disposed after the last

OneTimeSetUp and OneTimeTearDown

Once-per-assembly hook

AssemblyInitialize first, AssemblyCleanup last

no attribute; the fixture of a collection or a custom runner extension

SetUpFixture wraps the whole namespace

Order of the per-test hooks

constructor, TestInitialize, test, TestCleanup

constructor, test, Dispose

SetUp, test, TearDown

Async hooks

Task-returning hooks are supported (not built here)

IAsyncLifetime on the class or the fixture (built below)

Task-returning hooks are supported (not built here)

The practical rules that follow:

  • Do not keep state in fields and expect it to survive a test in MSTest and xUnit: each test gets a new instance, so state shared between tests must go in a fixture, a Shared field or a static helper (and that state must be thread-safe, because tests may run in parallel).

  • In NUnit, do reset any field a test mutates in <SetUp>, because the instance is shared. A test class that passes alone and fails after another is the symptom.

  • The trace of the MSTest class has <DoNotParallelize> on it. Without it the MSTest template’s assembly attribute runs the methods of one class in parallel, and the lines of First and Second interleave (the same constructor, TestInitialize and TestCleanup lines appear next to each other). Hooks per test must not touch shared state unless that state is thread-safe.

  • A Shared method in VB is the equivalent of C#'s static, so <ClassInitialize> and <AssemblyInitialize> methods are Public Shared Sub Name(context As TestContext). Leaving out Shared is an analyzer error in the MSTest project (real message: MSTEST0010: ClassInitialize method 'NotShared' signature is invalid).

Fixtures with an async lifetime

xUnit’s IAsyncLifetime is the place for asynchronous setup. In xUnit v2 its two methods return Task; in xUnit v3 they return ValueTask, and VB cannot write an Async Function that returns ValueTask (BC36945: The 'Async' modifier can only be used on Subs, or on Functions that return Task or Task(Of T).). The v3 form therefore returns the ValueTask of an existing task, or ValueTask.CompletedTask, from an ordinary Function:

Option Strict On

Imports System.IO
Imports Xunit

Public Class Life
    Implements IAsyncLifetime

    Private ReadOnly _path As String = Path.Combine(Path.GetTempPath(), "shelf-life.txt")

    Public Function InitializeAsync() As ValueTask Implements IAsyncLifetime.InitializeAsync
        Return New ValueTask(File.WriteAllTextAsync(_path, "ready"))
    End Function

    Public Function DisposeAsync() As ValueTask Implements IAsyncDisposable.DisposeAsync
        File.Delete(_path)
        Return ValueTask.CompletedTask
    End Function

    <Fact>
    Public Async Function File_ExistsDuringTest() As Task
        Dim text = Await File.ReadAllTextAsync(_path, TestContext.Current.CancellationToken)
        Assert.Equal("ready", text)
    End Function
End Class

DisposeAsync is declared on IAsyncDisposable, so the Implements clause names that interface. The class is part of the xUnit v3 project described in Microsoft.Testing.Platform and xUnit v3, where it passes.

Data-driven tests

One test method, many inputs. The attribute that carries the data differs, but the shelf scenarios are the same: three ISBN strings with an expected result, and three loan date pairs with an expected fine.

Constant data

DataRow, InlineData and TestCase put the values in the attribute, so they must be constants. Date and Decimal are not valid attribute-argument types in .NET, and the VB compiler reports it as a conversion error. The real messages for a Date and a Decimal argument in <DataRow>:

error BC30934: Conversion from 'Date' to 'Object' cannot occur in a constant expression used as an argument to an attribute.
error BC30934: Conversion from 'Decimal' to 'Object' cannot occur in a constant expression used as an argument to an attribute.

Strings, Integer, Long, Double, Boolean, Char, enumeration values and Type (through GetType) are fine, which is what the ISBN rows use:

    <TestMethod>
    <DataRow("9780441172719", True)>
    <DataRow("9780441172718", False)>
    <DataRow("not an isbn", False)>
    Public Sub Isbn_ChecksDigit(text As String, expected As Boolean)

(This is the MSTest spelling from CatalogTests.vb; the xUnit one is <Theory> with <InlineData>, the NUnit one <TestCase> without a method attribute.)

Computed data

For Date and Decimal values, objects, or anything that needs New, use a source: a Public Shared property or method of the class that the framework calls. MSTest returns IEnumerable(Of Object()), xUnit prefers TheoryData(Of ...), and NUnit returns TestCaseData objects. The three sources of the fine cases, copied from the files above:

    ' MSTest
    Public Shared ReadOnly Property FineCases As IEnumerable(Of Object())
        Get
            Return {
                New Object() {New Date(2026, 10, 1), New Date(2026, 10, 1), 0D},
                New Object() {New Date(2026, 10, 1), New Date(2026, 10, 5), 1D},
                New Object() {New Date(2026, 10, 5), New Date(2026, 10, 1), 0D}
            }
        End Get
    End Property

    <TestMethod>
    <DynamicData(NameOf(FineCases))>
    Public Sub Fine_IsQuarterPerLateDay(dueOn As Date, returnedOn As Date, expected As Decimal)

Using NameOf(FineCases) instead of the string "FineCases" makes a rename refactoring keep the link. The name lookup is by the simple name of the member, so the source must be in the same class unless the attribute names the declaring type.

What the test runner calls them

The display names of the cases come from the argument values and are the only way to tell failing rows apart. dotnet test --list-tests prints them (real output; the container uses the invariant culture, which formats dates as month/day/year):

MSTest
The following Tests are available:
    Search_IgnoresCase
    Isbn_RejectsBadCheckDigit
    Isbn_ChecksDigit ("9780441172719",True)
    Isbn_ChecksDigit ("9780441172718",False)
    Isbn_ChecksDigit ("not an isbn",False)
    Fine_IsQuarterPerLateDay (10/01/2026 00:00:00,10/01/2026 00:00:00,0)
    Fine_IsQuarterPerLateDay (10/01/2026 00:00:00,10/05/2026 00:00:00,1)
    Fine_IsQuarterPerLateDay (10/05/2026 00:00:00,10/01/2026 00:00:00,0)
    Describe_UsesLookup
    Loan_BecomesOverdueWhenClockAdvances
    Return_Twice_Throws
    Describe_PropagatesLookupFailure
    Add_ForwardsToStore
    First
    Second
xUnit.net
The following Tests are available:
    Shelf.Tests.XUnit.CatalogTests.Search_IgnoresCase
    Shelf.Tests.XUnit.CatalogTests.Isbn_RejectsBadCheckDigit
    Shelf.Tests.XUnit.CatalogTests.Isbn_ChecksDigit(text: "9780441172719", expected: True)
    Shelf.Tests.XUnit.CatalogTests.Isbn_ChecksDigit(text: "9780441172718", expected: False)
    Shelf.Tests.XUnit.CatalogTests.Isbn_ChecksDigit(text: "not an isbn", expected: False)
    Shelf.Tests.XUnit.CatalogTests.Fine_IsQuarterPerLateDay(dueOn: 2026-10-01T00:00:00.0000000, returnedOn: 2026-10-01T00:00:00.0000000, expected: 0)
    Shelf.Tests.XUnit.CatalogTests.Fine_IsQuarterPerLateDay(dueOn: 2026-10-01T00:00:00.0000000, returnedOn: 2026-10-05T00:00:00.0000000, expected: 1)
    Shelf.Tests.XUnit.CatalogTests.Fine_IsQuarterPerLateDay(dueOn: 2026-10-05T00:00:00.0000000, returnedOn: 2026-10-01T00:00:00.0000000, expected: 0)
    Shelf.Tests.XUnit.CatalogTests.Describe_UsesLookup
    Shelf.Tests.XUnit.CatalogTests.Loan_BecomesOverdueWhenClockAdvances
    Shelf.Tests.XUnit.CatalogTests.Return_Twice_Throws
    Shelf.Tests.XUnit.CatalogTests.Describe_PropagatesLookupFailure
    Shelf.Tests.XUnit.CatalogTests.Add_ForwardsToStore
    Shelf.Tests.XUnit.LifecycleTests.First
    Shelf.Tests.XUnit.LifecycleTests.Second
NUnit
The following Tests are available:
    Add_ForwardsToStore
    Describe_PropagatesLookupFailure
    Describe_UsesLookup
    Fine_IsQuarterPerLateDay(10/01/2026 00:00:00,10/01/2026 00:00:00,0m)
    Fine_IsQuarterPerLateDay(10/01/2026 00:00:00,10/05/2026 00:00:00,1m)
    Fine_IsQuarterPerLateDay(10/05/2026 00:00:00,10/01/2026 00:00:00,0m)
    Isbn_ChecksDigit("9780441172719",True)
    Isbn_ChecksDigit("9780441172718",False)
    Isbn_ChecksDigit("not an isbn",False)
    Isbn_RejectsBadCheckDigit
    Loan_BecomesOverdueWhenClockAdvances
    Return_Twice_Throws
    Search_IgnoresCase
    First
    Second

Three differences stand out: xUnit shows parameter names (text: "9780441172719") and formats dates in ISO 8601; MSTest and NUnit show only the values; NUnit appends m to decimals. They matter when a filter or a CI report refers to one row of a theory.

Async tests

A test that awaits is an Async Function ... As Task, and the three frameworks await the returned task. All three test files contain Describe_UsesLookup and Describe_PropagatesLookupFailure; the MSTest ones are shown above in the full listing.

Async Sub is the trap. A VB Async Sub compiles to async void, which no runner can await, so exceptions can escape the test or be lost. The three frameworks react differently, and the real results (from the scratch copy with three Async Sub tests that call Assert.Fail after an Await):

MSTest (build fails, because the analyzers are errors under TreatWarningsAsErrors):
  Failing.vb(15,22): error MSTEST0003: Test method 'AsyncSub_Test' signature is invalid
  Failing.vb(17,9): error MSTEST0040: Do not assert inside 'async void' methods, local functions, or lambdas because they may not fail the test

NUnit (the test is reported as failed at run time):
  Failed AsyncSub_Test [< 1 ms]
  Error Message:
   Async test method must have non-void return type

xUnit.net 2.9.3 (the test ran and its assertion failure was reported, but the framework does not support the pattern; no diagnostic was shown for the VB code):
  Failed Shelf.Tests.XUnit.Failing.AsyncSub_Test [4 ms]
  Error Message:
   never reported reliably

Rule: write Public Async Function Name() As Task for every async test and never Async Sub, even where it appears to work. The analyzers that catch this are shipped with the MSTest package; the xUnit analyzers did not report it for VB in this experiment.

The async/await model itself, including why a test host has no synchronization context, is on Async and Await.

Test doubles

A test double replaces a collaborator so the test controls it and can check how it was used. Visual Basic works with the two mocking libraries .NET developers use, because both are built on expression trees and generic methods that VB supports. The tests above use Moq in the MSTest project and NSubstitute in the xUnit and NUnit projects; any combination works.

Hand-written fakes

FakeStore above is a hand-written fake: a working in-memory implementation of ICatalogStore. Prefer it when the interface is small and several tests need real behaviour, because it has no setup syntax to learn and no mocking-library dependency. The solution’s own InMemoryCatalogStore (in Shelf.Data) is the same idea.

Moq in VB

The expressions Moq reads are VB lambdas, Function(l) l.FindTitleAsync(...) for a method with a result and a single-line Sub(s) s.Add(magazine) for a method without. The extracts from the MSTest file:

        Dim lookup As New Mock(Of IBookLookup)()
        lookup.Setup(Function(l) l.FindTitleAsync(It.IsAny(Of Isbn)())).ReturnsAsync("Dune")
        Dim desk As New LoanDesk(lookup.Object, TimeProvider.System)
        ...
        lookup.Verify(Function(l) l.FindTitleAsync(It.IsAny(Of Isbn)()), Times.Once)
        ...
        store.Verify(Sub(s) s.Add(magazine), Times.Once)
        store.VerifyNoOtherCalls()

VB-specific points that these tests rely on and that were verified:

  • It.IsAny(Of Isbn)() is written with parentheses here; VB also accepts It.IsAny(Of CatalogItem) without them for a parameterless method (built and run), so both forms work.

  • Moq’s Setup/Verify for a method that returns nothing take Expression(Of Action(Of T)); the single-line Sub lambda converts to an expression tree. A multi-line Sub lambda is a statement lambda and cannot: the compiler reports BC36675 (Statement lambdas cannot be converted to expression trees.), so keep the expression to one call per lambda.

  • Mock.Of(Of IBookLookup)() creates a mock with default behaviour in one expression, which is what the clock test uses when the lookup is irrelevant.

  • ReturnsAsync and ThrowsAsync (from Moq) wrap results in a completed or faulted Task. The test Describe_PropagatesLookupFailure shows ThrowsAsync(New InvalidOperationException("offline")).

  • The mocked type must be an interface or a class with Overridable members: a VB member that is not Overridable is not virtual and cannot be mocked, the same restriction that C# has for non-virtual.

NSubstitute in VB

NSubstitute needs no lambdas for most checks, so it reads naturally in VB. From the xUnit file:

        Dim lookup = Substitute.For(Of IBookLookup)()
        lookup.FindTitleAsync(Arg.Any(Of Isbn)()).Returns(Task.FromResult("Dune"))
        ...
        Await lookup.Received(1).FindTitleAsync(Arg.Any(Of Isbn)())
        ...
        lookup.FindTitleAsync(Arg.Any(Of Isbn)()).Returns(Task.FromException(Of String)(New InvalidOperationException("offline")))
        ...
        store.Received(1).Add(magazine)

Received(1) returns the substitute in "verify" mode, and the next call on it is the call being checked.

Both libraries are pinned to the versions of the tables above (Moq 4.20.72, NSubstitute 5.3.0). They are open-source projects with their own release cycles; pin versions and read the project’s release notes before upgrading.

Faking time with TimeProvider

System.TimeProvider (since .NET 8) is the way to inject the clock. The Microsoft.Extensions.TimeProvider.Testing package provides FakeTimeProvider, whose clock only moves when the test says so:

    <Fact>
    Public Sub Loan_BecomesOverdueWhenClockAdvances()
        Dim clock As New FakeTimeProvider(New DateTimeOffset(2026, 10, 1, 9, 0, 0, TimeSpan.Zero))
        clock.SetLocalTimeZone(TimeZoneInfo.Utc)
        Dim desk As New LoanDesk(Substitute.For(Of IBookLookup)(), clock)
        Dim loan = _catalog.Lend(_catalog.Search("Dune").First(), "Ada", clock.GetLocalNow().Date)

        Assert.False(desk.IsOverdue(loan))
        clock.Advance(TimeSpan.FromDays(15))
        Assert.True(desk.IsOverdue(loan))
    End Sub

SetLocalTimeZone(TimeZoneInfo.Utc) makes GetLocalNow() independent of the machine’s time zone, so the test gives the same result on a developer laptop and a CI agent. Clock classes of older code (DateTime.Now called directly) must be wrapped first; the same technique appears on Dates, Times and Formatting.

Testing internals with InternalsVisibleTo

Friend is VB’s internal. LoanDesk.ComputeFine is Friend Shared and the tests call it directly, which works because Shelf.Core.vbproj lists the three test assemblies as friends:

  <ItemGroup>
    <InternalsVisibleTo Include="Shelf.Tests.MSTest" />
    <InternalsVisibleTo Include="Shelf.Tests.XUnit" />
    <InternalsVisibleTo Include="Shelf.Tests.NUnit" />
  </ItemGroup>

The SDK turns each InternalsVisibleTo item into the assembly attribute when it generates the assembly info, and this works in VB projects. The attribute form is equivalent and also works (a file AssemblyInfo.vb in the library, built and tested):

Imports System.Runtime.CompilerServices

<Assembly: InternalsVisibleTo("Shelf.Tests.XUnit")>

Without either, the member is invisible to the test assembly and the compiler says so as if the member did not exist (real message, xUnit project, the InternalsVisibleTo items removed):

CatalogTests.vb(80,32): error BC30456: 'ComputeFine' is not a member of 'LoanDesk'.

Use it sparingly: a test that reaches into internals pins the implementation. It is justified for pure helpers, parsers and anything that would otherwise have to be made Public only for the tests. A strong-named library requires the public key in the attribute (InternalsVisibleTo("Tests, PublicKey=...")).

Running tests: dotnet test, filters and reports

dotnet test builds the project, starts the runner for its framework and prints a summary. The shelf solution can be run as a whole (the solution file lists the library and the three test projects; the three runs happen in parallel, so their lines interleave):

dotnet test Shelf.slnx
  Shelf.Core -> /w/Shelf.Core/bin/Debug/net10.0/Shelf.Core.dll
  Shelf.Tests.NUnit -> /w/Shelf.Tests.NUnit/bin/Debug/net10.0/Shelf.Tests.NUnit.dll
  Shelf.Tests.XUnit -> /w/Shelf.Tests.XUnit/bin/Debug/net10.0/Shelf.Tests.XUnit.dll
Test run for /w/Shelf.Tests.NUnit/bin/Debug/net10.0/Shelf.Tests.NUnit.dll (.NETCoreApp,Version=v10.0)
  Shelf.Tests.MSTest -> /w/Shelf.Tests.MSTest/bin/Debug/net10.0/Shelf.Tests.MSTest.dll
Test run for /w/Shelf.Tests.XUnit/bin/Debug/net10.0/Shelf.Tests.XUnit.dll (.NETCoreApp,Version=v10.0)
Test run for /w/Shelf.Tests.MSTest/bin/Debug/net10.0/Shelf.Tests.MSTest.dll (.NETCoreApp,Version=v10.0)
A total of 1 test files matched the specified pattern.
A total of 1 test files matched the specified pattern.
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:    15, Skipped:     0, Total:    15, Duration: 65 ms - Shelf.Tests.MSTest.dll (net10.0)

Passed!  - Failed:     0, Passed:    15, Skipped:     0, Total:    15, Duration: 63 ms - Shelf.Tests.NUnit.dll (net10.0)

Passed!  - Failed:     0, Passed:    15, Skipped:     0, Total:    15, Duration: 67 ms - Shelf.Tests.XUnit.dll (net10.0)

In the VSTest mode shown here, the project is a positional argument. The --project option belongs to the other mode (see below): with the VSTest runner, dotnet test --project Shelf.Tests.MSTest fails with MSB1001 (Unknown switch.).

Filters

--filter takes an expression over properties of the test. The property names differ per adapter, which is the usual source of a filter that silently runs zero tests:

Filter on MSTest xUnit.net NUnit

Category

TestCategory=Async

Category=Async (the trait named Category)

TestCategory=Async

Name

Name~Isbn or FullyQualifiedName~Isbn

FullyQualifiedName~Isbn

FullyQualifiedName~Isbn or Name~Isbn

The = operator is equality, ~ is "contains", != and !~ negate, & and | combine. Real runs of the filters (each prints one summary line):

dotnet test Shelf.Tests.MSTest --filter "TestCategory=Async"
Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: 31 ms - Shelf.Tests.MSTest.dll (net10.0)

dotnet test Shelf.Tests.XUnit --filter "Category=Async"
Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: 26 ms - Shelf.Tests.XUnit.dll (net10.0)

dotnet test Shelf.Tests.NUnit --filter "TestCategory=Async"
Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: 27 ms - Shelf.Tests.NUnit.dll (net10.0)

dotnet test Shelf.Tests.NUnit --filter "FullyQualifiedName~Isbn"
Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4, Duration: 6 ms - Shelf.Tests.NUnit.dll (net10.0)

Name filters were run in all three projects (FullyQualifiedName~Isbn in xUnit, MSTest and NUnit, and Name~Isbn in MSTest and NUnit) and each selected the same four tests, the three rows of Isbn_ChecksDigit and Isbn_RejectsBadCheckDigit. --list-tests (shown under What the test runner calls them) prints the names without running anything, and --logger "console;verbosity=normal" or detailed prints a line for every test and the captured output.

Parallelism

Test frameworks run tests in parallel by default or by configuration, and parallel tests cannot share mutable state:

  • MSTest: the assembly attribute from the template, <Assembly: Parallelize(Workers:=0, Scope:=ExecutionScope.MethodLevel)>, runs methods in parallel with as many workers as processors. The analyzer MSTEST0001 requires an explicit decision, either Parallelize or DoNotParallelize, and it is an error in a project that treats warnings as errors.

  • xUnit.net: test classes are different collections and run in parallel with each other, while the tests of one class run one after another; Collection groups classes that must not overlap.

  • NUnit: no parallelism unless the assembly or a fixture asks for it.

A static field, a file in a temporary folder with a fixed name, an environment variable or a My.Settings value are the usual victims; the cure is a unique name per test, an injected dependency or a fixture.

Analyzers and what they do in VB

Each framework ships Roslyn analyzers that look for common test mistakes. In VB the experience is uneven, and it is worth knowing what is really checked:

  • MSTest. The MSTest package includes the MSTest analyzers, and they run for VB. With warnings as errors, the first build of the MSTest project here failed twice before the test could run, with the real messages:

    vbc : error MSTEST0001: Explicitly enable or disable tests parallelization
    CatalogTests.vb(48,9): error MSTEST0037: Use 'Assert.HasCount' instead of 'Assert.AreEqual'

    Other rules that were hit by deliberately wrong tests: MSTEST0003 (invalid test method signature), MSTEST0040 (assert inside an async void method), MSTEST0025 (use Assert.Fail instead of an always-failing assertion) and MSTEST0032 (an assertion that is always true). Fixing them is quick, and the rule pages explain each one.

  • xUnit.net. The xunit package carries xunit.analyzers; the experiment above with Async Sub shows that at least some of its checks are not applied to VB. Do not rely on it for VB code.

  • NUnit. NUnit.Analyzers is in the VB template. In the experiments here it did not report anything for the wrong code, and the compiler reported the removed classic assertions instead.

The generic .NET analyzers and the .editorconfig style rules apply to test code like any other code; they are on XML Documentation, Analyzers and Code Style.

Code coverage

Code coverage tells which lines the tests executed, not whether they asserted anything useful. The xUnit and NUnit templates already reference coverlet.collector; the data collector is switched on with a single argument, and the result is a Cobertura XML file:

dotnet test Shelf.Tests.XUnit --collect:"XPlat Code Coverage" --results-directory ./cov
Passed!  - Failed:     0, Passed:    15, Skipped:     0, Total:    15, Duration: 46 ms - Shelf.Tests.XUnit.dll (net10.0)

Attachments:
  cov/172e4e43-f3e8-428d-abcb-e1db0ddccbd5/coverage.cobertura.xml

The root element of the report summarises the run (lines 89 of 123 covered, branches 24 of 32):

<coverage line-rate="0.7234999999999999" branch-rate="0.75" version="1.9" lines-covered="89" lines-valid="123" branches-covered="24" branches-valid="32">

and one <class> element per type gives the per-class rates. The per-class line and branch rates, extracted from that file, for the library under test:

Class Line rate Branch rate

Shelf.Core.Isbn

72.7 %

77.8 %

Shelf.Core.Author

0 %

100 % (no branches)

Shelf.Core.CatalogItem

57.1 %

100 %

Shelf.Core.Book

50.0 %

100 %

Shelf.Core.Magazine

57.1 %

100 %

Shelf.Core.Loan

100 %

75.0 %

Shelf.Core.LoanEventArgs

0 %

100 % (no branches)

Shelf.Core.Catalog

100 %

100 %

Shelf.Core.LoanDesk

100 %

100 %

Shelf.Core.LoanDesk/VB$StateMachine_3_DescribeAsync

100 %

50.0 %

Two VB-specific observations: the compiler-generated state machine of an Async Function appears as its own class, LoanDesk/VB$StateMachine_3_DescribeAsync, and counts its own branches; and a class that no test touches, such as Author, reports 0 % lines but 100 % branches, because it has no branches at all, so read both numbers. The tests here cover the catalog, loan and clock logic, not the book and author properties, which is exactly what the report says.

To turn the Cobertura XML into an HTML report, or to merge several of them, use the open-source ReportGenerator tool (dotnet tool install -g dotnet-reportgenerator-globaltool, not run for this page). Microsoft’s coverage guide also describes other collectors, such as the dotnet-coverage tool and the Visual Studio coverage feature, which this page does not run.

Microsoft.Testing.Platform and xUnit v3

VSTest is the original .NET test platform: dotnet test starts a separate vstest host that loads the test assembly and an adapter. Microsoft.Testing.Platform (MTP) is the newer, lighter alternative in which the test project is an executable that runs the tests itself. MSTest, xUnit v3 and NUnit (through its MTP runner) support it, and the .NET SDK can drive both through dotnet test.

flowchart LR A[dotnet test] --> B{runner chosen by global.json} B -->|default| C[VSTest host loads the test dll and an adapter] B -->|Microsoft.Testing.Platform| D[the test project runs as an exe and reports back] C --> E[Passed or Failed line and logger output] D --> E D --> F[direct: dotnet run or the built exe]

An MSTest project on MTP

An MTP test project is a VB project with OutputType Exe. For MSTest it is enabled by two properties; the build then generates the entry point, which works for VB too (no Sub Main is needed or allowed):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <RootNamespace>Shelf.Tests.Mtp</RootNamespace>
    <OutputType>Exe</OutputType>
    <EnableMSTestRunner>true</EnableMSTestRunner>
    <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="MSTest" Version="4.0.2" />
    <ProjectReference Include="..\Shelf.Core\Shelf.Core.vbproj" />
  </ItemGroup>
</Project>
Option Strict On

Imports Microsoft.VisualStudio.TestTools.UnitTesting
Imports Shelf.Core

<TestClass>
Public Class IsbnTests
    <TestMethod>
    <DataRow("9780441172719", True)>
    <DataRow("9780441172718", False)>
    Public Sub Isbn_ChecksDigit(text As String, expected As Boolean)
        Dim parsed As Isbn = Nothing
        Assert.AreEqual(expected, Isbn.TryParse(text, parsed))
    End Sub
End Class

The project builds with 0 warnings. As an executable it can be started directly, and it understands the MTP options (real output; the long container paths are shortened):

dotnet run --project Shelf.Tests.Mtp
Test Parallelization enabled for Shelf.Tests.Mtp.dll (Workers: 10, Scope: MethodLevel)

Test run summary: Passed! - Shelf.Tests.Mtp.dll (net10.0|arm64)
  total: 2
  failed: 0
  succeeded: 2
  skipped: 0
  duration: 136ms
dotnet run --project Shelf.Tests.Mtp --no-build -- --list-tests
  Isbn_ChecksDigit ("9780441172719",True)
  Isbn_ChecksDigit ("9780441172718",False)

Test discovery summary: found 2 test(s) - Shelf.Tests.Mtp.dll (net10.0|arm64)
  duration: 62ms

Options go after the -- that ends the own arguments of dotnet run, and extensions add more: --report-trx --results-directory <dir> wrote a .trx file in a run here, with no extra package needed for MSTest.

dotnet test with the MTP runner

With the .NET 10 SDK the runner is chosen by global.json in the solution folder, not by a command-line switch:

{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}
dotnet test --project Shelf.Tests.Mtp
Running tests from Shelf.Tests.Mtp.dll (net10.0|arm64)
Shelf.Tests.Mtp.dll (net10.0|arm64) passed (98ms)

Test run summary: Passed!
  total: 2
  failed: 0
  succeeded: 2
  skipped: 0
  duration: 215ms

(Here --project is the right switch.) The mode is all or nothing: pointing it at a VSTest project of the same folder is refused, and the message names the project:

global.json defines test runner to be Microsoft.Testing.Platform. All projects must use that test runner.
The following test projects are using VSTest test runner:
Shelf.Tests.NUnit.vbproj

So a solution moves to MTP as a whole (or stays on VSTest); the migration guide linked below lists the steps per framework.

xUnit v3 from VB

xUnit.net v3 (xunit.v3) is MTP-based: the test project is an executable. There is no VB template for it, but a hand-made VB project works. The project file needs OutputType Exe and the xunit.v3 package:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <RootNamespace>Shelf.Tests.XUnit3</RootNamespace>
    <OutputType>Exe</OutputType>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="xunit.v3" Version="3.1.0" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <ProjectReference Include="..\Shelf.Core\Shelf.Core.vbproj" />
  </ItemGroup>
</Project>
Option Strict On

Imports Xunit
Imports Shelf.Core

Public Class IsbnTests
    <Theory>
    <InlineData("9780441172719", True)>
    <InlineData("9780441172718", False)>
    Public Sub Isbn_ChecksDigit(text As String, expected As Boolean)
        Dim parsed As Isbn = Nothing
        Assert.Equal(expected, Isbn.TryParse(text, parsed))
    End Sub

    <Fact>
    Public Async Function Delay_Completes() As Task
        Await Task.Delay(1, TestContext.Current.CancellationToken)
        Assert.True(True)
    End Function
End Class

The project builds with 0 warnings and 0 errors, and the compiler-generated entry point works for VB. It runs both as an executable and under VSTest-mode dotnet test (Microsoft.NET.Test.Sdk and xunit.runner.visualstudio are in the project for that), with the four tests (the two ISBN rows, Delay_Completes, and File_ExistsDuringTest from Fixtures with an async lifetime):

dotnet run --project Shelf.Tests.XUnit3 --no-build
xUnit.net v3 In-Process Runner v3.1.0+03a071627b (64-bit .NET 10.0.13)
  Discovering: Shelf.Tests.XUnit3
  Discovered:  Shelf.Tests.XUnit3
  Starting:    Shelf.Tests.XUnit3
  Finished:    Shelf.Tests.XUnit3 (ID = 'a5953e00080a1de874820174e30c6a88e809fa67b3c19adcdbfcbf5d5caae29e')
=== TEST EXECUTION SUMMARY ===
   Shelf.Tests.XUnit3  Total: 4, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 0.043s

Differences from v2 that matter in VB:

  • IAsyncLifetime methods return ValueTask, so they cannot be Async Function (BC36945, see above).

  • The async test method can use TestContext.Current.CancellationToken, as Delay_Completes and Life.vb do.

  • The v2 pattern of injecting Xunit.Abstractions.ITestOutputHelper is not what v3 uses; check the xUnit v3 migration notes before moving a v2 suite.

The .NET 11 SDK is a release candidate on the check date (11.0.100-rc.2) and its dotnet new list -lang VB shows the same three VB test templates as .NET 10: it has no xUnit v3 VB template yet. Whether a final .NET 11 adds one should be read from the release notes; this page does not claim it.

Running the tests in CI

The CI step is the same command that runs locally. A GitHub Actions job that restores, builds with warnings as errors and runs the three test projects with coverage looks like this (a typical file, not executed for this page; it assumes a global.json that pins the SDK version, and the dotnet test options in it were run above except the trx logger):

name: tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          global-json-file: global.json
      - run: dotnet test Shelf.slnx --collect:"XPlat Code Coverage" --results-directory ./cov --logger trx
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: |
            cov/**/coverage.cobertura.xml
            **/TestResults/*.trx

Points for CI with VB:

  • Use ubuntu-latest unless the solution has Windows Forms or WPF projects; the test projects themselves are cross-platform, and the shelf’s Windows-only projects only build with EnableWindowsTargeting set.

  • Treat warnings as errors in the test projects as well; the MSTest analyzers are only useful if they fail the build.

  • Pin the .NET SDK with global.json so CI and developers run the same MSBuild and compiler (see Projects, MSBuild and the dotnet CLI).

  • Publish the TRX and Cobertura files with if: always() so a failing run still has a report.

  • A job that runs the unit tests of the library before it packs it is the first stage of the pipeline described on Libraries, Packaging and Deployment.

Practices that work well in VB

  • Name tests for behaviour: Isbn_RejectsBadCheckDigit, Return_Twice_Throws. VB identifiers are case-insensitive, so two test methods that differ only in case are a duplicate-definition error, and a local named like a type or member can hide it silently (see Variables, Constants and Scope).

  • Arrange, act, assert with blank lines between the parts, as the tests above do. One logical assertion per test; several Assert calls about the same result are fine.

  • Test through the public API; use InternalsVisibleTo for the few helpers that deserve their own tests.

  • Avoid default instances and My in code that must be tested: My.Settings and default form instances are global state (see The My Namespace).

  • Keep the VB runtime functions out of the logic where a test needs control: Now, Rnd, Dir and InputBox are untestable; TimeProvider, Random instances with a seed and interfaces are testable.

  • Test the error paths with the exact type: ThrowsExactly or Throws ensure that the failure is the one you meant, not a NullReferenceException from an earlier bug (exceptions are on Structured Exception Handling).

  • Do not test the framework or the language: a test that Isbn.Parse("x") returns an Isbn is a test of nothing; test the rules.

  • Coverage is a flashlight, not a goal: use the report to find untested branches, not to chase a number.

VB versus C# in test code

Topic Visual Basic C#

Attributes

<Fact>, one per line or comma-separated in one pair of brackets

[Fact]

Lambda for Throws

Sub() Isbn.Parse("x") or Function() desk.DescribeAsync(x)

() => Isbn.Parse("x")

NUnit constraint root

[Is].EqualTo(1) (the keyword needs brackets)

Is.EqualTo(1)

Async tests

Async Function Name() As Task; Async Sub is rejected

async Task Name(); async void is rejected

IAsyncLifetime in xUnit v3

a normal Function returning ValueTask (BC36945 for Async)

async ValueTask allowed

Data of Date/Decimal in attributes

BC30934; use a source member

an error for the non-constant argument; use a source member

Test entry point for an MTP exe

generated at build time, works for VB

generated at build time

Templates in the SDK

mstest, xunit (v2), nunit

the same, plus the Playwright test templates (mstest-playwright, nunit-playwright)

Filling a TheoryData

Add calls or From { {a, b, c} }

Add calls or a collection initializer

nameof

NameOf(Member)

nameof(Member)

Assembly attribute

<Assembly: InternalsVisibleTo("X")> in a file, or the MSBuild item

[assembly: InternalsVisibleTo("X")] or the item

Analyzers for xUnit

no diagnostic was reported for the VB experiment above

the xUnit analyzers run

What changed since the older guides

Books and blog posts about VB.NET testing often predate MSTest 4, NUnit 4 and xUnit v3. The points where the old advice no longer compiles or no longer applies:

Older advice Today

<ExpectedException(GetType(FormatException))> on a test

removed in MSTest 4 (BC30002); use Assert.ThrowsExactly(Of FormatException)(Sub() ...)

Assert.ThrowsException(Of T)

removed in MSTest 4 (BC30456); use Assert.ThrowsExactly(Of T) (exact) or Assert.Throws(Of T) (derived types allowed)

Assert.AreEqual(expected, actual) in NUnit

removed from Assert in NUnit 4; use the constraint model or ClassicAssert

Assert.AreEqual(1, list.Count)

works, but the MSTest 4 analyzer MSTEST0037 asks for Assert.HasCount(1, list) (an error under TreatWarningsAsErrors)

Async Sub test methods

not supported by any of the three; use Async Function ... As Task

Visual Studio’s Unit Test Project (.NET Framework) template

replaced by the SDK-style mstest, xunit, nunit templates

vstest.console.exe as the CI runner

dotnet test; and optionally MTP, where the test project is its own runner

Code Coverage "only in VS Enterprise"

coverlet.collector and dotnet-coverage work on the command line for every edition

Hand-written test doubles only

Moq, NSubstitute and the framework FakeTimeProvider all work from VB

Testing VB6/VBA code by hand

not applicable on .NET; see The VB family

Common errors in test projects

Code Where and why

BC30456

'ComputeFine' is not a member of 'LoanDesk' — the member is Friend and the test assembly is not a friend; also 'AreEqual' is not a member of 'Assert' (NUnit 4) and 'ThrowsException' is not a member of 'Assert' (MSTest 4)

BC30002

Type 'ExpectedException' is not defined. — the attribute was removed in MSTest 4

BC30934

Conversion from 'Date' to 'Object' cannot occur in a constant expression used as an argument to an attribute. — Date or Decimal in DataRow, InlineData or TestCase

BC36945

The 'Async' modifier can only be used on Subs, or on Functions that return Task or Task(Of T). — Async Function returning ValueTask, as the xUnit v3 lifetime interfaces require

MSTEST0001

explicit parallelism decision missing: add MSTestSettings.vb

MSTEST0003

invalid test method signature, for instance Async Sub

MSTEST0037

use the specific assertion (Assert.HasCount) instead of Assert.AreEqual on a count

MSBuild MSB1001

Unknown switch. — dotnet test --project in VSTest mode; pass the project positionally, or switch the solution to MTP with global.json

(The MSTEST rules have pages on Learn; the BC codes with no page there are described by their message text only.)

Pitfalls checklist

  • Put Option Strict On in the test projects too, through Directory.Build.props; the templates do not.

  • Test methods are Public; test classes are Public.

  • <TestClass> is required in MSTest and invisible in the other two: forgetting it gives "0 tests found", not an error.

  • [Is] in NUnit constraints; Async Function ... As Task for async tests; never Async Sub.

  • Attribute data must be constants; use a source member for Date, Decimal and objects.

  • The same fixture rules do not hold across frameworks: instance per test (MSTest, xUnit) versus per fixture (NUnit).

  • Do not put shared mutable state in Shared fields or My.Settings when tests run in parallel.

  • Match the filter property to the adapter: TestCategory for MSTest and NUnit, the trait name for xUnit.

  • Pin the versions of Moq, NSubstitute and the test frameworks, and update them deliberately; they are third-party packages.

  • Do not mix VSTest and MTP in one folder that has a global.json selecting MTP.

  • A VB Async Function shows up in coverage as a separate state-machine class.

  • The shared platform background (the test pyramid, isolation, naming) is on the C# Testing page; test projects can be written in VB for a C# library and the reverse (see Interop with C# and Other .NET Languages).

What was built for this page

All the examples are built with Option Strict On and TreatWarningsAsErrors in the .NET 10.0.402 SDK container. The numbers: three complete test projects (15 tests each, all passing), one MTP project (2 tests), one xUnit v3 project (4 tests), and the scratch projects for the failure messages, the analyzer errors and the removed MSTest 4 API. The Windows-only parts of the shelf (Shelf.Desktop, Shelf.Wpf) have no tests in this page.