[Test] Add integration test (#76)

* Add PartitionedProducerProcess.

* Remove PartitionedProducerState.

* Add UpdatePartitions Event.

* Implement PartitionedProducer.

* Fix some problems

* Remove PartitionedProducerProcess.

* Make some classes sealed.

* Use await in PartitionedProducer.Send

* Fix test method name.

* Make CreateSubProducers sync.

* Fix some comments.

* Subproducers

* Change PartitionedProducer to Producer and add some comments.

* Add UpdatePartitions to connect logic and fix some other problems.

* Add integration test.

* Improve integration framework

* Update and add fixture.

* Add integration test CI.

* fix docker compose file not exist in stress test

* fix

* remove testcontainer

* fix ci job name.

* Fix lookup exception not retry.

* Update integration test

* Use async Task instead of async void.
diff --git a/.github/workflows/ci-integration-test.yaml b/.github/workflows/ci-integration-test.yaml
new file mode 100644
index 0000000..7fd8cda
--- /dev/null
+++ b/.github/workflows/ci-integration-test.yaml
@@ -0,0 +1,60 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+name: CI - Integration Test
+on:
+  pull_request:
+    branches:
+      - master
+  push:
+    branches:
+      - master
+
+jobs:
+  integration-tests:
+    runs-on: ubuntu-latest
+    timeout-minutes: 120
+    steps:
+      - name: checkout
+        uses: actions/checkout@main
+
+      - name: Setup dotnet
+        uses: actions/setup-dotnet@v1
+        with:
+          dotnet-version: '5.0.x'
+        
+      - name: run integration tests
+        run: |
+          export PULSAR_DEPLOYMENT_TYPE=container
+          dotnet test ./tests/DotPulsar.IntegrationTests/DotPulsar.IntegrationTests.csproj --logger "trx;verbosity=detailed"
+
+      - name: package artifacts
+        if: failure()
+        run: |
+          rm -rf artifacts
+          mkdir artifacts
+          find . -type d -name "TestResults" -exec cp --parents -R {} artifacts/ \;
+          zip -r artifacts.zip artifacts
+
+      - name: upload artifacts
+        uses: actions/upload-artifact@master
+        if: failure()
+        with:
+          name: artifacts
+          path: artifacts.zip
diff --git a/DotPulsar.sln b/DotPulsar.sln
index 5300e55..9c4a0e3 100644
--- a/DotPulsar.sln
+++ b/DotPulsar.sln
@@ -26,6 +26,8 @@
 		README.md = README.md
 	EndProjectSection
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotPulsar.IntegrationTests", "tests\DotPulsar.IntegrationTests\DotPulsar.IntegrationTests.csproj", "{B44E52DB-DB45-4E31-AA2C-68E5C52AFDEB}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -56,6 +58,10 @@
 		{6D44683B-865C-4D15-9F0A-1A8441354589}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{6D44683B-865C-4D15-9F0A-1A8441354589}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{6D44683B-865C-4D15-9F0A-1A8441354589}.Release|Any CPU.Build.0 = Release|Any CPU
+		{B44E52DB-DB45-4E31-AA2C-68E5C52AFDEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{B44E52DB-DB45-4E31-AA2C-68E5C52AFDEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{B44E52DB-DB45-4E31-AA2C-68E5C52AFDEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{B44E52DB-DB45-4E31-AA2C-68E5C52AFDEB}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -66,6 +72,7 @@
 		{2A810EB9-45CE-4593-8E4C-026E0CBB3C42} = {E7106D0F-B255-4631-9FB8-734FC5748FA9}
 		{14934BED-A222-47B2-A58A-CFC4AAB89B49} = {E7106D0F-B255-4631-9FB8-734FC5748FA9}
 		{6D44683B-865C-4D15-9F0A-1A8441354589} = {E7106D0F-B255-4631-9FB8-734FC5748FA9}
+		{B44E52DB-DB45-4E31-AA2C-68E5C52AFDEB} = {E1C932A9-6D4C-4DDF-8922-BE7B71F12F1C}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {88355922-E70A-4B73-B7F8-ABF8F2B59789}
diff --git a/src/DotPulsar/Internal/DefaultExceptionHandler.cs b/src/DotPulsar/Internal/DefaultExceptionHandler.cs
index a783827..f29d269 100644
--- a/src/DotPulsar/Internal/DefaultExceptionHandler.cs
+++ b/src/DotPulsar/Internal/DefaultExceptionHandler.cs
@@ -51,6 +51,7 @@
                 AsyncLockDisposedException _ => FaultAction.Retry,
                 PulsarStreamDisposedException _ => FaultAction.Retry,
                 AsyncQueueDisposedException _ => FaultAction.Retry,
+                LookupNotReadyException _ => FaultAction.Retry,
                 OperationCanceledException _ => cancellationToken.IsCancellationRequested ? FaultAction.Rethrow : FaultAction.Retry,
                 DotPulsarException _ => FaultAction.Rethrow,
                 SocketException socketException => socketException.SocketErrorCode switch
diff --git a/tests/DotPulsar.IntegrationTests/Abstraction/IPulsarService.cs b/tests/DotPulsar.IntegrationTests/Abstraction/IPulsarService.cs
new file mode 100644
index 0000000..84dfec9
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/Abstraction/IPulsarService.cs
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+namespace DotPulsar.IntegrationTests.Abstraction
+{
+    using System;
+    using System.Net.Http;
+    using System.Threading.Tasks;
+    using Xunit;
+
+    /// <summary>
+    /// Pulsar Service interface
+    /// </summary>
+    public interface IPulsarService : IAsyncLifetime
+    {
+        /// <summary>
+        /// Get broker binary protocol uri
+        /// </summary>
+        Uri GetBrokerUri();
+
+        /// <summary>
+        /// Get broker rest uri
+        /// </summary>
+        Uri GetWebServiceUri();
+
+        /// <summary>
+        /// Create a partitioned topic
+        /// The format of the restTopic must be `{schema}/{tenant}/{namespace}/{topicName}`
+        /// For example, `persistent/public/default/test-topic`
+        /// </summary>
+        Task<HttpResponseMessage?> CreatePartitionedTopic(string restTopic, int numPartitions);
+    }
+}
diff --git a/tests/DotPulsar.IntegrationTests/DotPulsar.IntegrationTests.csproj b/tests/DotPulsar.IntegrationTests/DotPulsar.IntegrationTests.csproj
new file mode 100644
index 0000000..3f569d1
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/DotPulsar.IntegrationTests.csproj
@@ -0,0 +1,33 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net5.0</TargetFramework>
+
+        <IsPackable>false</IsPackable>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <PackageReference Include="FluentAssertions" Version="5.10.3" />
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
+        <PackageReference Include="xunit" Version="2.4.1" />
+        <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
+            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+            <PrivateAssets>all</PrivateAssets>
+        </PackageReference>
+        <PackageReference Include="coverlet.collector" Version="1.3.0">
+            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+            <PrivateAssets>all</PrivateAssets>
+        </PackageReference>
+    </ItemGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\..\src\DotPulsar\DotPulsar.csproj" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <None Update="docker-compose-standalone-tests.yml">
+        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+      </None>
+    </ItemGroup>
+
+</Project>
diff --git a/tests/DotPulsar.IntegrationTests/Fixtures/StandaloneClusterFixture.cs b/tests/DotPulsar.IntegrationTests/Fixtures/StandaloneClusterFixture.cs
new file mode 100644
index 0000000..eb2d072
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/Fixtures/StandaloneClusterFixture.cs
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+
+namespace DotPulsar.IntegrationTests.Fixtures
+{
+    using Abstraction;
+    using Services;
+    using System.Threading.Tasks;
+    using Xunit;
+
+    public class StandaloneClusterFixture : IAsyncLifetime
+    {
+        public IPulsarService? PulsarService { private set; get; }
+
+        public async Task InitializeAsync()
+        {
+            PulsarService = ServiceFactory.CreatePulsarService();
+            await PulsarService.InitializeAsync();
+        }
+
+        public async Task DisposeAsync()
+        {
+            if (PulsarService != null)
+                await PulsarService.DisposeAsync();
+        }
+    }
+}
diff --git a/tests/DotPulsar.IntegrationTests/Fixtures/StandaloneClusterTests.cs b/tests/DotPulsar.IntegrationTests/Fixtures/StandaloneClusterTests.cs
new file mode 100644
index 0000000..51ba95d
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/Fixtures/StandaloneClusterTests.cs
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+
+namespace DotPulsar.IntegrationTests.Fixtures
+{
+    using Xunit;
+
+    [CollectionDefinition(nameof(StandaloneClusterTest))]
+    public class StandaloneClusterTest : ICollectionFixture<StandaloneClusterFixture> { }
+}
diff --git a/tests/DotPulsar.IntegrationTests/ProducerTests.cs b/tests/DotPulsar.IntegrationTests/ProducerTests.cs
new file mode 100644
index 0000000..bcbaf69
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/ProducerTests.cs
@@ -0,0 +1,138 @@
+/*
+ * 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.
+ */
+
+namespace DotPulsar.IntegrationTests
+{
+    using Abstraction;
+    using Abstractions;
+    using Extensions;
+    using Fixtures;
+    using FluentAssertions;
+    using System;
+    using System.Collections.Generic;
+    using System.Diagnostics;
+    using System.Linq;
+    using System.Threading.Tasks;
+    using Xunit;
+    using Xunit.Abstractions;
+
+    [Collection(nameof(StandaloneClusterTest))]
+    public class ProducerTests
+    {
+        private readonly ITestOutputHelper _testOutputHelper;
+        private readonly IPulsarService _pulsarService;
+
+        public ProducerTests(ITestOutputHelper outputHelper, StandaloneClusterFixture fixture)
+        {
+            _testOutputHelper = outputHelper;
+            Debug.Assert(fixture.PulsarService != null, "fixture.PulsarService != null");
+            _pulsarService = fixture.PulsarService;
+        }
+
+        [Fact]
+        public async Task SimpleProduceConsume_WhenSendingMessagesToProducer_ThenReceiveMessagesFromConsumer()
+        {
+            //Arrange
+            await using var client = PulsarClient.Builder().ServiceUrl(_pulsarService.GetBrokerUri()).Build();
+            string topicName = $"simple-produce-consume{Guid.NewGuid():N}";
+            const string content = "test-message";
+
+            //Act
+            await using var producer = client.NewProducer(Schema.String)
+                .Topic(topicName)
+                .Create();
+
+            await using var consumer = client.NewConsumer(Schema.String)
+                .Topic(topicName)
+                .SubscriptionName("test-sub")
+                .InitialPosition(SubscriptionInitialPosition.Earliest)
+                .Create();
+
+            await producer.Send(content);
+            _testOutputHelper.WriteLine($"Sent a message: {content}");
+
+            //Assert
+            (await consumer.Receive()).Value().Should().Be(content);
+        }
+
+        [Fact]
+        public async Task SinglePartition_WhenSendMessages_ThenGetMessagesFromSinglePartition()
+        {
+            //Arrange
+            await using var client = PulsarClient.Builder().ServiceUrl(_pulsarService.GetBrokerUri()).Build();
+            string topicName = $"single-partitioned-{Guid.NewGuid():N}";
+            const string content = "test-message";
+            const int partitions = 3;
+            var consumers = new List<IConsumer<string>>();
+
+            await _pulsarService.CreatePartitionedTopic($"persistent/public/default/{topicName}", partitions);
+
+            //Act
+            await using var producer = client.NewProducer(Schema.String)
+                .Topic(topicName)
+                .Create();
+
+            for (var i = 0; i < partitions; ++i)
+            {
+                consumers.Add(client.NewConsumer(Schema.String)
+                    .Topic($"{topicName}-partition-{i}")
+                    .SubscriptionName("test-sub")
+                    .InitialPosition(SubscriptionInitialPosition.Earliest)
+                    .Create());
+            }
+
+            await producer.Send(content);
+            _testOutputHelper.WriteLine($"Sent a message: {content}");
+
+            //Assert
+            (await Task.WhenAny(consumers.Select(c => c.Receive().AsTask()).ToList())).Result.Value().Should().Be(content);
+        }
+
+        [Fact]
+        public async Task RoundRobinPartition_WhenSendMessages_ThenGetMessagesFromPartitionsInOrder()
+        {
+            //Arrange
+            await using var client = PulsarClient.Builder().ServiceUrl(_pulsarService.GetBrokerUri()).Build();
+            string topicName = $"round-robin-partitioned-{Guid.NewGuid():N}";
+            const string content = "test-message";
+            const int partitions = 3;
+            var consumers = new List<IConsumer<string>>();
+
+            await _pulsarService.CreatePartitionedTopic($"persistent/public/default/{topicName}", partitions);
+
+            //Act
+            await using var producer = client.NewProducer(Schema.String)
+                .Topic(topicName)
+                .Create();
+            await producer.StateChangedTo(ProducerState.Connected);
+
+            for (var i = 0; i < partitions; ++i)
+            {
+                consumers.Add(client.NewConsumer(Schema.String)
+                    .Topic($"{topicName}-partition-{i}")
+                    .SubscriptionName("test-sub")
+                    .InitialPosition(SubscriptionInitialPosition.Earliest)
+                    .Create());
+                await producer.Send($"{content}-{i}");
+                _testOutputHelper.WriteLine($"Sent a message to consumer [{i}]");
+            }
+
+            //Assert
+            for (var i = 0; i < partitions; ++i)
+            {
+                (await consumers[i].Receive()).Value().Should().Be($"{content}-{i}");
+            }
+        }
+    }
+}
diff --git a/tests/DotPulsar.IntegrationTests/Services/PulsarServiceBase.cs b/tests/DotPulsar.IntegrationTests/Services/PulsarServiceBase.cs
new file mode 100644
index 0000000..bc52005
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/Services/PulsarServiceBase.cs
@@ -0,0 +1,57 @@
+/*
+ * 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.
+ */
+
+namespace DotPulsar.IntegrationTests.Services
+{
+    using Abstraction;
+    using System;
+    using System.Net.Http;
+    using System.Text;
+    using System.Threading;
+    using System.Threading.Tasks;
+
+    public class PulsarServiceBase : IPulsarService
+    {
+        private readonly CancellationTokenSource _cts;
+        private readonly HttpClient _adminClient;
+
+        protected PulsarServiceBase()
+        {
+            _cts = new CancellationTokenSource();
+            _adminClient = new HttpClient();
+        }
+
+        public virtual Task InitializeAsync()
+            => Task.CompletedTask;
+
+        public virtual Task DisposeAsync()
+        {
+            _adminClient.Dispose();
+            _cts.Dispose();
+            return Task.CompletedTask;
+        }
+
+        public virtual Uri GetBrokerUri()
+            => throw new NotImplementedException();
+
+        public virtual Uri GetWebServiceUri()
+            => throw new NotImplementedException();
+
+        public async Task<HttpResponseMessage?> CreatePartitionedTopic(string restTopic, int numPartitions)
+        {
+            var content = new StringContent(numPartitions.ToString(), Encoding.UTF8, "application/json");
+            return await _adminClient.PutAsync($"{GetWebServiceUri()}admin/v2/{restTopic}/partitions", content, _cts.Token).ConfigureAwait(false);
+        }
+    }
+}
diff --git a/tests/DotPulsar.IntegrationTests/Services/ServiceFactory.cs b/tests/DotPulsar.IntegrationTests/Services/ServiceFactory.cs
new file mode 100644
index 0000000..01b3c1c
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/Services/ServiceFactory.cs
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+namespace DotPulsar.IntegrationTests.Services
+{
+    using Abstraction;
+
+    public static class ServiceFactory
+    {
+        private const string PulsarDeploymentType = "PULSAR_DEPLOYMENT_TYPE";
+        private const string ContainerDeployment = "container";
+
+        public static IPulsarService CreatePulsarService()
+        {
+            var deploymentType = System.Environment.GetEnvironmentVariable(PulsarDeploymentType);
+
+            if (deploymentType == ContainerDeployment)
+            {
+                return new StandaloneContainerService();
+            }
+
+            return new StandaloneExternalService();
+        }
+    }
+}
diff --git a/tests/DotPulsar.IntegrationTests/Services/StandaloneContainerService.cs b/tests/DotPulsar.IntegrationTests/Services/StandaloneContainerService.cs
new file mode 100644
index 0000000..e711d0c
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/Services/StandaloneContainerService.cs
@@ -0,0 +1,88 @@
+/*
+ * 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.
+ */
+
+namespace DotPulsar.IntegrationTests.Services
+{
+    using System;
+    using System.Diagnostics;
+    using System.Net.Http;
+    using System.Threading.Tasks;
+
+    public sealed class StandaloneContainerService : PulsarServiceBase
+    {
+        public override async Task InitializeAsync()
+        {
+            await base.InitializeAsync().ConfigureAwait(false);
+            TakeDownPulsar(); // clean-up if anything was left running from previous run
+
+            RunProcess("docker-compose", "-f docker-compose-standalone-tests.yml up -d");
+
+            var waitTries = 10;
+
+            using var handler = new HttpClientHandler { AllowAutoRedirect = true };
+
+            using var client = new HttpClient(handler);
+
+            while (waitTries > 0)
+            {
+                try
+                {
+                    await client.GetAsync("http://localhost:54546/metrics/").ConfigureAwait(false);
+                    return;
+                }
+                catch
+                {
+                    waitTries--;
+                    await Task.Delay(5000).ConfigureAwait(false);
+                }
+            }
+
+            throw new Exception("Unable to confirm Pulsar has initialized");
+        }
+
+        public override async Task DisposeAsync()
+        {
+            await base.DisposeAsync().ConfigureAwait(false);
+            TakeDownPulsar();
+        }
+
+        private static void TakeDownPulsar()
+            => RunProcess("docker-compose", "-f docker-compose-standalone-tests.yml down");
+
+        private static void RunProcess(string name, string arguments)
+        {
+            var processStartInfo = new ProcessStartInfo { FileName = name, Arguments = arguments };
+
+            processStartInfo.Environment["TAG"] = "test";
+            processStartInfo.Environment["CONFIGURATION"] = "Debug";
+            processStartInfo.Environment["COMPUTERNAME"] = Environment.MachineName;
+
+            var process = Process.Start(processStartInfo);
+
+            if (process is null)
+                throw new Exception("Process.Start returned null");
+
+            process.WaitForExit();
+
+            if (process.ExitCode != 0)
+                throw new Exception($"Exit code {process.ExitCode} when running process {name} with arguments {arguments}");
+        }
+
+        public override Uri GetBrokerUri()
+            => new("pulsar://localhost:54545");
+
+        public override Uri GetWebServiceUri()
+            => new("http://localhost:54546");
+    }
+}
diff --git a/tests/DotPulsar.IntegrationTests/Services/StandaloneExternalService.cs b/tests/DotPulsar.IntegrationTests/Services/StandaloneExternalService.cs
new file mode 100644
index 0000000..820cecd
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/Services/StandaloneExternalService.cs
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+namespace DotPulsar.IntegrationTests.Services
+{
+    using System;
+
+    public sealed class StandaloneExternalService : PulsarServiceBase
+    {
+        public override Uri GetBrokerUri()
+            => new ("pulsar://localhost:6650");
+
+        public override Uri GetWebServiceUri()
+            => new("http://localhost:8080");
+    }
+}
diff --git a/tests/DotPulsar.IntegrationTests/docker-compose-standalone-tests.yml b/tests/DotPulsar.IntegrationTests/docker-compose-standalone-tests.yml
new file mode 100644
index 0000000..a3642ec
--- /dev/null
+++ b/tests/DotPulsar.IntegrationTests/docker-compose-standalone-tests.yml
@@ -0,0 +1,14 @@
+version: '3.5'
+
+services:
+
+  pulsar:
+    container_name: pulsar-standalone
+    image: 'apachepulsar/pulsar:2.7.0'
+    ports:
+      - '54546:8080'
+      - '54545:6650'
+    environment:
+      PULSAR_MEM: " -Xms1g -Xmx1g -XX:MaxDirectMemorySize=2g"
+    command: |
+      /bin/bash -c "bin/apply-config-from-env.py conf/standalone.conf && bin/pulsar standalone --no-functions-worker"