Skip to content

Commit e7be24d

Browse files
authored
Adjust nullability to align with IConfiguration (#431)
Move port finder out into its own class, since it wasn't really anything to do with the FunctionsController. (Although this is a breaking change, the first two builds of v3.0 failed to push anything to NuGet, so nobody can be relying on the existing behaviour yet.)
1 parent dbcf1d2 commit e7be24d

File tree

10 files changed

+151
-71
lines changed

10 files changed

+151
-71
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// <copyright file="DemoFunctionConfig.cs" company="Endjin Limited">
2+
// Copyright (c) Endjin Limited. All rights reserved.
3+
// </copyright>
4+
5+
namespace Corvus.Testing.SpecFlow.Demo.AzureFunctionsTesting
6+
{
7+
using System.Collections.Generic;
8+
9+
using Corvus.Testing.AzureFunctions;
10+
using Corvus.Testing.AzureFunctions.SpecFlow;
11+
12+
using TechTalk.SpecFlow;
13+
14+
internal static class DemoFunctionConfig
15+
{
16+
public static void SetupTestConfig(SpecFlowContext context)
17+
{
18+
FunctionConfiguration functionConfiguration = FunctionsBindings.GetFunctionConfiguration(context);
19+
var config = new Dictionary<string, string?>()
20+
{
21+
{ "ResponseMessage", "Welcome, {name}" },
22+
23+
// IConfiguration's AsEnumerable includes null-valued entries for each
24+
// section. Since the most common way to set up a function's configuration
25+
// with this library is to pass that enumeration, we need to test this.
26+
// See https://github.com/corvus-dotnet/Corvus.Testing/issues/368
27+
{ "Emulate:Null:Section:Entry", null },
28+
};
29+
functionConfiguration.CopyToEnvironmentVariables(config);
30+
}
31+
}
32+
}

Solutions/Corvus.Testing.AzureFunctions.SpecFlow.Demo/AzureFunctionsTesting/DemoFunctionPerFeatureHooks.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,14 @@ public static Task StartIsolatedFunctionsAsync(FeatureContext featureContext)
4545
[BeforeFeature("usingInProcessDemoFunctionPerFeatureWithAdditionalConfiguration")]
4646
public static Task StartInProcessFunctionWithAdditionalConfigurationAsync(FeatureContext featureContext)
4747
{
48-
FunctionConfiguration functionConfiguration = FunctionsBindings.GetFunctionConfiguration(featureContext);
49-
functionConfiguration.EnvironmentVariables.Add("ResponseMessage", "Welcome, {name}");
50-
48+
DemoFunctionConfig.SetupTestConfig(featureContext);
5149
return StartInProcessFunctionsAsync(featureContext);
5250
}
5351

5452
[BeforeFeature("usingIsolatedDemoFunctionPerFeatureWithAdditionalConfiguration")]
5553
public static Task StartIsolatedFunctionWithAdditionalConfigurationAsync(FeatureContext featureContext)
5654
{
57-
FunctionConfiguration functionConfiguration = FunctionsBindings.GetFunctionConfiguration(featureContext);
58-
functionConfiguration.EnvironmentVariables.Add("ResponseMessage", "Welcome, {name}");
59-
55+
DemoFunctionConfig.SetupTestConfig(featureContext);
6056
return StartIsolatedFunctionsAsync(featureContext);
6157
}
6258

Solutions/Corvus.Testing.AzureFunctions.SpecFlow.Demo/AzureFunctionsTesting/DemoFunctionPerScenarioHooks.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,14 @@ public static Task StartIsolatedFunctionsAsync(ScenarioContext scenarioContext)
4343
[BeforeScenario("usingInProcessDemoFunctionPerScenarioWithAdditionalConfiguration")]
4444
public static Task StartInProcessFunctionWithAdditionalConfigurationAsync(ScenarioContext scenarioContext)
4545
{
46-
FunctionConfiguration functionConfiguration = FunctionsBindings.GetFunctionConfiguration(scenarioContext);
47-
functionConfiguration.EnvironmentVariables.Add("ResponseMessage", "Welcome, {name}");
48-
46+
DemoFunctionConfig.SetupTestConfig(scenarioContext);
4947
return StartIsolatedFunctionsAsync(scenarioContext);
5048
}
5149

5250
[BeforeScenario("usingIsolatedDemoFunctionPerScenarioWithAdditionalConfiguration")]
5351
public static Task StartIsolatedFunctionWithAdditionalConfigurationAsync(ScenarioContext scenarioContext)
5452
{
55-
FunctionConfiguration functionConfiguration = FunctionsBindings.GetFunctionConfiguration(scenarioContext);
56-
functionConfiguration.EnvironmentVariables.Add("ResponseMessage", "Welcome, {name}");
57-
53+
DemoFunctionConfig.SetupTestConfig(scenarioContext);
5854
return StartIsolatedFunctionsAsync(scenarioContext);
5955
}
6056

Solutions/Corvus.Testing.AzureFunctions.SpecFlow.Demo/Corvus.Testing.AzureFunctions.SpecFlow.Demo.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
<PrivateAssets>all</PrivateAssets>
5858
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
5959
</PackageReference>
60+
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
6061
</ItemGroup>
6162

6263
<ItemGroup>

Solutions/Corvus.Testing.AzureFunctions.SpecFlow.Demo/packages.lock.json

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@
1212
"Microsoft.SourceLink.GitHub": "1.1.1"
1313
}
1414
},
15+
"Microsoft.Extensions.Configuration": {
16+
"type": "Direct",
17+
"requested": "[8.0.0, )",
18+
"resolved": "8.0.0",
19+
"contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==",
20+
"dependencies": {
21+
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
22+
"Microsoft.Extensions.Primitives": "8.0.0"
23+
}
24+
},
1525
"Roslynator.Analyzers": {
1626
"type": "Direct",
1727
"requested": "[4.9.0, )",
@@ -90,21 +100,12 @@
90100
"System.Runtime.InteropServices.RuntimeInformation": "4.0.0"
91101
}
92102
},
93-
"Microsoft.Extensions.Configuration": {
94-
"type": "Transitive",
95-
"resolved": "6.0.0",
96-
"contentHash": "tq2wXyh3fL17EMF2bXgRhU7JrbO3on93MRKYxzz4JzzvuGSA1l0W3GI9/tl8EO89TH+KWEymP7bcFway6z9fXg==",
97-
"dependencies": {
98-
"Microsoft.Extensions.Configuration.Abstractions": "6.0.0",
99-
"Microsoft.Extensions.Primitives": "6.0.0"
100-
}
101-
},
102103
"Microsoft.Extensions.Configuration.Abstractions": {
103104
"type": "Transitive",
104-
"resolved": "6.0.0",
105-
"contentHash": "qWzV9o+ZRWq+pGm+1dF+R7qTgTYoXvbyowRoBxQJGfqTpqDun2eteerjRQhq5PQ/14S+lqto3Ft4gYaRyl4rdQ==",
105+
"resolved": "8.0.0",
106+
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
106107
"dependencies": {
107-
"Microsoft.Extensions.Primitives": "6.0.0"
108+
"Microsoft.Extensions.Primitives": "8.0.0"
108109
}
109110
},
110111
"Microsoft.Extensions.Configuration.Binder": {
@@ -209,11 +210,8 @@
209210
},
210211
"Microsoft.Extensions.Primitives": {
211212
"type": "Transitive",
212-
"resolved": "6.0.0",
213-
"contentHash": "9+PnzmQFfEFNR9J2aDTfJGGupShHjOuGw4VUv+JB044biSHrnmCIMD+mJHmb2H7YryrfBEXDurxQ47gJZdCKNQ==",
214-
"dependencies": {
215-
"System.Runtime.CompilerServices.Unsafe": "6.0.0"
216-
}
213+
"resolved": "8.0.0",
214+
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
217215
},
218216
"Microsoft.NET.Test.Sdk": {
219217
"type": "Transitive",

Solutions/Corvus.Testing.AzureFunctions.Xunit.Demo/FunctionPerTestFacts.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
// ReSharper disable ArrangeThisQualifier
66
namespace Corvus.Testing.AzureFunctions.Xunit.Demo
77
{
8-
using System;
98
using System.Net;
109
using System.Reflection;
1110
using System.Threading.Tasks;
11+
1212
using global::Xunit;
1313
using global::Xunit.Abstractions;
14+
1415
using Microsoft.Extensions.Logging;
16+
1517
using Serilog;
18+
1619
using ILogger = Microsoft.Extensions.Logging.ILogger;
1720

1821
public class FunctionPerTestFacts : DemoFunctionFacts, IAsyncLifetime
@@ -35,7 +38,7 @@ public FunctionPerTestFacts(ITestOutputHelper output)
3538
.CreateLogger("Xunit Demo tests");
3639

3740
this.function = new FunctionsController(logger);
38-
this.Port = this.function.FindAvailableTcpPort(50000, 60000);
41+
this.Port = PortFinder.FindAvailableTcpPort(50000, 60000);
3942
}
4043

4144
public int Port { get; }

Solutions/Corvus.Testing.AzureFunctions/Corvus/Testing/AzureFunctions/FunctionConfigurationExtensions.cs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,44 @@ public static class FunctionConfigurationExtensions
1717
/// </summary>
1818
/// <param name="functionConfiguration">The function configuration to copy the values to.</param>
1919
/// <param name="values">The values to copy.</param>
20+
/// <remarks>
21+
/// <para>
22+
/// It may seem odd that <paramref name="values"/> permits entries where the value is null.
23+
/// This is because <c>IConfiguration</c>'s <c>AsEnumerable</c> extension method has always
24+
/// supplied null-valued items to represent sections (but didn't make that clear until they
25+
/// finally added nullabililty annotations). For example, given this:
26+
/// </para>
27+
/// <code><![CDATA[
28+
/// {
29+
/// "Root": {
30+
/// "Middle": {
31+
/// "ExplicitNull": null,
32+
/// "Value": "v"
33+
/// }
34+
/// },
35+
/// "ExplicitNull": null
36+
/// }
37+
/// ]]></code>
38+
/// <para>
39+
/// the enumeration will include <c>Root</c> and <c>Root:Middle</c> entries that have a null
40+
/// value. This wasn't obvious until <c>Microsoft.Extensions.Configuration.Abstractions</c> was
41+
/// updated with nullability annotations.
42+
/// </para>
43+
/// <para>
44+
/// The most common usage of this method is to pass that enumerable from an <c>IConfiguration</c>
45+
/// which is why this tolerates nulls.
46+
/// </para>
47+
/// </remarks>
2048
public static void CopyToEnvironmentVariables(
2149
this FunctionConfiguration functionConfiguration,
22-
IEnumerable<KeyValuePair<string, string>> values)
50+
IEnumerable<KeyValuePair<string, string?>> values)
2351
{
24-
foreach (KeyValuePair<string, string> item in values)
52+
foreach (KeyValuePair<string, string?> item in values)
2553
{
26-
functionConfiguration.EnvironmentVariables.Add(item.Key, item.Value);
54+
if (item.Value is string value)
55+
{
56+
functionConfiguration.EnvironmentVariables.Add(item.Key, value);
57+
}
2758
}
2859
}
2960
}

Solutions/Corvus.Testing.AzureFunctions/Corvus/Testing/AzureFunctions/FunctionsController.cs

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -133,34 +133,6 @@ await Task.WhenAny(
133133
this.logger.LogInformation("Function {Path} now running on port {Port}", path, port);
134134
}
135135

136-
/// <summary>
137-
/// Randomly selects a port that appears to be available for use.
138-
/// </summary>
139-
/// <param name="lowerBoundInclusive">
140-
/// The lowest port number acceptable. Defaults to 50000.
141-
/// </param>
142-
/// <param name="upperBoundExclusive">
143-
/// The port number above which no port will be selected. Defaults to 60000.
144-
/// </param>
145-
/// <returns>A port number that seems to be available.</returns>
146-
public int FindAvailableTcpPort(int? lowerBoundInclusive, int? upperBoundExclusive)
147-
{
148-
int lb = lowerBoundInclusive ?? 50000;
149-
int ub = upperBoundExclusive ?? 60000;
150-
151-
var portsInRangeInUse = IPGlobalProperties
152-
.GetIPGlobalProperties()
153-
.GetActiveTcpListeners()
154-
.Select(e => e.Port)
155-
.Where(p => p >= lb && p < ub)
156-
.ToHashSet();
157-
158-
int availablePorts = ub - lb - portsInRangeInUse.Count;
159-
int availablePortOffset = Random.Shared.Next(availablePorts);
160-
int port = Enumerable.Range(lb, ub - lb).Where(p => !portsInRangeInUse.Contains(p)).ElementAt(availablePortOffset);
161-
return port;
162-
}
163-
164136
/// <summary>
165137
/// Provides access to the output.
166138
/// </summary>
@@ -273,14 +245,6 @@ private static void KillProcessAndChildren(int pid)
273245
while (failedDueToAccessDenied && accessDenyRetryCount++ < MaxAccessDeniedRetries);
274246
}
275247

276-
private static bool IsSomethingAlreadyListeningOn(int port)
277-
{
278-
return IPGlobalProperties
279-
.GetIPGlobalProperties()
280-
.GetActiveTcpListeners()
281-
.Any(e => e.Port == port);
282-
}
283-
284248
/// <summary>
285249
/// Waits until this computer is accepting TCP requests on the specified port.
286250
/// </summary>
@@ -368,7 +332,7 @@ private static async Task WaitUntilConnectionsAcceptedAsync(int port)
368332
/// </remarks>
369333
private async Task VerifyPortNotInUseAsync(int port)
370334
{
371-
for (int tries = 0; IsSomethingAlreadyListeningOn(port); ++tries)
335+
for (int tries = 0; PortFinder.IsSomethingAlreadyListeningOn(port); ++tries)
372336
{
373337
await Task.Delay(100).ConfigureAwait(false);
374338
if (tries > 30)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// <copyright file="PortFinder.cs" company="Endjin Limited">
2+
// Copyright (c) Endjin Limited. All rights reserved.
3+
// </copyright>
4+
5+
namespace Corvus.Testing.AzureFunctions
6+
{
7+
using System;
8+
using System.Linq;
9+
using System.Net.NetworkInformation;
10+
11+
/// <summary>
12+
/// Enables tests to discover available ports.
13+
/// </summary>
14+
public static class PortFinder
15+
{
16+
/// <summary>
17+
/// Randomly selects a port that appears to be available for use.
18+
/// </summary>
19+
/// <param name="lowerBoundInclusive">
20+
/// The lowest port number acceptable. Defaults to 50000.
21+
/// </param>
22+
/// <param name="upperBoundExclusive">
23+
/// The port number above which no port will be selected. Defaults to 60000.
24+
/// </param>
25+
/// <returns>A port number that seems to be available.</returns>
26+
public static int FindAvailableTcpPort(int? lowerBoundInclusive, int? upperBoundExclusive)
27+
{
28+
int lb = lowerBoundInclusive ?? 50000;
29+
int ub = upperBoundExclusive ?? 60000;
30+
31+
var portsInRangeInUse = IPGlobalProperties
32+
.GetIPGlobalProperties()
33+
.GetActiveTcpListeners()
34+
.Select(e => e.Port)
35+
.Where(p => p >= lb && p < ub)
36+
.ToHashSet();
37+
38+
int availablePorts = ub - lb - portsInRangeInUse.Count;
39+
int availablePortOffset = Random.Shared.Next(availablePorts);
40+
int port = Enumerable.Range(lb, ub - lb).Where(p => !portsInRangeInUse.Contains(p)).ElementAt(availablePortOffset);
41+
return port;
42+
}
43+
44+
/// <summary>
45+
/// Discovers whether something on the computer is already listening for incoming
46+
/// requests on a particular port.
47+
/// </summary>
48+
/// <param name="port">The port number to check.</param>
49+
/// <returns>True if the port is currently in use.</returns>
50+
public static bool IsSomethingAlreadyListeningOn(int port)
51+
{
52+
return IPGlobalProperties
53+
.GetIPGlobalProperties()
54+
.GetActiveTcpListeners()
55+
.Any(e => e.Port == port);
56+
}
57+
}
58+
}

docs/ReleaseNotes/Corvus.Testing.v3.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ There are also breaking changes:
1111

1212
* `Corvus.Testing.SpecFlow.NUnit` no longer references Moq; projects that were relying on this package to supply that dependency will now need to add their own reference if they want to continue using Moq
1313
* if a process is already listening on the port you want the hosted function to use, we now throw an exception instead of ploughing on
14+
* The `CopyToEnvironmentVariables` extension method for `FunctionConfiguration` now takes an enumerable of `KeyValuePair<string, string?>` - the value type is now a nullable string for reasons described in https://github.com/corvus-dotnet/Corvus.Testing/issues/368
1415

1516
The reason for the change in behaviour when the port is in use is that the old behaviour often caused baffling test results. It was very easy to hit this case accidentally if you were debugging test, and then stopped debugging—terminating the debug session typically meant that the code that would have torn down the hosted test function never got a chance to run.
1617

0 commit comments

Comments
 (0)