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) |
|
|
|
VB template in the .NET 10 SDK |
|
|
|
Test class marker |
|
none: any |
|
Test method marker |
|
|
|
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 |
|
the constructor |
|
Style of assertions |
|
|
constraint model |
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 Stricton. The SDK default for VB isOptionStrictoff, so the test projects of a strict solution need<OptionStrict>On</OptionStrict>in the project or, better, in aDirectory.Build.propsfor the whole solution (see Projects, MSBuild and the dotnet CLI and Option Statements). The shelf solution usesOptionStrict,TreatWarningsAsErrorsandNuGetAuditset in oneDirectory.Build.props, so every file below starts withOption Strict Onand every analyzer warning is an error. -
Only the MSTest template sets
LangVersion,ImplicitUsingsandNullable. They are C#-centred settings:Nullablehas no effect on VB, andImplicitUsingsonly adds a few namespace imports. -
The MSTest template adds
MSTestSettings.vb, an assembly-levelParallelizeattribute, because the MSTest analyzers demand a decision about parallelism (shown below). -
The xUnit and NUnit templates add
coverlet.collectorfor code coverage, and the NUnit one addsNUnit.Analyzers. -
The template folder name becomes the project name. A hyphenated name such as
Shelf-Testshas no valid root namespace, so setRootNamespace(BC30113 otherwise; see Program Structure and Code Conventions). -
There is no xUnit v3 template in the SDK’s list.
dotnet new list -lang VBlistsxunit(the v2 template shown above) and noxunit3, and neither doesdotnet new list testfor 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 arePublic SuborPublic Function ... As Task, neverAsync Sub. -
Public Property TestContext As TestContextis filled in by the framework before each test; a property of that exact type is all it needs, and theTestInitialize/TestCleanupmethods can use it (TestContext.TestName,WriteLine). -
MSTest 4 has
Assert.ThrowsExactly(Of T)(Sub() ...)for the exact type andAssert.Throws(Of T)for the type or a subclass. Both return the exception, so the message can be asserted (the testIsbn_RejectsBadCheckDigitusesStringAssert.Contains).Assert.ThrowsExactlyAsync(Of T)(Function() ...)is the async version. -
Assert.HasCount(1, found)is the collection-size assertion that replacedAssert.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
ExpectedExceptionattribute and noAssert.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.
Source: MSTest attributes
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 andIDisposable.Disposeis the teardown.Implements IDisposableon a VB class needs theImplements IDisposable.Disposeclause on the method. -
ITestOutputHelper(fromXunit.Abstractions) is injected through the constructor to write to the test output;Console.WriteLineis not captured. -
Assert.Single(found)asserts that the collection has exactly one element and returns it, whichDim 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 theMemberDatasource type-checked. The file fills it withAddcalls; the collection initializerNew 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)onDecimalcompares by value, so0Dequals0.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:
-
Isis 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,ThrowsandContainsare ordinary identifiers and need no brackets. Likewise[Is].False,[Is].Trueand[Is].Nullin 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#. TheClassicAssertclass 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 aSub. -
<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_catalogis doing.
Attributes at a glance
| Concept | MSTest | xUnit.net | NUnit |
|---|---|---|---|
A test |
|
|
|
Parameterised test, constant arguments |
|
|
|
Parameterised test, computed arguments |
|
|
|
Before every test |
|
constructor |
|
After every test |
|
|
|
Once per class |
|
|
|
Once per assembly |
|
no attribute (collection fixtures share across classes) |
|
Category for filtering |
|
|
|
Turn parallelism off for a class |
|
put the classes in one |
|
Assembly-wide parallel setting |
|
|
|
(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 |
|
|
|
Boolean |
|
|
|
Null |
|
|
|
Collection size |
|
|
|
Text contains |
|
|
|
Exception |
|
|
|
Async exception |
|
|
|
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.)
Source: MSTest assertions
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 |
one: the constructor ran once, |
Once-per-class hook |
|
the fixture ( |
|
Once-per-assembly hook |
|
no attribute; the fixture of a collection or a custom runner extension |
|
Order of the per-test hooks |
constructor, |
constructor, test, |
|
Async hooks |
|
|
|
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
Sharedfield 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 ofFirstandSecondinterleave (the same constructor,TestInitializeandTestCleanuplines appear next to each other). Hooks per test must not touch shared state unless that state is thread-safe. -
A
Sharedmethod in VB is the equivalent of C#'sstatic, so<ClassInitialize>and<AssemblyInitialize>methods arePublic Shared Sub Name(context As TestContext). Leaving outSharedis 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.
Source: MSTest data-driven tests
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 acceptsIt.IsAny(Of CatalogItem)without them for a parameterless method (built and run), so both forms work. -
Moq’s
Setup/Verifyfor a method that returns nothing takeExpression(Of Action(Of T)); the single-lineSublambda converts to an expression tree. A multi-lineSublambda 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. -
ReturnsAsyncandThrowsAsync(fromMoq) wrap results in a completed or faultedTask. The testDescribe_PropagatesLookupFailureshowsThrowsAsync(New InvalidOperationException("offline")). -
The mocked type must be an interface or a class with
Overridablemembers: a VB member that is notOverridableis 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.
Source: FakeTimeProvider class
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 |
|
|
|
Name |
|
|
|
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.
Source: Run selected unit tests
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, eitherParallelizeorDoNotParallelize, 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;
Collectiongroups 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
MSTestpackage 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 voidmethod), MSTEST0025 (useAssert.Failinstead 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
xunitpackage carriesxunit.analyzers; the experiment above withAsync Subshows that at least some of its checks are not applied to VB. Do not rely on it for VB code. -
NUnit.
NUnit.Analyzersis 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.
Source: MSTest analyzers overview
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 |
|---|---|---|
|
72.7 % |
77.8 % |
|
0 % |
100 % (no branches) |
|
57.1 % |
100 % |
|
50.0 % |
100 % |
|
57.1 % |
100 % |
|
100 % |
75.0 % |
|
0 % |
100 % (no branches) |
|
100 % |
100 % |
|
100 % |
100 % |
|
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.
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:
-
IAsyncLifetimemethods returnValueTask, so they cannot beAsync Function(BC36945, see above). -
The async test method can use
TestContext.Current.CancellationToken, asDelay_CompletesandLife.vbdo. -
The v2 pattern of injecting
Xunit.Abstractions.ITestOutputHelperis 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.
Source: xUnit.net v3: getting started
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-latestunless the solution has Windows Forms or WPF projects; the test projects themselves are cross-platform, and the shelf’s Windows-only projects only build withEnableWindowsTargetingset. -
Treat warnings as errors in the test projects as well; the MSTest analyzers are only useful if they fail the build.
-
Pin the
.NET SDKwithglobal.jsonso 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
Assertcalls about the same result are fine. -
Test through the public API; use
InternalsVisibleTofor the few helpers that deserve their own tests. -
Avoid default instances and
Myin code that must be tested:My.Settingsand 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,DirandInputBoxare untestable;TimeProvider,Randominstances with a seed and interfaces are testable. -
Test the error paths with the exact type:
ThrowsExactlyorThrowsensure that the failure is the one you meant, not aNullReferenceExceptionfrom an earlier bug (exceptions are on Structured Exception Handling). -
Do not test the framework or the language: a test that
Isbn.Parse("x")returns anIsbnis 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 |
|
|
Lambda for |
|
|
NUnit constraint root |
|
|
Async tests |
|
|
|
a normal |
|
Data of |
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 |
|
the same, plus the Playwright test templates ( |
Filling a |
|
|
|
|
|
Assembly attribute |
|
|
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 |
|---|---|
|
removed in MSTest 4 (BC30002); use |
|
removed in MSTest 4 (BC30456); use |
|
removed from |
|
works, but the MSTest 4 analyzer MSTEST0037 asks for |
|
not supported by any of the three; use |
Visual Studio’s Unit Test Project (.NET Framework) template |
replaced by the SDK-style |
|
|
Code Coverage "only in VS Enterprise" |
|
Hand-written test doubles only |
Moq, NSubstitute and the framework |
Testing VB6/VBA code by hand |
not applicable on .NET; see The VB family |
Common errors in test projects
| Code | Where and why |
|---|---|
BC30456 |
|
BC30002 |
|
BC30934 |
|
BC36945 |
|
MSTEST0001 |
explicit parallelism decision missing: add |
MSTEST0003 |
invalid test method signature, for instance |
MSTEST0037 |
use the specific assertion ( |
MSBuild MSB1001 |
|
(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 Onin the test projects too, throughDirectory.Build.props; the templates do not. -
Test methods are
Public; test classes arePublic. -
<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 Taskfor async tests; neverAsync Sub. -
Attribute data must be constants; use a source member for
Date,Decimaland 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
Sharedfields orMy.Settingswhen tests run in parallel. -
Match the filter property to the adapter:
TestCategoryfor 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.jsonselecting MTP. -
A VB
Async Functionshows 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.
References
-
Unit testing Visual Basic .NET Core libraries using dotnet test and MSTest, with NUnit and with xUnit
-
Microsoft.Testing.Platform overview and Migrate from VSTest to MTP
-
Run selected unit tests, Use code coverage and Unit testing best practices
-
xUnit.net, xUnit.net v3 getting started, NUnit, Moq, NSubstitute and coverlet (project sites)
-
Testing (C#), Debugging and Diagnostics and Best Practices and Common Pitfalls