diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 373695a90..e53a0a56b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index b5577e310..24f28af42 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0c9c19a44..8a841aff9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -40,6 +40,7 @@
+
diff --git a/README.md b/README.md
index f29a5cc21..df76cacce 100644
--- a/README.md
+++ b/README.md
@@ -198,10 +198,17 @@ suffice.
| `SPRING_TEST_SQLSERVER_USER`, `SPRING_TEST_SQLSERVER_PASSWORD` | the `springqa` login (`springqa` / `springqa`) |
| `SPRING_TEST_SQLSERVER_CS_` | 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
diff --git a/build-support/build/Build.Test.cs b/build-support/build/Build.Test.cs
index ee19e7a2a..d4cf34dc7 100644
--- a/build-support/build/Build.Test.cs
+++ b/build-support/build/Build.Test.cs
@@ -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 =
[
@@ -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)
@@ -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)
@@ -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);
@@ -80,8 +87,10 @@ void RunTestProjects(IEnumerable 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;
diff --git a/test/Shared/ActiveMqTestEnvironment.cs b/test/Shared/ActiveMqTestEnvironment.cs
new file mode 100644
index 000000000..5b122b332
--- /dev/null
+++ b/test/Shared/ActiveMqTestEnvironment.cs
@@ -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;
+
+///
+/// Provisions the ActiveMQ broker that the messaging integration test assemblies run against, and publishes where it is.
+///
+///
+///
+/// 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 [SetUpFixture] calls and . 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 SqlServerTestEnvironment) without port clashes.
+///
+///
+/// By default an ActiveMQ Classic container is started with Testcontainers, which needs only a running Docker daemon. When
+/// the environment variable 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).
+///
+///
+/// Either way, the tests learn where the broker is from . The Spring XML contexts read it
+/// through a ${SPRING_TEST_ACTIVEMQ_BROKER_URI} placeholder; C# code uses .
+///
+///
+internal static class ActiveMqTestEnvironment
+{
+ ///
+ /// The image the container runs: ActiveMQ Classic, whose OpenWire transport is what Apache.NMS.ActiveMQ speaks.
+ /// Pinned, so that a run does not change with the registry's "latest".
+ ///
+ public const string Image = "apache/activemq-classic:6.2.0";
+
+ ///
+ /// The environment variable that holds the broker's URI (tcp://host:port). If it is set before
+ /// runs, that broker is used and no container is started.
+ ///
+ public const string BrokerUriVariable = "SPRING_TEST_ACTIVEMQ_BROKER_URI";
+
+ ///
+ /// The port of the OpenWire transport inside the container.
+ ///
+ public const int OpenWirePort = 61616;
+
+ private static readonly SemaphoreSlim s_lock = new(1, 1);
+ private static IContainer s_container;
+ private static bool s_initialized;
+
+ ///
+ /// Starts the broker, unless already names one, and publishes
+ /// .
+ ///
+ 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();
+ }
+ }
+
+ ///
+ /// Stops the container, if one was started.
+ ///
+ public static async Task DisposeAsync()
+ {
+ await s_lock.WaitAsync();
+ try
+ {
+ s_initialized = false;
+ await DisposeContainerAsync();
+ }
+ finally
+ {
+ s_lock.Release();
+ }
+ }
+
+ ///
+ /// Gets the broker's URI, tcp://host:port.
+ ///
+ 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();
+ }
+ }
+}
diff --git a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/NmsTemplateTests.cs b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/NmsTemplateTests.cs
index a21c570b2..71882179c 100644
--- a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/NmsTemplateTests.cs
+++ b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/NmsTemplateTests.cs
@@ -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(() => cf.CreateConnection());
}
-#endif
[Test]
public void ConvertAndSend()
diff --git a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/NmsTemplateTests.xml b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/NmsTemplateTests.xml
index 0dd5bb222..1af7c7144 100644
--- a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/NmsTemplateTests.xml
+++ b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/NmsTemplateTests.xml
@@ -3,9 +3,18 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:nms="http://www.springframework.net/nms">
+
+
+
-
+
@@ -14,7 +23,7 @@
-
+
diff --git a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/SimpleMessageListener.cs b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/SimpleMessageListener.cs
index 6003ef4a1..461b0469a 100644
--- a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/SimpleMessageListener.cs
+++ b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Core/SimpleMessageListener.cs
@@ -23,17 +23,28 @@ public class SimpleMessageListener : IMessageListener
{
private static readonly ILogger LOG = LogManager.GetLogger();
+ private readonly SemaphoreSlim received = new(0);
+
private int messageCount;
public int MessageCount
{
- get { return messageCount; }
+ get { return Volatile.Read(ref messageCount); }
+ }
+
+ ///
+ /// Waits until a message has been delivered that no earlier call has consumed.
+ ///
+ /// true if a message arrived within .
+ 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)
{
@@ -43,5 +54,7 @@ public void OnMessage(IMessage message)
{
LOG.LogWarning("Can not process message of type {MessageType}", message.GetType());
}
+
+ received.Release();
}
}
diff --git a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Listener/SimpleMessageListenerContainerTests.cs b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Listener/SimpleMessageListenerContainerTests.cs
index b7f70337e..8229f1cd8 100644
--- a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Listener/SimpleMessageListenerContainerTests.cs
+++ b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Listener/SimpleMessageListenerContainerTests.cs
@@ -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));
}
diff --git a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Listener/SimpleMessageListenerContainerTests.xml b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Listener/SimpleMessageListenerContainerTests.xml
index 68e1e8bf3..52ed4ec50 100644
--- a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Listener/SimpleMessageListenerContainerTests.xml
+++ b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Messaging/Nms/Listener/SimpleMessageListenerContainerTests.xml
@@ -3,9 +3,18 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:nms="http://www.springframework.net/nms">
+
+
+
+
+
+
+
+
+
-
+
@@ -14,7 +23,7 @@
-
+
diff --git a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Spring.Messaging.Nms.Integration.Tests.csproj b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Spring.Messaging.Nms.Integration.Tests.csproj
index 4d0d08d77..e01c9e9a6 100644
--- a/test/Spring/Spring.Messaging.Nms.Integration.Tests/Spring.Messaging.Nms.Integration.Tests.csproj
+++ b/test/Spring/Spring.Messaging.Nms.Integration.Tests/Spring.Messaging.Nms.Integration.Tests.csproj
@@ -1,6 +1,6 @@
- $(TargetFullFrameworkVersion)
+ net10.0;$(TargetFullFrameworkVersion)
@@ -11,7 +11,11 @@
-
+
+
+
+
+
diff --git a/test/Spring/Spring.Messaging.Nms.Integration.Tests/TestAssemblySetup.cs b/test/Spring/Spring.Messaging.Nms.Integration.Tests/TestAssemblySetup.cs
new file mode 100644
index 000000000..92395a98c
--- /dev/null
+++ b/test/Spring/Spring.Messaging.Nms.Integration.Tests/TestAssemblySetup.cs
@@ -0,0 +1,38 @@
+/*
+ * 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 NUnit.Framework;
+using Spring.IntegrationTests;
+
+///
+/// Starts the ActiveMQ broker the tests of this assembly run against (see ), once,
+/// before the first of them.
+///
+[SetUpFixture]
+public class TestAssemblySetup
+{
+ [OneTimeSetUp]
+ public async Task SetUp()
+ {
+ await ActiveMqTestEnvironment.InitializeAsync();
+ }
+
+ [OneTimeTearDown]
+ public async Task TearDown()
+ {
+ await ActiveMqTestEnvironment.DisposeAsync();
+ }
+}