Integration Testing
Every project generated from the templates includes a <YourProject>.Tests.Integration project, wired up and ready to run. Its tests go against a real Dataverse environment rather than mocks, because most of what breaks in a portal lives in the seams a mock replaces — query translation, metadata, formatting, permissions. Two base classes are provided, one for each side of that.
What's in the Project
The template ships these files; they're ordinary source in your repository, so change them freely.
| File | Purpose |
|---|---|
PortalEndpointTestBase.cs |
Boots your portal in memory and exposes an HttpClient against it. Derive from this for endpoint and permission tests. |
TestBase.cs |
Builds a service container with an authenticated Dataverse connection. Derive from this to call your own services and logic directly. |
TestAuthentication.cs |
The test authentication scheme and the TestUserContext that decides who a request runs as. |
appsettings.json, xunit.runner.json |
Non-secret settings, and the xunit runner configuration that keeps collections serial. |
ExampleTests.cs, README.md |
Worked examples of both bases (skipped until you point them at real records), and a short local reference. |
Choosing a Base
The two are complementary — they test different layers, and a healthy suite uses both.
PortalEndpointTestBaseruns your actualProgram, so the request passes through the real middleware order, the real authorization, and the permission handlers exactly as you registered them. Use it for table permissions, the framework's/api/*surface, and any endpoint you add. Because it is your application, a test here can't drift from production wiring.TestBasestarts no host. It builds a container and hands you a Dataverse client, which makes it the cheaper choice for plugin-style logic, domain services and query shapes. It sees no HTTP, so no middleware, authorization or permission handlers apply.
Configuration and Secrets
Point D365:Url at the environment the tests should run against in appsettings.json — a development or throwaway environment, since tests create and delete records. Credentials come from user secrets, and the test project deliberately declares the same UserSecretsId as the web project, so a portal that already runs locally needs nothing further. To set them explicitly:
dotnet user-secrets set "D365:ClientId" "<application (client) id>"
dotnet user-secrets set "D365:ClientSecret" "<client secret value>"
Sources are layered appsettings.json → user secrets → environment variables, so a build agent can supply everything through environment variables (D365__ClientSecret, using the double underscore) without touching a file. Never put the client secret in appsettings.json, which is committed.
Note
You don't need a license key to run tests. The in-memory host listens on a loopback address, and loopback is licensed without one — no Portal Website record and no test double required.
Testing Endpoints and Permissions
Derive from PortalEndpointTestBase and make requests with Client. Requests start anonymous, which is the right default for asserting that an endpoint turns anonymous callers away. Redirects are not followed, so a test can assert on a 302 rather than the page it would land on.
public class AccountPermissionTests : PortalEndpointTestBase
{
[Fact]
public async Task AnonymousCallersAreRefused()
{
// No sign-in: the request is anonymous.
var response = await this.Client.GetAsync("/api/table/account/records");
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task SignedInContactCanRead()
{
this.SignInAs(contactId);
var response = await this.Client.GetAsync("/api/table/account/records");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
Acting as a User
SignInAs is the one to reach for. It stubs only the credential step — the principal it issues carries the same claims a real sign-in emits, so authorization, the permission handlers and every claim-reading extension behave as they do in production. No password, no provisioning, and it can change identity between requests in a single test.
LoginAsync posts real credentials to the login endpoint and keeps the cookie, for when the sign-in path itself is under test. It needs a contact with a password and a confirmed email, so it's slower; the two coexist and the base falls back to the real cookie whenever no impersonated user is set.
// Impersonate — fast, no password, still runs the real authorization path.
this.SignInAs(contactId);
this.SignInAs(contactId, "Administrator", "Manager"); // with role claims
this.SignInAs(userId, PortalUserType.SystemUser); // an internal user
this.SignOut(); // anonymous again
// Or sign in for real, when the sign-in path itself is what's under test.
var login = await this.LoginAsync("someone@example.com", "password");
Note
LoginAsyncneeds the JSON auth endpoints, which are mapped for the React and Blazor WebAssembly/Auto hosts. A server-rendered Blazor project signs in through form posts to the Razor Account pages instead, so/api/auth/loginisn't mapped there andLoginAsyncreturns 404 — useSignInAs, which works in every host.
Setup, Teardown and Substitutions
Three hooks cover the usual needs. ConfigureTestServices runs after your application's own registrations, so a Replace there wins over Program.cs — the way to stand in a fake for an outbound dependency. OnInitializedAsync and OnDisposingAsync bracket each test for seeding and cleanup, and the teardown runs even when a test fails.
public class OrderTests : PortalEndpointTestBase
{
private Guid _contactId;
// Replace a service in the running host — a fake for an outbound dependency, say.
protected override void ConfigureTestServices(IServiceCollection services)
{
services.Replace(ServiceDescriptor.Transient<IShippingQuotes, FakeShippingQuotes>());
}
// Seed what the test needs. Runs after the host starts.
protected override async Task OnInitializedAsync()
{
_contactId = await this.CreateTestContactAsync();
}
// Always runs, including after a failing test.
protected override async Task OnDisposingAsync()
{
await this.DeleteContactAsync(_contactId);
}
}
Important
These tests start your portal, so anything that stops it starting stops them too. In particular, with enhanced authorization enabled the host validates the configured Website Id at startup and fails with guidance until a real
powerpagesiteid is supplied — the same precondition as running the portal.
Testing Services and Logic
Derive from TestBase, register what the code under test needs, and resolve it. The Dataverse client is already authenticated.
public class InvoiceLogicTests : TestBase
{
// Register what the code under test needs; always call base first.
protected override void RegisterServices(IServiceCollection services)
{
base.RegisterServices(services);
services.AddMyPortalLogic();
}
[Fact]
public async Task PostingAnInvoiceSetsTheBalance()
{
var logic = this.ServiceProvider.GetRequiredService<InvoiceLogic>();
// A Dataverse client, already authenticated. IOrganizationService resolves to the
// same connection, so resolving either doesn't open a second one.
using var service = this.CreateOrganizationService();
var invoice = await service.Queryable("invoice")
.FirstOrDefaultAsync(i => i.GetAttributeValue<string>("invoicenumber") == "INV-1");
invoice.Should().NotBeNull();
}
}
Pass a user id to CreateOrganizationService(userId) to impersonate — Dataverse then enforces that user's own privileges, which is how to check record-level access from the data side rather than through an endpoint.
CreateWebApiHttpClientAsync() returns a client authenticated against the Dataverse Web API (/api/data/v9.2/) for assertions that are easier to express in OData than through the SDK. It needs Azure:TenantId in configuration as well as the client id and secret.
How the Tests Run
Run them with dotnet test, or from your IDE's test explorer. A few characteristics are worth knowing before you write many:
- Collections run serially. They share one Dataverse environment, and parallel writes to the same records make failures hard to reproduce. This is set in
xunit.runner.json. - xunit builds a new instance of the test class for every test method. The authenticated Dataverse connection is cached for the whole process and handed out as a lightweight clone, so that costs one sign-in per run rather than one per test — but anything expensive you add to a constructor pays on every test.
- Clean up what you create. A test that leaves records behind makes the next run's assertions depend on the last one. Prefer unique values per test (a GUID in a name field) over fixed ones, so repeated runs can't collide.
Best practice
Point the suite at a development environment, never production. These are real writes against real data, and a test that deletes what it created will delete the real record if you aim it at one.
