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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
- name: Run './build.cmd ci'
run: ./build.sh ci

# The SQL Server backed integration tests. Each test assembly starts its own SQL Server container with Testcontainers,
# The SQL Server and ActiveMQ backed integration tests. Each test assembly starts its own container with Testcontainers,
# which needs only the Docker daemon that ubuntu-latest provides.
integration-linux:
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dotnet test --project test/Spring/Spring.Core.Tests/Spring.Core.Tests.csproj -f
Fallout's `Test` target excludes `*.Integration.Tests` projects (and `Spring.Web.Conversation.NHibernate5.Tests`) by default; they need external infrastructure and are gated behind flags: `--test-full`, `--test-integration-data`, `--test-integration-nms`, `--test-integration-ms-mq`. Several test projects are Windows-only (net472: Web, Mvc5, Messaging/MSMQ).

- SQL Server-backed tests (`Spring.Data.Integration.Tests`, `Spring.Data.NHibernate5.Integration.Tests`, `Spring.Data.NHibernate5.NestedTxSuspension.Integration.Tests`, `Spring.Scheduling.Quartz3.Integration.Tests`) provision their own database with Testcontainers: a running Docker daemon is all they need. `./build.cmd IntegrationTest` runs them (CI: the `integration-linux` job); `--test-integration-data` adds them to `Test`. Each assembly's `[SetUpFixture]` calls `test/Shared/SqlServerTestEnvironment.cs` (a linked source file), which starts a pinned SQL Server container, runs `build-support/create-integration-test-databases-and-users.sql` and the project's `*_database.sql` scripts, and publishes `SPRING_TEST_SQLSERVER_*` environment variables that the XML contexts read as `${...}` placeholders. Setting `SPRING_TEST_SQLSERVER` to an admin connection string uses that server instead of a container. Tests that need MSDTC are `[Explicit]`. See README.md "Running tests".
- ActiveMQ-backed tests (`Spring.Messaging.Nms.Integration.Tests`, net10.0 and net472) provision a pinned ActiveMQ Classic container the same way, through `test/Shared/ActiveMqTestEnvironment.cs`, which publishes `SPRING_TEST_ACTIVEMQ_BROKER_URI` (`tcp://host:port`) for the XML contexts. Setting that variable beforehand uses that broker instead of a container. `./build.cmd IntegrationTest` runs this project too; `--test-integration-nms` adds it to `Test`.

## Architecture

Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<PackageVersion Include="System.Runtime.Serialization.Formatters" Version="10.0.14" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.14" />
<PackageVersion Include="Testcontainers" Version="4.16.0" />
<PackageVersion Include="Testcontainers.MsSql" Version="4.16.0" />
<PackageVersion Include="log4net" Version="3.5.0" />
</ItemGroup>
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,17 @@ suffice.
| `SPRING_TEST_SQLSERVER_USER`, `SPRING_TEST_SQLSERVER_PASSWORD` | the `springqa` login (`springqa` / `springqa`) |
| `SPRING_TEST_SQLSERVER_CS_<DATABASE>` | a complete connection string, as `springqa`, for each database the scripts created, with the name upper-cased: `..._CS_SPRING`, `..._CS_CREDITS`, `..._CS_DEBITS`, `..._CS_CREDITSANDDEBITS`, `..._CS_QUARTZ` |

- The NMS integration tests (`Spring.Messaging.Nms.Integration.Tests`) work the same way with an ActiveMQ Classic container (a pinned
`apache/activemq-classic` image, on a random host port), and `./build.cmd IntegrationTest` runs them too, on both net10.0 and
net472. The test assembly publishes the broker's address as `SPRING_TEST_ACTIVEMQ_BROKER_URI` (`tcp://host:port`), which the XML
contexts read as `${SPRING_TEST_ACTIVEMQ_BROKER_URI}`. To use a broker you already run, set that variable before the run and no
container is started (`./build.cmd IntegrationTest` then runs the net10.0 and net472 builds one after the other, because they
share the broker's queues).
- Tests that need a distributed transaction (MSDTC) are `[Explicit]`: a SQL Server container cannot take part in one. Run them
by name against a Windows server with MSDTC enabled if you work on that code.
- `./build.cmd Test --test-integration-data` also runs the four SQL Server projects as part of the `Test` target, and
`--test-full` runs every integration project, including the ones that need ActiveMQ, MSMQ or a SQLite file.
- `./build.cmd Test --test-integration-data` also runs the four SQL Server projects as part of the `Test` target,
`--test-integration-nms` the NMS project, and `--test-full` runs every integration project, including the ones that need
MSMQ or a SQLite file.


## Acknowledgements
Expand Down
23 changes: 16 additions & 7 deletions build-support/build/Build.Test.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public partial class Build
"Spring.Scheduling.Quartz3.Integration.Tests",
];

// The integration test project that provisions its own ActiveMQ broker with Testcontainers (test/Shared/ActiveMqTestEnvironment.cs).
// It needs a running Docker daemon, or a broker named by the SPRING_TEST_ACTIVEMQ_BROKER_URI environment variable.
const string NmsIntegrationTestProject = "Spring.Messaging.Nms.Integration.Tests";

// Projects that target net472 only (no net10.0 build to run on Linux).
static readonly string[] WindowsOnlyIntegrationTestProjects =
[
Expand All @@ -34,6 +38,8 @@ public partial class Build

static bool IsSqlServerIntegrationTestProject(Project project) => SqlServerIntegrationTestProjects.Contains(project.Name);

static bool IsNmsIntegrationTestProject(Project project) => project.Name == NmsIntegrationTestProject;

Target Test => _ => _
.DependsOn(Restore)
.After(Compile)
Expand All @@ -44,11 +50,12 @@ public partial class Build
// merely contain ".Test", and a Microsoft.Testing.Platform run of a project without tests is an error.
.Where(x => x.Name.EndsWith(".Tests"))
// Integration tests need infrastructure, so they run only when asked for: --test-full for all of them, or one
// flag for the projects behind it. (The four SQL Server projects also have the IntegrationTest target.)
// flag for the projects behind it. (The four SQL Server projects and the NMS project also have the IntegrationTest
// target.)
.Where(x => !x.Name.Contains(".Integration")
|| TestFull
|| (TestIntegrationData && IsSqlServerIntegrationTestProject(x))
|| (TestIntegrationNms && x.Name == "Spring.Messaging.Nms.Integration.Tests"))
|| (TestIntegrationNms && IsNmsIntegrationTestProject(x)))
.Where(x => !x.Name.Contains("Spring.Web.Conversation.NHibernate5.Tests") || TestFull)
.Where(x => !x.Name.Contains("Spring.Messaging.Tests") || TestIntegrationMsMq || TestFull)
.Where(x => !x.Name.Contains("Spring.Testing.Microsoft.Test") || EnvironmentInfo.IsWin)
Expand All @@ -59,16 +66,16 @@ public partial class Build
RunTestProjects(testTargets);
});

// Runs the four SQL Server backed integration test projects. Each starts its own SQL Server container, so a Docker
// daemon must be running (or SPRING_TEST_SQLSERVER must name a server). Unlike CI-only targets this one is meant to be
// run locally: ./build.cmd IntegrationTest
// Runs the four SQL Server backed integration test projects and the ActiveMQ backed NMS one. Each starts its own container,
// so a Docker daemon must be running (or SPRING_TEST_SQLSERVER and SPRING_TEST_ACTIVEMQ_BROKER_URI must name a server and a
// broker). Unlike CI-only targets this one is meant to be run locally: ./build.cmd IntegrationTest
Target IntegrationTest => _ => _
.DependsOn(Restore)
.After(Compile)
.Executes(() =>
{
var testTargets = GetActiveProjects()
.Where(IsSqlServerIntegrationTestProject)
.Where(x => IsSqlServerIntegrationTestProject(x) || IsNmsIntegrationTestProject(x))
.Where(x => !WindowsOnlyIntegrationTestProjects.Contains(x.Name) || EnvironmentInfo.IsWin);

RunTestProjects(testTargets);
Expand All @@ -80,8 +87,10 @@ void RunTestProjects(IEnumerable<Project> testTargets)

// Microsoft.Testing.Platform runs the modules of a multi-targeted project (net10.0 and net472) at the same time. Against
// a server named by SPRING_TEST_SQLSERVER they would drop and recreate the same databases under each other, so they run
// one after the other then. With a container per module (the default) there is nothing to share.
// one after the other then. The same goes for a broker named by SPRING_TEST_ACTIVEMQ_BROKER_URI, whose queues the modules
// would share. With a container per module (the default) there is nothing to share.
var maxParallelModules = string.IsNullOrWhiteSpace(System.Environment.GetEnvironmentVariable("SPRING_TEST_SQLSERVER"))
&& string.IsNullOrWhiteSpace(System.Environment.GetEnvironmentVariable("SPRING_TEST_ACTIVEMQ_BROKER_URI"))
? System.Environment.ProcessorCount
: 1;

Expand Down
157 changes: 157 additions & 0 deletions test/Shared/ActiveMqTestEnvironment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* Copyright 2002-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;

namespace Spring.IntegrationTests;

/// <summary>
/// Provisions the ActiveMQ broker that the messaging integration test assemblies run against, and publishes where it is.
/// </summary>
/// <remarks>
/// <para>
/// This file is compiled into each ActiveMQ backed integration test project (a linked source file, not a project of its
/// own), and the assembly's <c>[SetUpFixture]</c> calls <see cref="InitializeAsync"/> and <see cref="DisposeAsync"/>. Every
/// assembly therefore gets its own broker, on a random host port, so the assemblies can run in parallel with the SQL Server
/// backed ones (see <c>SqlServerTestEnvironment</c>) without port clashes.
/// </para>
/// <para>
/// By default an ActiveMQ Classic container is started with Testcontainers, which needs only a running Docker daemon. When
/// the environment variable <see cref="BrokerUriVariable"/> is already set, to the URI of a broker, no container is started
/// and the tests use that broker instead (for a developer without a Linux container engine).
/// </para>
/// <para>
/// Either way, the tests learn where the broker is from <see cref="BrokerUriVariable"/>. The Spring XML contexts read it
/// through a <c>${SPRING_TEST_ACTIVEMQ_BROKER_URI}</c> placeholder; C# code uses <see cref="GetBrokerUri"/>.
/// </para>
/// </remarks>
internal static class ActiveMqTestEnvironment
{
/// <summary>
/// The image the container runs: ActiveMQ Classic, whose OpenWire transport is what <c>Apache.NMS.ActiveMQ</c> speaks.
/// Pinned, so that a run does not change with the registry's "latest".
/// </summary>
public const string Image = "apache/activemq-classic:6.2.0";

/// <summary>
/// The environment variable that holds the broker's URI (<c>tcp://host:port</c>). If it is set before
/// <see cref="InitializeAsync"/> runs, that broker is used and no container is started.
/// </summary>
public const string BrokerUriVariable = "SPRING_TEST_ACTIVEMQ_BROKER_URI";

/// <summary>
/// The port of the OpenWire transport inside the container.
/// </summary>
public const int OpenWirePort = 61616;

private static readonly SemaphoreSlim s_lock = new(1, 1);
private static IContainer s_container;
private static bool s_initialized;

/// <summary>
/// Starts the broker, unless <see cref="BrokerUriVariable"/> already names one, and publishes
/// <see cref="BrokerUriVariable"/>.
/// </summary>
public static async Task InitializeAsync()
{
await s_lock.WaitAsync();
try
{
if (s_initialized)
{
return;
}

try
{
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(BrokerUriVariable)))
{
s_container = new ContainerBuilder(Image)
.WithPortBinding(OpenWirePort, true)
// The broker logs this line once the OpenWire transport accepts connections. The mapped port
// accepts a connection earlier than that, because Docker's proxy listens on behalf of the container.
.WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Connector openwire started"))
.Build();
await s_container.StartAsync();

Environment.SetEnvironmentVariable(
BrokerUriVariable,
$"tcp://{s_container.Hostname}:{s_container.GetMappedPublicPort(OpenWirePort)}");
}

s_initialized = true;
}
catch
{
// Report the failure of the setup, not one of the cleanup after it.
try
{
await DisposeContainerAsync();
}
catch (Exception)
{
// The container's Ryuk reaper removes it anyway.
}

throw;
}
}
finally
{
s_lock.Release();
}
}

/// <summary>
/// Stops the container, if one was started.
/// </summary>
public static async Task DisposeAsync()
{
await s_lock.WaitAsync();
try
{
s_initialized = false;
await DisposeContainerAsync();
}
finally
{
s_lock.Release();
}
}

/// <summary>
/// Gets the broker's URI, <c>tcp://host:port</c>.
/// </summary>
public static string GetBrokerUri()
{
return Environment.GetEnvironmentVariable(BrokerUriVariable)
?? throw new InvalidOperationException(
$"'{BrokerUriVariable}' is not set. Run the test from an assembly whose [SetUpFixture] calls ActiveMqTestEnvironment.InitializeAsync.");
}

private static async Task DisposeContainerAsync()
{
IContainer container = s_container;
s_container = null;
if (container is not null)
{
// The variable names this container's broker; do not leave it pointing at a broker that is gone.
Environment.SetEnvironmentVariable(BrokerUriVariable, null);
await container.DisposeAsync();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ public NmsTemplateTests()
this.PopulateProtectedVariables = true;
}

#if NETFRAMEWORK
[Test]
public void ConnectionThrowException()
{
// The host name never resolves (the .invalid top-level domain is reserved for that, RFC 6761), so this fails the
// same way whatever runs on the machine, unlike a closed port on localhost.
var cf = new Apache.NMS.ActiveMQ.ConnectionFactory();
cf.BrokerUri = new Uri("tcp://localaaahost:61616");
cf.BrokerUri = new Uri("tcp://spring-net-no-such-broker.invalid:61616");
Assert.Throws<NMSConnectionException>(() => cf.CreateConnection());
}
#endif

[Test]
public void ConvertAndSend()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:nms="http://www.springframework.net/nms">

<!-- The broker's URI comes from the environment: the test assembly's [SetUpFixture] starts the broker and sets it. -->
<object type="Spring.Objects.Factory.Config.VariablePlaceholderConfigurer, Spring.Core">
<property name="VariableSources">
<list>
<object type="Spring.Objects.Factory.Config.EnvironmentVariableSource, Spring.Core" />
</list>
</property>
</object>

<!-- the default ConnectionFactory -->
<object id="nmsConnectionFactory" type="Apache.NMS.ActiveMQ.ConnectionFactory, Apache.NMS.ActiveMQ">
<constructor-arg index="0" value="tcp://localhost:61616" />
<constructor-arg index="0" value="${SPRING_TEST_ACTIVEMQ_BROKER_URI}" />
</object>


Expand All @@ -14,7 +23,7 @@
<property name="SessionCacheSize" value="10" />
<property name="TargetConnectionFactory">
<object type="Apache.NMS.ActiveMQ.ConnectionFactory, Apache.NMS.ActiveMQ">
<constructor-arg index="0" value="tcp://localhost:61616" />
<constructor-arg index="0" value="${SPRING_TEST_ACTIVEMQ_BROKER_URI}" />
</object>
</property>
</object>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,28 @@ public class SimpleMessageListener : IMessageListener
{
private static readonly ILogger<SimpleMessageListener> LOG = LogManager.GetLogger<SimpleMessageListener>();

private readonly SemaphoreSlim received = new(0);

private int messageCount;

public int MessageCount
{
get { return messageCount; }
get { return Volatile.Read(ref messageCount); }
}

/// <summary>
/// Waits until a message has been delivered that no earlier call has consumed.
/// </summary>
/// <returns><c>true</c> if a message arrived within <paramref name="timeout"/>.</returns>
public bool WaitForMessage(TimeSpan timeout)
{
return received.Wait(timeout);
}

public void OnMessage(IMessage message)
{
messageCount++;
LOG.LogDebug("Message listener count = {MessageCount}", messageCount);
int count = Interlocked.Increment(ref messageCount);
LOG.LogDebug("Message listener count = {MessageCount}", count);
ITextMessage textMessage = message as ITextMessage;
if (textMessage != null)
{
Expand All @@ -43,5 +54,7 @@ public void OnMessage(IMessage message)
{
LOG.LogWarning("Can not process message of type {MessageType}", message.GetType());
}

received.Release();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public void SendAndRecieveAsync()
Assert.That(simpleMessageListener, Is.Not.Null);
Assert.That(simpleMessageListener.MessageCount, Is.EqualTo(0));
simpleGateway.Publish("CSCO", 123.45);
Thread.Sleep(1000);
Assert.That(simpleMessageListener.WaitForMessage(TimeSpan.FromSeconds(30)), Is.True, "no message was delivered to the listener");
Assert.That(simpleMessageListener.MessageCount, Is.EqualTo(1));
}

Expand Down
Loading
Loading