Skip to main content

Command Palette

Search for a command to run...

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.

Published
β€’17 min read
Testing the BFF: Unit, Integration & Contract Tests

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.


☰ Series navigation

The Frontend's Contract: Building Backends for Frontends

Part 2 of 10

A practitioner's guide to the BFF pattern β€” from architectural rationale to production-grade implementation. Covers when BFF earns its complexity, how to design a clean client-specific API layer, and what it takes to run it reliably on Azure. Stack: Vue 3 Β· .NET Core 8+ Β· Azure.

Up next

Shipping BFF to Azure: Docker Images, Artifact Publishing & Azure Container Instances

Full IaaS deployment pipeline β€” building and tagging Docker images, publishing artifacts, and running the BFF on Azure Container Instances. Includes Azure Front Door routing and when API Management adds value vs noise.