From 78cdbeeabe811aeda723869adaf873c07e971c68 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 4 Nov 2026 01:04:52 +0200 Subject: [PATCH] Packet N1: run the NMS integration tests on an ActiveMQ Testcontainer Spring.Messaging.Nms.Integration.Tests now provisions its own broker: an apache/activemq-classic:6.2.0 container, started with Testcontainers' generic ContainerBuilder, on a random host port, and ready once the broker logs that the OpenWire connector started. test/Shared/ActiveMqTestEnvironment.cs (linked, shaped like SqlServerTestEnvironment) publishes SPRING_TEST_ACTIVEMQ_BROKER_URI; if that variable is already set, no container is started. - The project multi-targets net10.0 and net472: Apache.NMS.ActiveMQ 2.3.0 is netstandard2.0, so the net472-only reference was a leftover. - The XML contexts read the broker URI through VariablePlaceholderConfigurer. - ConnectionThrowException runs on both TFMs and uses a host that can never resolve (.invalid); the listener test waits on a semaphore, not a sleep. - The IntegrationTest target runs the project; --test-integration-nms still adds it to Test. An external broker or SQL Server serializes the modules. - Directory.Packages.props: Testcontainers 4.16.0 (same as Testcontainers.MsSql). - README and AGENTS.md describe the broker and the override variable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LGkLQuAuUjvRjrU1AcTsmk Signed-off-by: Marko Lahma --- .github/workflows/ci.yml | 2 +- AGENTS.md | 1 + Directory.Packages.props | 1 + README.md | 11 +- build-support/build/Build.Test.cs | 23 ++- test/Shared/ActiveMqTestEnvironment.cs | 157 ++++++++++++++++++ .../Messaging/Nms/Core/NmsTemplateTests.cs | 6 +- .../Messaging/Nms/Core/NmsTemplateTests.xml | 13 +- .../Nms/Core/SimpleMessageListener.cs | 19 ++- .../SimpleMessageListenerContainerTests.cs | 2 +- .../SimpleMessageListenerContainerTests.xml | 13 +- ...ing.Messaging.Nms.Integration.Tests.csproj | 8 +- .../TestAssemblySetup.cs | 38 +++++ 13 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 test/Shared/ActiveMqTestEnvironment.cs create mode 100644 test/Spring/Spring.Messaging.Nms.Integration.Tests/TestAssemblySetup.cs 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(); + } +}