| name | smartcon-testing |
| description | Unit + integration testing patterns for SmartCon Revit plugin. xUnit + Moq, .NET 8 / net48 multi-version. Covers Revit API mocking limitations, test seams, fake implementations, Moq gotchas with async/generic, integration test frameworks, Jeremy Tammik recommendations. Use when writing, updating, or running tests for SmartCon. |
SmartCon Testing
Patterns and rules for testing the SmartCon Revit plugin. Read this BEFORE writing
tests โ Revit API has unique limitations (sealed native types) that make generic
.NET testing advice misleading.
Quick decision tree
| What you test | Approach | Tool |
|---|
| Pure logic (no Revit types in SUT) | xUnit + Moq for interfaces | dotnet test |
Document/ElementId/Family touched by SUT | Cannot unit-test the SUT | Refactor to abstraction OR use integration test |
FilteredElementCollector inline call | Cannot unit-test | Refactor to IFamilyFinder (seam) |
| POCO / records / enums | xUnit equality/value tests | dotnet test |
| WPF ViewModel | xUnit + Moq for IExternalEventService | dotnet test |
| End-to-end with running Revit | [RevitFact] style | ricaun.RevitTest or RevitXunit.TestAdapter (see integration-testing.md) |
Rule of thumb: if your SUT's constructor takes IFamilyVersionStore,
IRevitContext, or IFamilyFinder โ you CANNOT unit-test the SUT directly. See
revit-mocking.md ยง "What cannot be mocked" and
docs/testing/stale-detection-coverage-gaps.md for the real example.
Critical rules
- Revit API types are sealed native โ
Mock<Document>() and
new ElementId(...) throw FileNotFoundException: RevitAPI 25.4.50.0 in CI
because Nice3point.Revit.Api.RevitAPI is ExcludeAssets=runtime. See
revit-mocking.md.
- Moq + generic nullable fails โ
Mock<IFamilyManagerAwaitableEvent>().Object
throws "Type contains generic type parameters" when RaiseAsync<T> is called
with T = FamilyVersion?. Use a hand-written fake (see
test-fakes.md).
- Moq
.Callback without .Returns returns default โ for Task-returning
methods this is null, breaking await. Always pair
.Setup(...).Callback(...).Returns(Task.CompletedTask). (Moq issue #702.)
ElementId in tests = new ElementId((long)N) โ never mock. The
(long) constructor is NOT deprecated; (int) is in Revit 2024+.
- xUnit 1031 (blocking Task ops) โ
.GetAwaiter().GetResult() in sync
tests triggers the analyzer. Either make the test async Task and await,
or add #pragma warning disable xUnit1031 at file top with a comment.
InternalsVisibleTo "SmartCon.Tests" is set in
SmartCon.FamilyManager.csproj โ internal SUTs (e.g. StaleDetector,
StaleCategoryAggregator) are visible to tests. No test seam class needed
for them.
- Custom
Debug.* configurations don't define DEBUG;TRACE โ see
project-conventions.md for the
Directory.Build.props fix and how to verify the deployed DLL.
- ArgumentNullException.ThrowIfNull for net48 โ wrap in
#if NET8_0_OR_GREATER ... #else throw new ArgumentNullException(...) #endif
to keep both TFM compatible.
- Async tests with
RaiseAsync/RaiseAsync<T> โ use the
internal ProcessQueue(object) test seam on
FamilyManagerAwaitableEvent instead of mocking
IFamilyManagerAwaitableEvent (see existing
FamilyManagerAwaitableEventTests.cs).
- Record equality on
IReadOnlyList<T> is reference-based โ the BCL does
NOT compare collection contents. Compare element-by-element or expose a
helper that does.
Project test layout
src/SmartCon.Tests/
โโโ Core/ # SmartCon.Core unit tests
โโโ FamilyManager/ # SmartCon.FamilyManager unit tests
โ โโโ Core/ # Normalizers/validators
โ โโโ Events/ # AwaitableEvent + Fakes/
โ โโโ Models/ # POCO / record tests
โ โโโ Repository/ # SQLite-based tests
โ โโโ Services/ # Service tests
โ โโโ Stale/ # Stale detection tests
โ โโโ ViewModels/ # WPF VM tests
โโโ TestDoubles/ # Shared fakes (FakeClock, FakeRevitContextWriter, ...)
โโโ TestResults/ # Coverage output (gitignored)
โโโ xunit.runner.json
Test file naming: {ProductionClassName}Tests.cs. Test method naming:
{MethodUnderTest}_{StateUnderTest}_{ExpectedBehavior} (e.g.
MergeInto_NullExisting_StartsEmpty).
Test seam patterns
Constructor null-checks (Theory)
[Theory]
[InlineData(0)] [InlineData(1)] [InlineData(2)] [InlineData(3)]
[InlineData(4)] [InlineData(5)] [InlineData(6)] [InlineData(7)]
public void Constructor_NullArg_Throws(int nullIndex)
{
object[] args = { dep1, dep2, dep3, dep4, dep5, dep6, dep7, dep8 };
args[nullIndex] = null!;
Assert.Throws<ArgumentNullException>(() => new Sut(
(IDep1)args[0], (IDep2)args[1], ..., (IClock)args[7]));
}
Moq for safe interfaces (no Document in signatures)
var mock = new Mock<IDependency>();
mock.Setup(m => m.Method(It.IsAny<string>())).ReturnsAsync("value");
var sut = new Sut(mock.Object, ...);
Fake for interfaces with Document/ElementId
See test-fakes.md โ use a hand-written class, not
Mock<> (the proxy fails to load Document).
Async + Cancellation
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<TaskCanceledException>(() => sut.MethodAsync(cts.Token));
Parallel tests + integration
[assembly: CollectionBehavior(DisableTestParallelization = true)]
[CollectionDefinition("RevitIntegration", DisableParallelization = true)]
[Collection("RevitIntegration")]
public class IntegrationTests { }
Common gotchas
| Symptom | Cause | Fix |
|---|
FileNotFoundException: RevitAPI 25.4.50.0 in tests | new ElementId(...) or Mock<IFamilyVersionStore>() | See revit-mocking.md โ Document is sealed native |
Castle.DynamicProxy exception at Mock construction | Generic nullable or sealed type | Hand-written fake in TestDoubles/ |
Moq.Verify reports Times.Once but the code DID call it | RaiseAsync<T> test mock returns null (no .Returns(Task.FromResult(...))) | Always pair .Returns(Task.FromResult(...)) |
| Test passes locally, fails in CI | Test depends on DateTimeOffset.UtcNow | Use TestDoubles/FakeClock with fixed value |
Assert.Equal on StaleBatchUpdateResult fails despite same data | Record equality compares IReadOnlyList<T> by reference | Compare .TotalRequested/.SuccessCount/.FailedCount element-by-element |
xUnit1031 warning on .GetAwaiter().GetResult() | xUnit prefers async Task | Make test async OR #pragma warning disable xUnit1031 with justification comment |
Project-specific conventions
- Multi-version tests: only
SmartCon.Tests.csproj targets net8.0-windows
(Revit 2025). R24/R21/R19 are .NET Framework 4.x and don't run unit tests.
Build: dotnet build src/SmartCon.Tests/SmartCon.Tests.csproj -c Debug.R25.
- Run tests:
dotnet test src/SmartCon.Tests/SmartCon.Tests.csproj -c Debug.R25 --filter "FullyQualifiedName~Stale" (or no filter for all).
- Validate docs after test additions:
powershell -ExecutionPolicy Bypass -File tools/validate-docs.ps1 โ fails if new public types are undocumented.
- Commit message style:
test(<module>): <verb> <noun> (e.g.
test(stale-detection): add 27 unit tests for snapshot POCO, SQL builder).
References
- revit-mocking.md โ What CANNOT be mocked, why, workarounds
- test-fakes.md โ Existing fakes + how to write new ones
- moq-patterns.md โ Moq patterns: Callback, Returns, Verify, Sequences
- integration-testing.md โ ricaun.RevitTest, RevitXunit.TestAdapter
- project-conventions.md โ Multi-version build, DEBUG symbol gotcha, net48/ThrowIfNull
- jeremy-tammik.md โ Jeremy Tammik recommendations
- open-source.md โ GitHub examples: Speckle, ricaun, Scotec, Onbox
- gotchas.md โ Extended gotchas with production examples