Testing
A plug-in’s logic and agent tools need only IStudioHost. Give them a small host of your own in a test project, and call the tools as an agent would: no studio, and no Windows, needed.
The pattern
- Keep the logic and the tools in the plain
net10.0library, apart from any WPF view. - Write a fake
IStudioHostthat answers what your tests need: the answers to its OAX queries, the project’s state; and keep what the plug-in writes to the Activity panel, to check it. - Make your tool classes with it, call their methods, and check what they return: that text is what the agent reads.
Customer Service is tested this way: a FakeStudioHost and a mailbox in memory, with no studio and no mail server (see Its tests).
A fake host
For Model Summary, from Your first plug-in. The members with a body of their own (the data plug-in members of contract 1.1, RequestAgentAccessAsync of 1.2) need nothing.
using OpenBI.Studio.Core.Engine;
using OpenBI.Studio.Core.Privacy;
using OpenBI.Studio.Sdk;
namespace ModelSummary.Tests;
/// <summary>
/// The studio, as far as Model Summary's tests need it: the UI thread is the test's own, the model answers what Dax says,
/// and the Activity panel's lines are kept in Logged. The members of IStudioHost with a body of their own (contract 1.1's
/// data plug-in members) need nothing here.
/// </summary>
public sealed class FakeHost : IStudioHost
{
public Func<string, QueryResult>? Dax { get; set; }
public IReadOnlyList<TableInfo> Tables { get; set; } = [];
public List<string> Logged { get; } = [];
public string StudioVersion => "0.3.74";
public Task InvokeAsync(Action action) { action(); return Task.CompletedTask; }
public Task<T> InvokeAsync<T>(Func<T> func) => Task.FromResult(func());
public void Post(Action action) => action();
public object MainWindow { get; } = new();
public bool HasProject => Dax is not null;
public string? ProjectName => HasProject ? "Test" : null;
public string? ProjectFolder => null;
public event EventHandler? ProjectChanged { add { } remove { } }
public Task<QueryResult> DaxAsync(string dax, CancellationToken ct = default) =>
Task.FromResult(Dax?.Invoke(dax) ?? throw new InvalidOperationException("No model."));
public void ShowDax(string dax) { }
public void ShowView(string view) { }
public IPluginStorage Storage(string pluginId) => throw new NotSupportedException("Model Summary keeps no settings.");
public Task<string?> MicrosoftTokenAsync(string[] scopes, bool interactive, string? clientId = null, string authority = "organizations",
string? loginHint = null, CancellationToken ct = default) => Task.FromResult<string?>(null);
public PrivacySettings Privacy => new();
public Pseudonymizer NewPseudonymizer() => throw new NotSupportedException("Model Summary hands agents no personal data.");
public bool SetPrivacy(string mode, string? presidioUrl, string language = "en") => false;
public AgentEndpoint Agent => new("http://127.0.0.1:0/mcp", "", false, "", "");
public void Log(string title, string detail = "", bool error = false, bool byAgent = false) => Logged.Add(title);
public void SetStatus(string text) { }
public void ShowPluginView(string pluginId, string viewId) { }
}
The tests
using System.Text.Json;
using OpenBI.Studio.Core.Engine;
using Xunit;
namespace ModelSummary.Tests;
public sealed class SummaryToolsTests
{
[Fact]
public void Folders_says_so_when_no_project_is_open() =>
Assert.Equal("No project is open in OpenBI Studio.", new SummaryTools(new FakeHost()).Folders());
[Fact]
public async Task Top_ranks_by_the_models_measure_and_says_so_in_the_Activity_panel()
{
string? asked = null;
var host = new FakeHost
{
Dax = dax =>
{
asked = dax;
return new QueryResult
{
Columns = [new ResultColumn("Products[Name]", "string"), new ResultColumn("[Value]", "double")],
Rows =
[
[JsonSerializer.SerializeToElement("Pale ale"), JsonSerializer.SerializeToElement(1200)],
[JsonSerializer.SerializeToElement("Stout"), JsonSerializer.SerializeToElement(800)],
],
};
},
};
var text = await new SummaryTools(host).Top("Products", "Name", "Revenue", top: 2);
Assert.Contains("[Revenue]", asked);
Assert.Contains("TOPN ( 2,", asked);
Assert.Contains("- Pale ale: 1200", text);
Assert.Contains("- Stout: 800", text);
Assert.Contains("Model Summary ranked Name", host.Logged);
}
}
The test project
The SDK’s package carries OpenBI.Studio.Core.dll as a reference assembly: a test compiles against it, but .NET doesn’t run one (“Reference assemblies cannot be loaded for execution”). A test that makes the studio’s own types, a QueryResult as above, a TableInfo or a Pseudonymizer, references an installed OpenBI Studio’s assemblies instead, on Windows or a Mac:
<Project Sdk="Microsoft.NET.Sdk">
<!-- Model Summary's tests (docs/sdk/testing/). They make the studio's own types (QueryResult, ResultColumn), so they run
against an installed OpenBI Studio's assemblies: the SDK's package carries OpenBI.Studio.Core as a reference assembly,
which .NET compiles against but does not run. Windows or a Mac. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<!-- Windows; on a Mac: /Applications/OpenBI Studio.app/Contents/MacOS -->
<OpenBIStudio>C:\Program Files\OpenBI</OpenBIStudio>
</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="ModelContextProtocol" Version="2.2.0" />
<Reference Include="$(OpenBIStudio)\OpenBI.Studio.Sdk.dll" />
<Reference Include="$(OpenBIStudio)\OpenBI.Studio.Core.dll" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ModelSummary\ModelSummary.csproj" />
</ItemGroup>
</Project>
Model Summary’s own project references the SDK’s package with PrivateAssets="all", so the test project sees only the studio’s assemblies. Then:
cd ModelSummary.Tests
dotnet test
On Linux and in CI
A test that uses only your plug-in’s own types and the SDK’s interfaces runs with the package anywhere, Linux and CI included. To keep most tests there, put the plug-in’s logic in classes of your own that take plain values, and keep the studio’s types at the edges, in the tools.