Test Azure SDK clients

Use XBullet.EasyTesting.Azure to build Azure SDK responses and pages, provide a deterministic credential, replace clients in DI, or exercise a real Azure client through an offline HTTP pipeline. It depends on Azure.Core; service-specific SDK packages stay in the application or test project.

Install

dotnet add package XBullet.EasyTesting.Azure

Import XBullet.EasyTesting.Azure, Azure, and Azure.Core, plus the namespace of the service-specific SDK client under test.

Choose the narrowest boundary that proves the behavior:

  • Return AzureTestData from an application-owned client abstraction for fast business tests.
  • Replace an Azure client and credential in DI to test application composition.
  • Use StubAzureHttpPipelineTransport to test the real SDK client's serialization, parsing, and retry pipeline without network access.

Raw and model responses

TestAzureResponse supplies status, headers, reason phrase, and binary or JSON content. Convert it to Response<T> with FromValue, or use AzureTestData.ModelResponse:

[Fact]
public void Response_exposes_content_headers_and_model_value()
{
    using var raw = new TestAzureResponse(201)
        .WithHeader("x-ms-request-id", "request-42")
        .WithJsonContent(new { name = "Ada" });
    var response = raw.FromValue("created");

    Assert.Equal("created", response.Value);
    Assert.Equal(201, response.GetRawResponse().Status);
    Assert.Equal("request-42", response.GetRawResponse().Headers.RequestId);
    Assert.Equal("application/json", response.GetRawResponse().Headers.ContentType);
    Assert.Contains("Ada", response.GetRawResponse().Content.ToString());
}

Create service output models with the service SDK's *ModelFactory; these helpers wrap the model in the Azure response contract rather than bypassing its public construction model.

Pageable results

Build pages with explicit continuation tokens, then combine them into synchronous or asynchronous pageable results:

[Fact]
public async Task Pageable_helpers_preserve_pages_and_continuation_tokens()
{
    var first = AzureTestData.Page([1, 2], "next");
    var last = AzureTestData.Page([3]);
    var pageable = AzureTestData.AsyncPageable(first, last);
    var pages = new List<Page<int>>();

    await foreach (var page in pageable.AsPages()
        .WithCancellation(TestContext.Current.CancellationToken))
    {
        pages.Add(page);
    }

    Assert.Equal(2, pages.Count);
    Assert.Equal("next", pages[0].ContinuationToken);
    Assert.Equal([1, 2, 3], pages.SelectMany(page => page.Values));
}

Preserve page boundaries when code under test reacts to continuation tokens or performs one action per page. A flattened list is enough when only enumeration behavior matters.

Credentials and dependency injection

TestTokenCredential returns a deterministic token, records scopes, claims, tenant, and parent request ID, and can arrange a failure:

[Fact]
public async Task Credential_returns_deterministic_token_records_context_and_can_fail()
{
    var expiresOn = new DateTimeOffset(2040, 1, 1, 0, 0, 0, TimeSpan.Zero);
    var credential = new TestTokenCredential("token-42", expiresOn);
    var context = new TokenRequestContext(
        ["https://storage.azure.com/.default"],
        parentRequestId: "parent-1",
        claims: "claims-1",
        tenantId: "tenant-1");

    var token = await credential.GetTokenAsync(
        context,
        TestContext.Current.CancellationToken);

    Assert.Equal("token-42", token.Token);
    Assert.Equal(expiresOn, token.ExpiresOn);
    var request = Assert.Single(credential.Requests);
    Assert.Equal(context.Scopes, request.Scopes);
    Assert.Equal("tenant-1", request.TenantId);

    credential.FailWith(new InvalidOperationException("Expected test failure."));
    await Assert.ThrowsAsync<InvalidOperationException>(
        async () => await credential.GetTokenAsync(
            context,
            TestContext.Current.CancellationToken));
    Assert.Equal(2, credential.RequestCount);
}

Replace Azure dependencies through the host builder:

var credential = new TestTokenCredential("local-test-token");
var secretClient = CreateTestSecretClient();

using var factory = EasyTestHost.Create<Program>()
    .UseAzureTestCredential(credential)
    .ReplaceAzureClient(secretClient)
    .Build();

factory.RegisterScenarioResource("Azure credential", credential);

Scenario registration resets captured requests and contributes non-secret credential metadata to failure diagnostics. A replacement verifies application behavior, not Azure identity validation.

Exercise a real SDK pipeline

Inject StubAzureHttpPipelineTransport into the service client's options. Ordered responses make a retry policy deterministic while the real client still creates the request and parses the result:

[Fact]
public async Task Transport_exercises_real_blob_client_and_records_retries()
{
    var transport = new StubAzureHttpPipelineTransport()
        .Respond(status: 500)
        .Respond(status: 200);
    var options = new BlobClientOptions
    {
        Transport = transport,
        Retry =
        {
            Delay = TimeSpan.Zero,
            MaxRetries = 1,
            Mode = RetryMode.Fixed
        }
    };
    var client = new BlobClient(
        new Uri("https://storage.test/container/blob"),
        new AzureSasCredential("sig=test"),
        options);

    var response = await client.ExistsAsync(TestContext.Current.CancellationToken);

    Assert.True(response.Value);
    Assert.Equal(2, transport.CallCount);
    Assert.Equal(0, transport.RemainingResponseCount);
    transport.VerifyCalled(RequestMethod.Head, "/container/blob?sig=test", expectedCount: 2);
}

The transport records method, URI, headers, and buffered content. Use VerifyCalled for an exact method and URI, or Verify with a predicate, expected count, and description. An unexpected call returns a diagnostic 501 response; response exhaustion exposes an unplanned retry.

Failure verification and secret safety

Verification output redacts SAS and other sensitive query values and excludes header values and request bodies. The recorded request remains available for exact in-memory assertions:

[Fact]
public async Task Failed_verification_redacts_actual_and_expected_SAS_query_values()
{
    var transport = new StubAzureHttpPipelineTransport().Respond();
    var client = CreateSasBlobClient(transport);

    await client.ExistsAsync(TestContext.Current.CancellationToken);

    var exactFailure = Assert.Throws<AzureTransportVerificationException>(() =>
        transport.VerifyCalled(
            RequestMethod.Head,
            "https://other.test/expected?sig=expected-signature&comp=properties"));
    var predicateFailure = Assert.Throws<AzureTransportVerificationException>(() =>
        transport.Verify(_ => false, expectedCount: 1, "the expected blob request"));

    Assert.DoesNotContain(SasSignature, exactFailure.Message);
    Assert.DoesNotContain("expected-signature", exactFailure.Message);
    Assert.DoesNotContain("https://storage.test", exactFailure.Message);
    Assert.DoesNotContain("https://other.test", exactFailure.Message);
    Assert.Contains(
        "/expected?sig={Redacted}&comp=properties",
        exactFailure.Message);
    Assert.Contains("/container/blob?comp=metadata", exactFailure.Message);
    Assert.Contains("sv={Redacted}", exactFailure.Message);
    Assert.Contains("sp={Redacted}", exactFailure.Message);
    Assert.Contains("comp=metadata", predicateFailure.Message);
    Assert.DoesNotContain(SasSignature, predicateFailure.Message);
}

Never serialize raw recorded requests into logs or snapshots without application-specific redaction. Keep synthetic tokens and SAS signatures in tests. Pipeline tests prove client behavior against arranged HTTP contracts; use an emulator or isolated Azure resource when service-side authorization, consistency, or semantics are the subject.

TestTokenCredential and StubAzureHttpPipelineTransport implement scenario-resource cleanup. Register shared instances so request history and queued responses do not cross scenario boundaries. See AzureTestingTests for validation, reset, cancellation, unarranged response, and SAS matching cases.

Browse the Azure API reference.