Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Represents the **NuGet** versions.

## v6.1.0
- *Enhancement:* Added `FunctionsWebhookClient(HttpClient)` for posting Functions webhook events without an Aspire fixture; the caller retains ownership of the supplied client and returned responses.
- *Enhancement:* Added framework-neutral `WithFunctionTester<TEntryPoint>` for fixture-level testing and disposal, with an optional `TestFrameworkImplementor` factory also supported by `FunctionTester.Create` and the concrete tester constructor.

## v6.0.0
- *Breaking:* Target frameworks are now `net8.0`, `net9.0` and `net10.0` only; `net6.0` and `net7.0` (both out of support) have been removed.
- *Breaking:* `UnitTestEx.Azure.Functions` supports the Azure Functions **v4 isolated worker** model only; in-process (WebJobs) support has been removed, including the `FunctionsStartup` entry point and `CreateWebJobsServiceBus*MessageActions`. See [Migrating from v5 to v6](./docs/migration-v6.md).
Expand Down
2 changes: 1 addition & 1 deletion Common.targets
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>6.0.0</Version>
<Version>6.1.0</Version>
<LangVersion>latest</LangVersion>
<Authors>Avanade</Authors>
<Company>Avanade</Company>
Expand Down
96 changes: 96 additions & 0 deletions clean-build-outputs.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Removes generated Debug and Release build outputs across the repository to recover disk space.

.DESCRIPTION
Deletes the per-configuration directories beneath each project's bin and obj directories (for example
bin\Debug, bin\Release, obj\Debug and obj\Release). NuGet restore assets directly within obj (such as
project.assets.json) are retained, so a subsequent build does not require a restore.

Source, NuGet caches, containers and database volumes are never touched. All outputs are regenerated on
the next build; the trade-off is losing incremental-build state, so that build is slower.

Use -WhatIf to preview what would be removed.

.PARAMETER Configuration
The build configurations to remove. Defaults to Debug and Release.

.PARAMETER ShutdownBuildServers
Shuts down MSBuild/compiler build servers first to release any file locks.

.EXAMPLE
./clean-build-outputs.ps1
./clean-build-outputs.ps1 -WhatIf
./clean-build-outputs.ps1 -Configuration Release
./clean-build-outputs.ps1 -ShutdownBuildServers
#>

[CmdletBinding(SupportsShouldProcess)]
param(
[string[]]$Configuration = @("Debug", "Release"),
[switch]$ShutdownBuildServers = $false
)

$ErrorActionPreference = "Stop"

$repoRoot = $PSScriptRoot
$searchRoots = @("src", "tests") |
ForEach-Object { Join-Path $repoRoot $_ } |
Where-Object { Test-Path $_ }

if ($ShutdownBuildServers -and $PSCmdlet.ShouldProcess("dotnet build servers", "Shut down")) {
dotnet build-server shutdown | Out-Null
}

$targets = foreach ($root in $searchRoots) {
Get-ChildItem -LiteralPath $root -Directory -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -in @("bin", "obj") } |
ForEach-Object {
foreach ($config in $Configuration) {
$path = Join-Path $_.FullName $config
if (Test-Path -LiteralPath $path) { Get-Item -LiteralPath $path }
Comment on lines +51 to +52
}
}
}

if (-not $targets) {
Write-Output "Nothing to clean for configuration(s): $($Configuration -join ', ')."
return
}

# Drop targets nested within another target (e.g. obj\Debug\net8.0\WorkerExtensions\bin\Release); they are removed along with their parent.
$targets = @($targets | Sort-Object FullName -Unique)
$targets = $targets | Where-Object {
$path = $_.FullName
-not ($targets | Where-Object { $path.StartsWith($_.FullName + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) })
}

$totalBytes = 0L
$removed = 0
$failed = @()

foreach ($target in $targets) {
$bytes = (Get-ChildItem -LiteralPath $target.FullName -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
$relative = $target.FullName.Substring($repoRoot.Length).TrimStart('\', '/')

if ($PSCmdlet.ShouldProcess($relative, "Remove ($([math]::Round($bytes / 1MB, 1)) MB)")) {
try {
Remove-Item -LiteralPath $target.FullName -Recurse -Force
$totalBytes += $bytes
$removed++
}
catch {
$failed += $relative
Write-Warning "Could not remove '$relative' (likely locked by a running process): $($_.Exception.Message)"
}
}
}

if ($WhatIfPreference) { return }

Write-Output "Removed $removed director$(if ($removed -eq 1) { 'y' } else { 'ies' }); recovered $([math]::Round($totalBytes / 1GB, 2)) GB."
if ($failed) {
Write-Warning "$($failed.Count) director$(if ($failed.Count -eq 1) { 'y' } else { 'ies' }) could not be removed. Close Visual Studio/running hosts, or rerun with -ShutdownBuildServers."
exit 1
}
11 changes: 11 additions & 0 deletions docs/aspire.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,17 @@ response.EnsureSuccessStatusCode();

Webhook payloads preserve each event's `Topic`; when it is absent or empty, the helper supplies `UnitTestEx` as the placeholder required by the runtime webhook.

To use an already-running Functions host without creating an Aspire fixture, construct `UnitTestEx.Aspire.FunctionsWebhookClient` with an existing `HttpClient`. This still requires the `UnitTestEx.Aspire` package:

```csharp
using var httpClient = new HttpClient { BaseAddress = new Uri("http://localhost:7071/") };
var webhookClient = new UnitTestEx.Aspire.FunctionsWebhookClient(httpClient);
using var response = await webhookClient.PostBlobCreatedAsync("Host.Functions.CustomerImport", created);
response.EnsureSuccessStatusCode();
```

Set `BaseAddress` to the Functions host endpoint (including a trailing `/` for any path prefix) and configure any required authentication on the supplied client. The caller retains ownership: `FunctionsWebhookClient` never disposes or reconfigures that client, including after failed or canceled requests. It can be reused for multiple webhook calls; dispose it only after those calls finish. Each returned response must also be disposed by the caller. The Aspire `tester.FunctionsWebhook(...)` path continues to create and dispose its own HTTP client per request.

`WaitForResourceAsync("functions")` only waits for Aspire's resource health check; an HTTP health endpoint can respond before the Functions isolated worker has finished indexing. Use `WaitForFunctionsAsync` when a test needs function registration to be ready:

```csharp
Expand Down
46 changes: 46 additions & 0 deletions docs/azure-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,52 @@ Scopes created by `HttpTrigger`, `ServiceBusTrigger`, and `Trigger` belong to th

<br/>

## Fixture-level testing

`WithFunctionTester<TEntryPoint>` (in the `UnitTestEx` namespace, package `UnitTestEx.Azure.Functions`) exposes a `Test` property and implements `IDisposable` to release the tester, its trigger scopes and its host. The host is built lazily, so configure `Test` before accessing its services or invoking a function. There are no application-start lifecycle hooks because the worker is never started.

For an NUnit fixture that shares one tester across its test methods:

```csharp
[TestFixture]
public sealed class ProductFunctionTests : WithFunctionTester<ProductFunction>
{
[Test]
public void GetProduct()
{
Test.HttpTrigger<ProductFunction>()
.Run(f => f.Run(Test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc"))
.AssertOK();
}
}
```

The protected constructor accepts an optional `Func<TestFrameworkImplementor> createFactory`, like `WithAspireTester`. Passing `null` uses the normal framework resolution. An xUnit test class can pass its output helper explicitly:

```csharp
public sealed class ProductFunctionTests : WithFunctionTester<ProductFunction>
{
public ProductFunctionTests(ITestOutputHelper output)
: base(() => XunitTestImplementor.Create(output)) { }

[Fact]
public void GetProduct()
{
Test.HttpTrigger<ProductFunction>()
.Run(f => f.Run(Test.CreateHttpRequest(HttpMethod.Get, "product/abc"), "abc"))
.AssertOK();
}
}
```

`XunitTestImplementor` is in `UnitTestEx.Xunit.Internal`; `ITestOutputHelper` is in `Xunit.Abstractions`. The explicit factory is also available through `FunctionTester.Create<TEntryPoint>(createFactory: ...)` and the concrete tester constructor; existing configuration arguments retain their order.

Sharing follows the framework's fixture lifetime. xUnit creates a test-class instance per test, so inheriting this base does **not** share the host across methods. Use an xUnit class or collection fixture for that lifetime; rebind its shared tester to the current test's output with `fixture.Test.ReplaceTestFrameworkImplementor(XunitTestImplementor.Create(output))` in the test-class constructor, rather than retaining one test's output helper. Ensure the owning fixture is disposed. Shared hosts and tester state can contaminate later tests; isolate mutable state, use `Test.ResetHost()` when needed, and do not use the shared tester concurrently.

Examples and lifecycle regressions: [`WithFunctionTesterTest` (NUnit)](../tests/UnitTestEx.NUnit.Test/WithFunctionTesterTest.cs), [`WithFunctionTesterTest` (xUnit)](../tests/UnitTestEx.Xunit.Test/WithFunctionTesterTest.cs).

<br/>

## HTTP triggers

Both HTTP programming models are supported:
Expand Down
21 changes: 20 additions & 1 deletion src/UnitTestEx.Aspire/Aspire/FunctionsWebhookClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,29 @@ namespace UnitTestEx.Aspire
public sealed class FunctionsWebhookClient
{
private readonly Func<Task<HttpClient>> _httpClientFactory;
private readonly bool _disposeHttpClient = true;

/// <summary>
/// Creates a webhook client using an existing HTTP client without starting an Aspire application.
/// </summary>
/// <param name="httpClient">The HTTP client with <see cref="HttpClient.BaseAddress"/> set to the Functions host endpoint.</param>
/// <remarks>
/// The caller retains ownership of <paramref name="httpClient"/> and is responsible for disposing it.
/// This webhook client never disposes or reconfigures it. The caller must also dispose each returned response.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="httpClient"/> is <see langword="null"/>.</exception>
public FunctionsWebhookClient(HttpClient httpClient)
{
ArgumentNullException.ThrowIfNull(httpClient);
_httpClientFactory = () => Task.FromResult(httpClient);
_disposeHttpClient = false;
}

/// <summary>
/// Creates a webhook client that obtains its HTTP client from the specified factory.
/// </summary>
/// <param name="httpClientFactory">The factory that creates an HTTP client for the Functions resource.</param>
/// <remarks>Each factory-created HTTP client is disposed after its request completes.</remarks>
internal FunctionsWebhookClient(Func<Task<HttpClient>> httpClientFactory) => _httpClientFactory = httpClientFactory;

/// <summary>
Expand Down Expand Up @@ -77,7 +95,8 @@ private async Task<HttpResponseMessage> PostEventsAsync(string route, string fun
var payload = JsonSerializer.Serialize(eventArray.Select(ToEventGridSchemaEvent));
var requestUri = $"{route}?functionName={Uri.EscapeDataString(functionName)}";

using var client = await _httpClientFactory().ConfigureAwait(false);
var client = await _httpClientFactory().ConfigureAwait(false);
using var ownedClient = _disposeHttpClient ? client : null;
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx

using System;
using System.Collections.Generic;
using UnitTestEx.Abstractions;

namespace UnitTestEx.Azure.Functions
{
/// <summary>
/// Provides the <b>NUnit</b> <see cref="FunctionTesterBase{TEntryPoint, TSelf}"/> implementation.
/// Provides the concrete <see cref="FunctionTesterBase{TEntryPoint, TSelf}"/> implementation.
/// </summary>
/// <typeparam name="TEntryPoint">Any <see cref="System.Type"/> within the Azure Functions isolated-worker application assembly.</typeparam>
/// <param name="includeUnitTestConfiguration">Indicates whether to include '<c>appsettings.unittest.json</c>' configuration file.</param>
/// <param name="includeUserSecrets">Indicates whether to include user secrets.</param>
/// <param name="additionalConfiguration">Additional configuration values to add/override.</param>
public class FunctionTester<TEntryPoint>(bool? includeUnitTestConfiguration, bool? includeUserSecrets, IEnumerable<KeyValuePair<string, string?>>? additionalConfiguration)
: FunctionTesterBase<TEntryPoint, FunctionTester<TEntryPoint>>(TestFrameworkImplementor.Create(), includeUnitTestConfiguration, includeUserSecrets, additionalConfiguration) where TEntryPoint : class
/// <param name="createFactory">The optional function to create the <see cref="TestFrameworkImplementor"/> instance.</param>
public class FunctionTester<TEntryPoint>(bool? includeUnitTestConfiguration, bool? includeUserSecrets, IEnumerable<KeyValuePair<string, string?>>? additionalConfiguration, Func<TestFrameworkImplementor>? createFactory = null)
: FunctionTesterBase<TEntryPoint, FunctionTester<TEntryPoint>>(TestFrameworkImplementor.Create(createFactory), includeUnitTestConfiguration, includeUserSecrets, additionalConfiguration) where TEntryPoint : class
{ }
}
6 changes: 4 additions & 2 deletions src/UnitTestEx.Azure.Functions/FunctionTester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using System;
using System.Collections.Generic;
using UnitTestEx.Abstractions;
using UnitTestEx.Azure.Functions;

namespace UnitTestEx
Expand All @@ -18,9 +19,10 @@ public static class FunctionTester
/// <param name="includeUnitTestConfiguration">Indicates whether to include '<c>appsettings.unittest.json</c>' configuration file.</param>
/// <param name="includeUserSecrets">Indicates whether to include user secrets.</param>
/// <param name="additionalConfiguration">Additional configuration values to add/override.</param>
/// <param name="createFactory">The optional function to create the <see cref="TestFrameworkImplementor"/> instance.</param>
/// <returns>The <see cref="FunctionTester{TEntryPoint}"/>.</returns>
public static FunctionTester<TEntryPoint> Create<TEntryPoint>(bool? includeUnitTestConfiguration = null, bool? includeUserSecrets = null, IEnumerable<KeyValuePair<string, string?>>? additionalConfiguration = null)
public static FunctionTester<TEntryPoint> Create<TEntryPoint>(bool? includeUnitTestConfiguration = null, bool? includeUserSecrets = null, IEnumerable<KeyValuePair<string, string?>>? additionalConfiguration = null, Func<TestFrameworkImplementor>? createFactory = null)
where TEntryPoint : class
=> new(includeUnitTestConfiguration, includeUserSecrets, additionalConfiguration);
=> new(includeUnitTestConfiguration, includeUserSecrets, additionalConfiguration, createFactory);
}
}
55 changes: 55 additions & 0 deletions src/UnitTestEx.Azure.Functions/WithFunctionTester.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/UnitTestEx

using System;
using UnitTestEx.Abstractions;
using UnitTestEx.Azure.Functions;

namespace UnitTestEx
{
/// <summary>
/// Provides a shared <see cref="FunctionTester{TEntryPoint}"/> for tests that reuse the same underlying Azure Functions application host.
/// </summary>
/// <typeparam name="TEntryPoint">Any <see cref="Type"/> within the Azure Functions isolated-worker application assembly.</typeparam>
/// <remarks>Implements <see cref="IDisposable"/> to dispose the tester and its host. The host is built lazily and never started.
/// Sharing depends on the test framework's fixture lifetime; isolate mutable state and do not use the shared tester concurrently.</remarks>
public abstract class WithFunctionTester<TEntryPoint> : IDisposable where TEntryPoint : class
{
private FunctionTester<TEntryPoint>? _functionTester;

/// <summary>
/// Initializes a new instance of the <see cref="WithFunctionTester{TEntryPoint}"/> class.
/// </summary>
/// <param name="createFactory">The optional function to create the <see cref="TestFrameworkImplementor"/> instance.</param>
protected WithFunctionTester(Func<TestFrameworkImplementor>? createFactory = null)
{
_functionTester = FunctionTester.Create<TEntryPoint>(createFactory: createFactory);
}

/// <summary>
/// Gets the underlying <see cref="FunctionTester{TEntryPoint}"/> for testing.
/// </summary>
/// <exception cref="ObjectDisposedException">The fixture has been disposed.</exception>
public FunctionTester<TEntryPoint> Test => _functionTester ?? throw new ObjectDisposedException(nameof(Test));

/// <summary>
/// Releases the managed resources when disposing.
/// </summary>
/// <param name="disposing">Indicates whether to release managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
var tester = _functionTester;
_functionTester = null;
tester?.Dispose();
}
}

/// <inheritdoc/>
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}
Loading
Loading