Testing the BFF: Unit, Integration & Contract Tests
A layered testing strategy for the BFF. WebApplicationFactory for integration tests, Pact for consumer-driven contract testing with Vue.

A note on the code in this article. The testing strategy, test fixtures, and code examples shown here are derived from a production BFF built for a Norwegian enterprise education platform. Service names, domain models, and certain structural details have been generalised to meet NDA obligations. The test architecture, WebApplicationFactory configuration, Pact contract setup, and the specific failure modes each test layer is designed to catch are drawn directly from what was written and maintained in production.
A BFF sits at a boundary. On one side, the Vue application depends on it for every piece of data it renders. On the other side, it depends on upstream services it does not own. Both sides of that boundary can change. Both sides have broken production in real systems that lacked the test coverage to catch the breakage before deployment.
The testing strategy for a BFF is therefore not the same as for an isolated service. It has to answer three distinct questions that a single testing layer cannot answer alone: does the aggregation logic produce the right output given specific inputs? does the running service behave correctly end to end with real HTTP machinery? and does the contract the BFF exposes to the Vue application stay stable as both sides evolve independently?
This article builds a layered answer to those three questions β unit tests for the aggregation and shaping logic, integration tests using WebApplicationFactory for the full HTTP pipeline, and consumer-driven contract tests using Pact to enforce the Vue-to-BFF contract at the CI level.
The testing pyramid, applied to a BFF
The standard testing pyramid β many unit tests, fewer integration tests, fewest end-to-end tests β applies to BFF testing with one modification: contract tests sit alongside integration tests, not above them. They are not end-to-end tests. They are fast, isolated, and run in CI. They occupy a distinct position in the pyramid because they address a concern that neither unit nor integration tests cover: whether the two independent codebases (the BFF and the Vue application) agree on the shape of their shared interface.
ββββββββββββββββ
β E2E tests β β Minimal β Playwright smoke tests only
ββββββββββββββββ
ββββββββββββββββββββββββ
β Integration tests β β WebApplicationFactory, full HTTP pipeline
β Contract tests β β Pact, VueβBFF interface verification
ββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββ
β Unit tests β β Aggregators, shaping logic, error handling
ββββββββββββββββββββββββββββββββ
Each layer catches a different class of failure. Unit tests catch logic errors in isolation. Integration tests catch wiring errors β misconfigured middleware, incorrect route mapping, broken serialisation. Contract tests catch interface drift β changes to either side that invalidate the shared contract. None of these layers is redundant with the others.
Project setup
The test projects mirror the source project structure. Each concern gets its own project with its own dependencies:
dotnet new xunit -n EducationPlatform.Bff.UnitTests
dotnet new xunit -n EducationPlatform.Bff.IntegrationTests
dotnet new xunit -n EducationPlatform.Bff.ContractTests
# Unit test dependencies
cd EducationPlatform.Bff.UnitTests
dotnet add reference ../EducationPlatform.Bff
dotnet add package NSubstitute
dotnet add package FluentAssertions
# Integration test dependencies
cd ../EducationPlatform.Bff.IntegrationTests
dotnet add reference ../EducationPlatform.Bff
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package NSubstitute
dotnet add package FluentAssertions
# Contract test dependencies (provider side)
cd ../EducationPlatform.Bff.ContractTests
dotnet add reference ../EducationPlatform.Bff
dotnet add package PactNet
dotnet add package Microsoft.AspNetCore.Mvc.Testing
Layer 1: Unit tests
Unit tests cover the aggregation logic and response shaping in isolation. The subjects under test are the aggregators β the classes that orchestrate upstream calls, handle partial failures, and shape responses into the Vue contract. The upstream clients are substituted with NSubstitute mocks.
Testing the happy path
// EducationPlatform.Bff.UnitTests/Aggregators/DashboardAggregatorTests.cs
public class DashboardAggregatorTests
{
private readonly UserServiceClient _userClient;
private readonly CourseServiceClient _courseClient;
private readonly SessionServiceClient _sessionClient;
private readonly NotificationServiceClient _notificationClient;
private readonly DashboardAggregator _aggregator;
public DashboardAggregatorTests()
{
_userClient = Substitute.For<UserServiceClient>();
_courseClient = Substitute.For<CourseServiceClient>();
_sessionClient = Substitute.For<SessionServiceClient>();
_notificationClient = Substitute.For<NotificationServiceClient>();
_aggregator = new DashboardAggregator(
_userClient,
_courseClient,
_sessionClient,
_notificationClient,
Substitute.For<ILogger<DashboardAggregator>>());
}
[Fact]
public async Task AggregateAsync_AllServicesAvailable_ReturnsMappedResponse()
{
// Arrange
const string userId = "ingrid.solberg@skole.no";
_userClient.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(new UserProfileDto(
"Ingrid", "Solberg",
OrgId: "uninett",
RoleCode: "TEACHER",
AvatarPath: "i-solberg.jpg"));
_notificationClient.GetUnreadCountAsync(userId, Arg.Any<CancellationToken>())
.Returns(3);
_courseClient.GetCoursesByOrgAsync("uninett", Arg.Any<CancellationToken>())
.Returns([
new CourseDto("c-1",
new CourseMetadataDto("Mathematics β Year 9", "MATH-9", null!),
new EnrollmentDto(30, 24, 0),
new CourseStatusDto("ACTIVE", DateTimeOffset.UtcNow))
]);
_sessionClient.GetUpcomingAsync(
Arg.Is<string[]>(ids => ids.Contains("c-1")),
3,
Arg.Any<CancellationToken>())
.Returns([
new SessionDto("s-1", "Integration review",
DateTimeOffset.Parse("2025-04-08T09:00:00"),
"Mathematics β Year 9", Room: "204")
]);
// Act
var result = await _aggregator.AggregateAsync(userId);
// Assert
result.User.DisplayName.Should().Be("Ingrid Solberg");
result.User.Role.Should().Be("Teacher");
result.User.AvatarUrl.Should().Be("/media/avatars/i-solberg.jpg");
result.Courses.Should().HaveCount(1);
result.Courses[0].Title.Should().Be("Mathematics β Year 9");
result.Courses[0].EnrollmentLabel.Should().Be("24 / 30");
result.Courses[0].EnrollmentPercent.Should().Be(80);
result.Courses[0].Status.Should().Be("Active");
result.UpcomingSessions.Should().HaveCount(1);
result.UpcomingSessions[0].LocationLabel.Should().Be("Room 204");
result.Notifications.Count.Should().Be(3);
result.PartialFailures.Should().BeEmpty();
}
}
Testing partial failure handling
This is the test the happy-path test cannot catch: what happens when an upstream service returns null β either due to unavailability or a resilience handler exhausting its retries.
[Fact]
public async Task AggregateAsync_CourseServiceUnavailable_ReturnsDegradedResponse()
{
// Arrange
const string userId = "ingrid.solberg@skole.no";
_userClient.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(new UserProfileDto("Ingrid", "Solberg", "uninett", "TEACHER", null));
_notificationClient.GetUnreadCountAsync(userId, Arg.Any<CancellationToken>())
.Returns(0);
// Course service unavailable β client returns null
_courseClient.GetCoursesByOrgAsync("uninett", Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<CourseDto>?)null);
// Act
var result = await _aggregator.AggregateAsync(userId);
// Assert β response is still structurally valid
result.Should().NotBeNull();
result.User.DisplayName.Should().Be("Ingrid Solberg");
result.Courses.Should().BeEmpty();
result.UpcomingSessions.Should().BeEmpty(); // No courses β no sessions fetched
result.PartialFailures.Should().Contain("courses");
// Session service should not have been called β no course IDs to query with
await _sessionClient.DidNotReceive()
.GetUpcomingAsync(Arg.Any<string[]>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task AggregateAsync_ProfileServiceUnavailable_ThrowsBffAggregationException()
{
// Arrange
_userClient.GetProfileAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((UserProfileDto?)null);
_notificationClient.GetUnreadCountAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(0);
// Act & Assert β profile is required; its absence is a hard failure
await _aggregator.Invoking(a => a.AggregateAsync("any-user"))
.Should().ThrowAsync<BffAggregationException>()
.WithMessage("*User profile service unavailable*");
}
Testing response shaping
Shaping logic deserves its own tests, independent of aggregation orchestration. The shaping methods were private in the production aggregator β testing them through the public AggregateAsync method is correct, but targeted shaping tests are faster to write and easier to read when verifying edge cases:
[Theory]
[InlineData(30, 30, 100)]
[InlineData(0, 30, 0)]
[InlineData(24, 30, 80)]
[InlineData(1, 3, 33)] // Rounds correctly
[InlineData(0, 0, 0)] // Zero capacity β no division by zero
public async Task AggregateAsync_EnrollmentPercent_CalculatesCorrectly(
int enrolled, int capacity, int expectedPercent)
{
// Arrange
SetupValidProfile("test-user");
_notificationClient.GetUnreadCountAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(0);
_courseClient.GetCoursesByOrgAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns([new CourseDto("c-1",
new CourseMetadataDto("Test Course", "TC-1", null!),
new EnrollmentDto(capacity, enrolled, 0),
new CourseStatusDto("ACTIVE", DateTimeOffset.UtcNow))]);
_sessionClient.GetUpcomingAsync(Arg.Any<string[]>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns([]);
// Act
var result = await _aggregator.AggregateAsync("test-user");
// Assert
result.Courses[0].EnrollmentPercent.Should().Be(expectedPercent);
}
[Theory]
[InlineData("TEACHER", "Teacher")]
[InlineData("STUDENT", "Student")]
[InlineData("ADMIN", "Administrator")]
[InlineData("UNKNOWN", "Unknown")]
[InlineData("", "Unknown")]
public async Task AggregateAsync_RoleTranslation_MapsCorrectly(
string roleCode, string expectedRole)
{
SetupValidProfile("test-user", roleCode: roleCode);
_notificationClient.GetUnreadCountAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(0);
_courseClient.GetCoursesByOrgAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns([]);
var result = await _aggregator.AggregateAsync("test-user");
result.User.Role.Should().Be(expectedRole);
}
private void SetupValidProfile(string userId, string roleCode = "TEACHER") =>
_userClient.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(new UserProfileDto("Test", "User", "uninett", roleCode, null));
The [Theory] / [InlineData] pattern is the right tool for shaping edge cases β it tests the same logic with multiple inputs without duplicating test structure. The division-by-zero case (0 / 0) is the one that caused a production exception in an early deployment; it is now a first-class test case.
Layer 2: Integration tests with WebApplicationFactory
Integration tests verify the full HTTP pipeline: middleware execution order, route mapping, authentication enforcement, serialisation, and error handling. They use WebApplicationFactory<Program> from Microsoft.AspNetCore.Mvc.Testing, which hosts the real application in memory with substituted dependencies.
The test factory
// EducationPlatform.Bff.IntegrationTests/BffWebApplicationFactory.cs
public sealed class BffWebApplicationFactory : WebApplicationFactory<Program>
{
public UserServiceClient UserClient { get; } =
Substitute.For<UserServiceClient>();
public CourseServiceClient CourseClient { get; } =
Substitute.For<CourseServiceClient>();
public SessionServiceClient SessionClient { get; } =
Substitute.For<SessionServiceClient>();
public NotificationServiceClient NotificationClient { get; } =
Substitute.For<NotificationServiceClient>();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Replace real typed clients with substitutes
services.RemoveAll<UserServiceClient>();
services.RemoveAll<CourseServiceClient>();
services.RemoveAll<SessionServiceClient>();
services.RemoveAll<NotificationServiceClient>();
services.AddSingleton(UserClient);
services.AddSingleton(CourseClient);
services.AddSingleton(SessionClient);
services.AddSingleton(NotificationClient);
// Replace real Data Protection with ephemeral keys for tests
services.AddDataProtection()
.UseEphemeralDataProtectionProvider();
// Use test authentication β bypass real Feide OIDC
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", _ => { });
});
builder.UseEnvironment("Testing");
}
}
The TestAuthHandler simulates an authenticated user without going through the Feide OIDC flow:
// EducationPlatform.Bff.IntegrationTests/TestAuthHandler.cs
public sealed class TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string UserId = "ingrid.solberg@skole.no";
public const string OrgId = "uninett";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Check for the test auth header β allows tests to simulate unauthenticated requests
if (!Request.Headers.ContainsKey("X-Test-Auth"))
return Task.FromResult(AuthenticateResult.Fail("No test auth header."));
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, UserId),
new Claim(ClaimTypes.Name, "Ingrid Solberg"),
new Claim(ClaimTypes.Email, "ingrid.solberg@skole.no"),
new Claim("feidePersonPrincipalName", UserId),
new Claim("eduPersonPrimaryAffiliation", "staff"),
new Claim("eduPersonOrgDN", $"dc={OrgId},dc=no")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
Using a header (X-Test-Auth) to trigger test authentication allows the same factory to test both authenticated and unauthenticated scenarios without changing the factory configuration between tests.
Testing the dashboard endpoint
// EducationPlatform.Bff.IntegrationTests/Endpoints/DashboardEndpointTests.cs
public class DashboardEndpointTests(BffWebApplicationFactory factory)
: IClassFixture<BffWebApplicationFactory>
{
private HttpClient CreateAuthenticatedClient() =>
factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
})
.WithDefaultRequestHeaders(h => h.Add("X-Test-Auth", "true"));
[Fact]
public async Task GET_Dashboard_AuthenticatedUser_Returns200WithCorrectShape()
{
// Arrange
factory.UserClient
.GetProfileAsync(TestAuthHandler.UserId, Arg.Any<CancellationToken>())
.Returns(new UserProfileDto("Ingrid", "Solberg",
TestAuthHandler.OrgId, "TEACHER", null));
factory.NotificationClient
.GetUnreadCountAsync(TestAuthHandler.UserId, Arg.Any<CancellationToken>())
.Returns(2);
factory.CourseClient
.GetCoursesByOrgAsync(TestAuthHandler.OrgId, Arg.Any<CancellationToken>())
.Returns([]);
var client = CreateAuthenticatedClient();
// Act
var response = await client.GetAsync("/api/dashboard");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<DashboardResponse>();
body.Should().NotBeNull();
body!.User.DisplayName.Should().Be("Ingrid Solberg");
body.Notifications.Count.Should().Be(2);
body.Courses.Should().BeEmpty();
body.PartialFailures.Should().BeEmpty();
}
[Fact]
public async Task GET_Dashboard_UnauthenticatedRequest_Returns401()
{
// Arrange β client without X-Test-Auth header
var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Act
var response = await client.GetAsync("/api/dashboard");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task GET_Dashboard_ProfileServiceDown_Returns503WithProblemDetails()
{
// Arrange β profile service returns null (upstream unavailable)
factory.UserClient
.GetProfileAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((UserProfileDto?)null);
factory.NotificationClient
.GetUnreadCountAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(0);
var client = CreateAuthenticatedClient();
// Act
var response = await client.GetAsync("/api/dashboard");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
var problem = await response.Content
.ReadFromJsonAsync<ProblemDetails>();
problem!.Title.Should().Be("Upstream service unavailable");
problem.Status.Should().Be(503);
}
[Fact]
public async Task GET_Dashboard_CourseServiceDown_Returns200WithPartialFailure()
{
// Arrange β course service returns null, but profile is available
factory.UserClient
.GetProfileAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new UserProfileDto("Ingrid", "Solberg",
TestAuthHandler.OrgId, "TEACHER", null));
factory.NotificationClient
.GetUnreadCountAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(0);
factory.CourseClient
.GetCoursesByOrgAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<CourseDto>?)null);
var client = CreateAuthenticatedClient();
// Act
var response = await client.GetAsync("/api/dashboard");
// Assert β still a 200, not a 503
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<DashboardResponse>();
body!.Courses.Should().BeEmpty();
body.PartialFailures.Should().Contain("courses");
}
[Fact]
public async Task GET_Dashboard_ResponseContainsCorrelationIdHeader()
{
factory.UserClient
.GetProfileAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new UserProfileDto("Ingrid", "Solberg",
TestAuthHandler.OrgId, "TEACHER", null));
factory.NotificationClient
.GetUnreadCountAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(0);
factory.CourseClient
.GetCoursesByOrgAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns([]);
var client = CreateAuthenticatedClient();
var requestCorrelationId = Guid.NewGuid().ToString();
client.DefaultRequestHeaders.Add("X-Correlation-Id", requestCorrelationId);
var response = await client.GetAsync("/api/dashboard");
// The same correlation ID must be echoed back in the response
response.Headers.Should().ContainKey("X-Correlation-Id");
response.Headers.GetValues("X-Correlation-Id").First()
.Should().Be(requestCorrelationId);
}
}
The correlation ID test is worth calling out. It is an integration test concern, not a unit test concern β it verifies that the middleware is registered in the pipeline and executes correctly. A unit test of CorrelationIdMiddleware in isolation would verify the logic; this test verifies the middleware is actually wired into the application.
Layer 3: Consumer-driven contract tests with Pact
Contract tests address a problem that neither unit nor integration tests can: the two independent codebases that share an interface β the Vue application and the BFF β can each pass their own tests while simultaneously breaking the other's expectations.
Consumer-driven contract testing (CDCT) with Pact inverts the usual testing direction. The consumer (Vue application) defines what it expects from the provider (BFF) in a contract file β a Pact. The provider runs that contract against its actual implementation and verifies it is satisfied. Neither side needs to run the other's tests. The contract is the shared artefact.
Consumer side: generating the Pact in Vue
The consumer test runs in the Vue application's test suite using @pact-foundation/pact. It verifies that the Vue composables consume the BFF's response correctly, and in doing so, generates a Pact file describing exactly what the BFF must return.
// frontend/tests/contracts/dashboard.pact.spec.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact'
import path from 'path'
import { apiClient } from '@/api/client'
import type { DashboardResponse } from '@/api/types'
const { like, eachLike, string, integer, boolean } = MatchersV3
const provider = new PactV3({
consumer: 'education-platform-vue',
provider: 'education-platform-bff',
dir: path.resolve(__dirname, '../../pacts'),
port: 4321
})
describe('Dashboard contract', () => {
it('returns a valid dashboard response for an authenticated user', async () => {
await provider
.addInteraction({
states: [{ description: 'user ingrid.solberg@skole.no exists with courses' }],
uponReceiving: 'a GET request for the dashboard',
withRequest: {
method: 'GET',
path: '/api/dashboard',
headers: { Accept: 'application/json' }
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
user: like({
displayName: string('Ingrid Solberg'),
role: string('Teacher'),
avatarUrl: string('/media/avatars/i-solberg.jpg')
}),
courses: eachLike({
id: string('c-1'),
title: string('Mathematics β Year 9'),
code: string('MATH-9'),
enrollmentLabel: string('24 / 30'),
enrollmentPercent: integer(80),
status: string('Active')
}),
upcomingSessions: eachLike({
id: string('s-1'),
title: string('Integration review'),
startsAt: string('2025-04-08T09:00:00'),
courseTitle: string('Mathematics β Year 9'),
locationLabel: string('Room 204')
}),
notifications: like({
count: integer(3)
}),
partialFailures: []
}
}
})
.executeTest(async mockServer => {
// Point the API client at the Pact mock server
const original = globalThis.fetch
globalThis.fetch = (input, init) => {
const url = typeof input === 'string'
? input.replace('/api', mockServer.url)
: input
return original(url, init)
}
const data = await apiClient.get<DashboardResponse>('/dashboard')
// Verify the composable can handle the response
expect(data.user.displayName).toBeTruthy()
expect(data.courses).toBeInstanceOf(Array)
expect(data.notifications.count).toBeGreaterThanOrEqual(0)
expect(data.partialFailures).toBeInstanceOf(Array)
})
})
it('returns 401 for unauthenticated requests', async () => {
await provider
.addInteraction({
states: [{ description: 'no authenticated session' }],
uponReceiving: 'an unauthenticated GET request for the dashboard',
withRequest: {
method: 'GET',
path: '/api/dashboard'
},
willRespondWith: {
status: 401,
body: like({
title: string('Unauthorized'),
status: integer(401)
})
}
})
.executeTest(async mockServer => {
const original = globalThis.fetch
globalThis.fetch = (input, init) =>
original(typeof input === 'string'
? input.replace('/api', mockServer.url) : input, init)
await expect(
apiClient.get<DashboardResponse>('/dashboard')
).rejects.toMatchObject({ status: 401 })
})
})
})
Running this test suite generates a Pact file at pacts/education-platform-vue-education-platform-bff.json. This file is the contract β the formal record of what the Vue application expects.
Provider side: verifying the Pact in the BFF
The BFF verifies the generated Pact against its actual implementation using PactNet and WebApplicationFactory. The verification spins up the real BFF (with substituted upstream clients), executes each interaction defined in the Pact, and asserts the response matches the contract.
// EducationPlatform.Bff.ContractTests/DashboardPactProviderTests.cs
public class DashboardPactProviderTests : IClassFixture<BffWebApplicationFactory>
{
private readonly BffWebApplicationFactory _factory;
private readonly ITestOutputHelper _output;
public DashboardPactProviderTests(
BffWebApplicationFactory factory,
ITestOutputHelper output)
{
_factory = factory;
_output = output;
}
[Fact]
public async Task BFF_SatisfiesVueConsumerContract()
{
// Arrange β set up state handlers to satisfy Pact provider states
SetupProviderStates();
// Start the BFF on a random port using WebApplicationFactory
var server = _factory.Server;
server.BaseAddress = new Uri("http://localhost");
var config = new PactVerifierConfig
{
Outputters = [new XUnitOutput(_output)],
LogLevel = PactLogLevel.Information
};
var pactPath = Path.Combine(
Directory.GetCurrentDirectory(),
"..", "..", "..", "..", "..", // Navigate to repo root
"frontend", "pacts",
"education-platform-vue-education-platform-bff.json");
// Act & Assert
await new PactVerifier("education-platform-bff", config)
.WithHttpEndpoint(server.BaseAddress)
.WithFileSource(new FileInfo(pactPath))
.WithProviderStateUrl(new Uri(server.BaseAddress, "/pact/provider-states"))
.VerifyAsync();
}
private void SetupProviderStates()
{
// "user ingrid.solberg@skole.no exists with courses"
_factory.UserClient
.GetProfileAsync("ingrid.solberg@skole.no", Arg.Any<CancellationToken>())
.Returns(new UserProfileDto("Ingrid", "Solberg", "uninett", "TEACHER",
"i-solberg.jpg"));
_factory.CourseClient
.GetCoursesByOrgAsync("uninett", Arg.Any<CancellationToken>())
.Returns([
new CourseDto("c-1",
new CourseMetadataDto("Mathematics β Year 9", "MATH-9", null!),
new EnrollmentDto(30, 24, 0),
new CourseStatusDto("ACTIVE", DateTimeOffset.UtcNow))
]);
_factory.SessionClient
.GetUpcomingAsync(Arg.Any<string[]>(), 3, Arg.Any<CancellationToken>())
.Returns([
new SessionDto("s-1", "Integration review",
DateTimeOffset.Parse("2025-04-08T09:00:00"),
"Mathematics β Year 9", Room: "204")
]);
_factory.NotificationClient
.GetUnreadCountAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(3);
// "no authenticated session" β no setup needed; TestAuthFactory
// returns 401 for requests without the X-Test-Auth header
}
}
The provider state endpoint is a lightweight Minimal API registered in the test factory that the Pact verifier calls before each interaction to set up the correct data state:
// BffWebApplicationFactory.cs β add provider state endpoint
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services => { /* ... existing setup ... */ });
builder.Configure(app =>
{
// Provider states endpoint β only registered in test environment
app.Map("/pact/provider-states", stateApp =>
{
stateApp.Run(async ctx =>
{
var body = await ctx.Request.ReadFromJsonAsync<ProviderStateRequest>();
// State setup is handled by the substitute configuration above
// This endpoint just acknowledges the state transition
ctx.Response.StatusCode = 200;
await ctx.Response.WriteAsJsonAsync(new { acknowledged = true });
});
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints => endpoints.MapControllers());
});
}
private sealed record ProviderStateRequest(string State, string Consumer);
The CI workflow for contract tests
The Pact file flows through CI as an artefact. The Vue consumer tests generate it; the BFF provider tests verify it:
# .github/workflows/contract-tests.yml
name: Contract Tests
on: [push, pull_request]
jobs:
consumer-generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Run consumer contract tests
working-directory: ./frontend
run: npm run test:contracts
- name: Upload Pact file
uses: actions/upload-artifact@v4
with:
name: pact-files
path: frontend/pacts/*.json
provider-verify:
runs-on: ubuntu-latest
needs: consumer-generate
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.0.x' }
- name: Download Pact file
uses: actions/download-artifact@v4
with:
name: pact-files
path: frontend/pacts
- name: Run provider verification
run: dotnet test EducationPlatform.Bff.ContractTests
The provider-verify job depends on consumer-generate. If the Vue consumer tests fail to generate a valid Pact file, the provider verification never runs. If the BFF's implementation no longer satisfies the Pact, the provider verification fails and the build is blocked. The contract mismatch surfaces in CI before either codebase reaches staging.
What the production system learned about testing
Integration tests caught the OnRedirectToLogin bug before it reached staging. The test GET_Dashboard_UnauthenticatedRequest_Returns401 was written after the bug was discovered in local development. Its presence in the integration suite meant it was caught in CI on every subsequent pull request. Tests written in response to bugs are the highest-return tests in a suite.
Pact contract tests caught a field rename. In the second month of the project, the BFF renamed avatarUrl to profileImageUrl in a refactor. The TypeScript type check in the Vue application caught it for components that referenced the field directly. The Pact consumer test caught it for the contract β the generated Pact still specified avatarUrl, and the BFF provider verification failed because the response now contained profileImageUrl. The fix was a conscious decision: keep avatarUrl for backward compatibility, add profileImageUrl as an alias, then migrate in a subsequent release.
The [Theory] / [InlineData] pattern found the division-by-zero bug. The enrollment percentage calculation was tested only for the typical case initially. Adding [InlineData(0, 0, 0)] to the theory immediately failed β the implementation did not guard against zero capacity. The guard was added in the same commit. Without the theory, this would have been a production error on the first course with no enrollment configured.
What comes next
The final article in the core series brings the operational picture together: structured logging, distributed tracing, and Azure Application Insights β how to make the running BFF observable, how to connect the traces from Vue through the BFF to upstream services, and what the dashboard configuration looks like when you need to diagnose an incident at 2am.





