diff --git a/examples/Elastic.TUnit.ExampleComplex/Clusters.cs b/examples/Elastic.TUnit.ExampleComplex/Clusters.cs
index 0be9caa..fcdc9ac 100644
--- a/examples/Elastic.TUnit.ExampleComplex/Clusters.cs
+++ b/examples/Elastic.TUnit.ExampleComplex/Clusters.cs
@@ -2,6 +2,7 @@
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information
+using System;
using Elastic.Clients.Elasticsearch;
using Elastic.TUnit.Elasticsearch;
using Elastic.TUnit.Elasticsearch.Core;
@@ -11,6 +12,8 @@ namespace Elastic.TUnit.ExampleComplex;
///
/// Shared base — inherits the default Client from .
+/// Env-var detection (TEST_ELASTICSEARCH_URL / TEST_ELASTICSEARCH_API_KEY) is handled
+/// automatically by the base class — no code changes needed.
///
public abstract class MyClusterBase : ElasticsearchCluster
{
@@ -46,6 +49,10 @@ public TestGenericCluster() : base(new ElasticsearchConfiguration("latest-9"))
var settings = new ElasticsearchClientSettings(pool)
.EnableDebugMode()
.OnRequestCompleted(call => output.WriteLine(call.DebugInformation));
+
+ if (ExternalApiKey != null)
+ settings = settings.Authentication(new ApiKey(ExternalApiKey));
+
return new ElasticsearchClient(settings);
});
@@ -54,3 +61,27 @@ protected override void SeedCluster()
var response = Client.Info();
}
}
+
+///
+/// Demonstrates the programmatic hook — override
+/// to point at a specific cluster from code (e.g. read from a config file, service
+/// discovery, etc.). Falls through to ephemeral startup when returning null.
+///
+public class ProgrammaticExternalCluster : ElasticsearchCluster
+{
+ public ProgrammaticExternalCluster() : base(new ElasticsearchConfiguration("latest-9"))
+ {
+ }
+
+ protected override ExternalClusterConfiguration TryUseExternalCluster()
+ {
+ var url = Environment.GetEnvironmentVariable("MY_DEV_CLUSTER_URL");
+ if (string.IsNullOrEmpty(url))
+ return null;
+
+ return new ExternalClusterConfiguration(
+ new Uri(url),
+ Environment.GetEnvironmentVariable("MY_DEV_CLUSTER_KEY")
+ );
+ }
+}
diff --git a/src/Elastic.TUnit.Elasticsearch.Core/ElasticsearchCluster{TConfiguration}.cs b/src/Elastic.TUnit.Elasticsearch.Core/ElasticsearchCluster{TConfiguration}.cs
index 990bd1d..d147539 100644
--- a/src/Elastic.TUnit.Elasticsearch.Core/ElasticsearchCluster{TConfiguration}.cs
+++ b/src/Elastic.TUnit.Elasticsearch.Core/ElasticsearchCluster{TConfiguration}.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
@@ -21,6 +22,20 @@ namespace Elastic.TUnit.Elasticsearch.Core;
/// Base class for an Elasticsearch cluster that integrates with TUnit's lifecycle.
/// Implements to start the cluster and
/// to tear it down.
+///
+/// Supports skipping ephemeral cluster startup when an external cluster is
+/// available. The resolution order in is:
+///
+/// -
+/// — override for programmatic control
+///
+/// -
+/// TEST_ELASTICSEARCH_URL environment variable (with optional
+/// TEST_ELASTICSEARCH_API_KEY)
+///
+/// - Start an ephemeral cluster as usual
+///
+///
///
public abstract class ElasticsearchCluster : EphemeralCluster,
IAsyncInitializer, IAsyncDisposable
@@ -35,14 +50,46 @@ public abstract class ElasticsearchCluster : EphemeralCluster
internal static ConcurrentDictionary ClusterVersions { get; } = new();
+ private ExternalClusterConfiguration _externalCluster;
+
protected ElasticsearchCluster(TConfiguration configuration) : base(configuration)
{
}
+ ///
+ /// Whether this cluster is backed by an external (remote) Elasticsearch instance
+ /// rather than a locally managed ephemeral process.
+ ///
+ public bool IsExternal => _externalCluster != null;
+
+ ///
+ /// The API key for the external cluster, or null if not applicable.
+ ///
+ public string ExternalApiKey => _externalCluster?.ApiKey;
+
+ ///
+ /// Override to programmatically provide an external Elasticsearch cluster,
+ /// skipping ephemeral cluster startup entirely. Return null to fall
+ /// through to environment variable detection and then ephemeral startup.
+ ///
+ protected virtual ExternalClusterConfiguration TryUseExternalCluster() => null;
+
+ ///
+ /// Returns the node URIs for this cluster. When connected to an external cluster,
+ /// returns the external URI; otherwise delegates to the ephemeral cluster's nodes.
+ ///
+ public override ICollection NodesUris(string hostName = null) =>
+ _externalCluster != null ? [_externalCluster.Uri] : base.NodesUris(hostName);
+
///
/// Starts the Elasticsearch cluster. Cluster startups are serialized via a
/// semaphore since Elasticsearch is resource-intensive.
///
+ /// Before starting an ephemeral cluster, checks for an external cluster
+ /// via and then the
+ /// TEST_ELASTICSEARCH_URL environment variable.
+ ///
+ ///
/// Bootstrap output mode is determined by
/// :
///
@@ -57,6 +104,16 @@ protected ElasticsearchCluster(TConfiguration configuration) : base(configuratio
///
public async Task InitializeAsync()
{
+ var external = ResolveExternalCluster();
+ if (external != null)
+ {
+ await external.ValidateAsync().ConfigureAwait(false);
+ _externalCluster = external;
+ ClusterVersions[GetType()] = ClusterConfiguration.Version;
+ WriteExternalClusterInfo(external);
+ return;
+ }
+
await StartupSemaphore.WaitAsync().ConfigureAwait(false);
try
{
@@ -87,11 +144,12 @@ public async Task InitializeAsync()
}
///
- /// Disposes the Elasticsearch cluster.
+ /// Disposes the Elasticsearch cluster. No-op when using an external cluster.
///
public ValueTask DisposeAsync()
{
- Dispose();
+ if (_externalCluster == null)
+ Dispose();
return default;
}
@@ -111,6 +169,35 @@ private IConsoleLineHandler CreateBootstrapWriter()
};
}
+ private ExternalClusterConfiguration ResolveExternalCluster()
+ {
+ var programmatic = TryUseExternalCluster();
+ if (programmatic != null)
+ return programmatic;
+
+ var url = Environment.GetEnvironmentVariable("TEST_ELASTICSEARCH_URL");
+ if (string.IsNullOrWhiteSpace(url))
+ return null;
+
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ throw new InvalidOperationException(
+ $"TEST_ELASTICSEARCH_URL is set but is not a valid URI: {url}");
+
+ var apiKey = Environment.GetEnvironmentVariable("TEST_ELASTICSEARCH_API_KEY");
+ return new ExternalClusterConfiguration(uri, string.IsNullOrWhiteSpace(apiKey) ? null : apiKey);
+ }
+
+ private void WriteExternalClusterInfo(ExternalClusterConfiguration external)
+ {
+ var name = GetType().Name;
+ var auth = string.IsNullOrEmpty(external.ApiKey) ? "no auth" : "API key";
+ var message = $"[{name}] using external cluster at {external.Uri} ({auth}) — skipping ephemeral startup";
+
+ using var stdout = new System.IO.StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true };
+ stdout.WriteLine(message);
+ TestContext.Current?.Output.WriteLine(message);
+ }
+
private void WriteBootstrapFailure(Exception ex)
{
var sb = new StringBuilder();
diff --git a/src/Elastic.TUnit.Elasticsearch.Core/ExternalClusterConfiguration.cs b/src/Elastic.TUnit.Elasticsearch.Core/ExternalClusterConfiguration.cs
new file mode 100644
index 0000000..2866f6c
--- /dev/null
+++ b/src/Elastic.TUnit.Elasticsearch.Core/ExternalClusterConfiguration.cs
@@ -0,0 +1,57 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Elastic.TUnit.Elasticsearch.Core;
+
+///
+/// Connection details for an externally managed Elasticsearch cluster.
+/// Used by to skip
+/// ephemeral cluster startup when a remote cluster is available.
+///
+public class ExternalClusterConfiguration
+{
+ public ExternalClusterConfiguration(Uri uri, string apiKey = null) =>
+ (Uri, ApiKey) = (uri ?? throw new ArgumentNullException(nameof(uri)), apiKey);
+
+ /// The base URI of the external Elasticsearch cluster.
+ public Uri Uri { get; }
+
+ /// An optional API key for authenticating with the cluster.
+ public string ApiKey { get; }
+
+ ///
+ /// Validates that the external cluster is reachable by issuing a GET request to the root endpoint.
+ /// Throws with a descriptive message on failure.
+ ///
+ public async Task ValidateAsync(TimeSpan? timeout = null, CancellationToken ctx = default)
+ {
+ timeout ??= TimeSpan.FromSeconds(10);
+
+ using var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ using var http = new HttpClient(handler) { Timeout = timeout.Value };
+
+ if (!string.IsNullOrEmpty(ApiKey))
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("ApiKey", ApiKey);
+
+ try
+ {
+ var response = await http.GetAsync(Uri, ctx).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ $"External Elasticsearch cluster at {Uri} is not reachable: {ex.Message}", ex);
+ }
+ }
+}
diff --git a/src/Elastic.TUnit.Elasticsearch.Core/README.md b/src/Elastic.TUnit.Elasticsearch.Core/README.md
index 3e92b30..12cf381 100644
--- a/src/Elastic.TUnit.Elasticsearch.Core/README.md
+++ b/src/Elastic.TUnit.Elasticsearch.Core/README.md
@@ -25,6 +25,7 @@ It depends on this package transitively and adds:
- `ElasticsearchCluster` — generic cluster base with TUnit lifecycle integration
- `ElasticsearchConfiguration` — cluster configuration with bootstrap diagnostics settings
+- `ExternalClusterConfiguration` — connection details for remote Elasticsearch clusters
- `ElasticsearchClusterExtensions` — `GetOrAddClient` helpers for caching any client type
- `ElasticsearchTestHooks` — global `[BeforeEvery(Test)]` hook for skip evaluation
- `ElasticsearchTestBase` — optional convenience base class
@@ -32,6 +33,28 @@ It depends on this package transitively and adds:
- `SkipVersionAttribute` / `SkipTestAttribute` — version-based and custom skip conditions
- Bootstrap diagnostics writers (ANSI console, progress heartbeat)
+## External cluster support
+
+`ElasticsearchCluster` supports skipping ephemeral cluster startup when a
+remote Elasticsearch instance is already available. This significantly speeds up development
+feedback loops since you don't need to wait for Elasticsearch to bootstrap on every test run.
+
+The resolution order during `InitializeAsync()` is:
+
+1. **Programmatic hook** — override `TryUseExternalCluster()` to return an
+ `ExternalClusterConfiguration`, or `null` to fall through
+2. **Environment variables** — set `TEST_ELASTICSEARCH_URL` (and optionally
+ `TEST_ELASTICSEARCH_API_KEY`)
+3. **Ephemeral startup** — download, install, and start Elasticsearch locally
+
+When using an external cluster:
+
+- `NodesUris()` returns the external URI
+- `IsExternal` is `true`
+- `ExternalApiKey` contains the API key (if provided)
+- `Dispose()` is a no-op (the remote cluster is not managed)
+- Connectivity is validated with a GET `/` before tests run
+
## Dependencies
- `TUnit.Core`
diff --git a/src/Elastic.TUnit.Elasticsearch/ElasticsearchCluster.cs b/src/Elastic.TUnit.Elasticsearch/ElasticsearchCluster.cs
index a2383f2..1d82189 100644
--- a/src/Elastic.TUnit.Elasticsearch/ElasticsearchCluster.cs
+++ b/src/Elastic.TUnit.Elasticsearch/ElasticsearchCluster.cs
@@ -18,6 +18,11 @@ namespace Elastic.TUnit.Elasticsearch;
/// with debug mode enabled and per-request diagnostics routed to the
/// current TUnit test's output. Override to customize.
///
+///
+/// When connected to an external cluster (via TEST_ELASTICSEARCH_URL or
+/// ),
+/// the client is automatically configured with the external API key if provided.
+///
///
public class ElasticsearchCluster : ElasticsearchCluster
{
@@ -40,12 +45,18 @@ public ElasticsearchCluster(ElasticsearchConfiguration configuration)
/// A default configured with debug mode
/// and per-request diagnostics routed to the current TUnit test's output.
/// The client is cached for the cluster's lifetime.
+ /// When using an external cluster with an API key, authentication is
+ /// configured automatically.
/// Override to customize connection settings, authentication, serialization, etc.
///
public virtual ElasticsearchClient Client => this.GetOrAddClient((c, output) =>
{
var settings = new ElasticsearchClientSettings(new StaticNodePool(c.NodesUris()))
.WireTUnitOutput(output);
+
+ if (ExternalApiKey != null)
+ settings = settings.Authentication(new ApiKey(ExternalApiKey));
+
return new ElasticsearchClient(settings);
});
}
diff --git a/src/Elastic.TUnit.Elasticsearch/README.md b/src/Elastic.TUnit.Elasticsearch/README.md
index ae759ae..ff7cd31 100644
--- a/src/Elastic.TUnit.Elasticsearch/README.md
+++ b/src/Elastic.TUnit.Elasticsearch/README.md
@@ -4,7 +4,7 @@ Write integration tests against Elasticsearch using [TUnit](https://tunit.dev) a
[`Elastic.Clients.Elasticsearch`](https://www.nuget.org/packages/Elastic.Clients.Elasticsearch/).
This is the recommended package for most users — it builds on
-[`Elastic.TUnit`](https://www.nuget.org/packages/Elastic.TUnit/) and adds a convenience
+[`Elastic.TUnit.Elasticsearch.Core`](https://www.nuget.org/packages/Elastic.TUnit.Elasticsearch.Core/) and adds a convenience
`ElasticsearchCluster` base class with a pre-configured `ElasticsearchClient`.
## Getting started
@@ -23,7 +23,7 @@ This is the recommended package for most users — it builds on
```
-`Elastic.Clients.Elasticsearch` and `Elastic.TUnit` are included as transitive dependencies.
+`Elastic.Clients.Elasticsearch` and `Elastic.TUnit.Elasticsearch.Core` are included as transitive dependencies.
### Define a cluster
@@ -63,6 +63,75 @@ reference the same key.
dotnet run --project MyTests/
```
+## Using an external cluster
+
+When developing integration tests, waiting for an ephemeral cluster to start on every run
+can be slow. You can point tests at an already-running Elasticsearch instance instead.
+
+### Environment variables (zero code changes)
+
+Set `TEST_ELASTICSEARCH_URL` and optionally `TEST_ELASTICSEARCH_API_KEY`:
+
+```bash
+# Basic — no authentication
+TEST_ELASTICSEARCH_URL=https://localhost:9200 dotnet run --project MyTests/
+
+# With API key authentication
+TEST_ELASTICSEARCH_URL=https://localhost:9200 \
+TEST_ELASTICSEARCH_API_KEY=your-api-key-here \
+dotnet run --project MyTests/
+```
+
+When `TEST_ELASTICSEARCH_URL` is set, the cluster validates connectivity (GET `/`)
+and skips ephemeral startup entirely. The default `Client` on `ElasticsearchCluster`
+automatically picks up the API key.
+
+### Programmatic hook
+
+Override `TryUseExternalCluster()` for custom logic — service discovery, config files,
+conditional per-developer overrides, etc.:
+
+```csharp
+public class MyTestCluster : ElasticsearchCluster
+{
+ public MyTestCluster() : base(new ElasticsearchConfiguration("latest-9")) { }
+
+ protected override ExternalClusterConfiguration TryUseExternalCluster()
+ {
+ var url = Environment.GetEnvironmentVariable("MY_DEV_CLUSTER_URL");
+ if (string.IsNullOrEmpty(url))
+ return null; // fall through to ephemeral startup
+
+ return new ExternalClusterConfiguration(
+ new Uri(url),
+ Environment.GetEnvironmentVariable("MY_DEV_CLUSTER_KEY")
+ );
+ }
+}
+```
+
+The resolution order is:
+
+1. `TryUseExternalCluster()` override (programmatic hook)
+2. `TEST_ELASTICSEARCH_URL` environment variable
+3. Start an ephemeral cluster
+
+### Inspecting external cluster state
+
+The cluster exposes `IsExternal` and `ExternalApiKey` properties:
+
+```csharp
+[Test]
+public async Task SomeTest()
+{
+ if (cluster.IsExternal)
+ TestContext.Current.Output.WriteLine("Running against external cluster");
+
+ var info = await cluster.Client.InfoAsync();
+ await Assert.That(info.IsValidResponse).IsTrue();
+}
+```
+
## Features
### Custom client configuration
@@ -87,6 +156,22 @@ public class MyTestCluster() : ElasticsearchCluster("latest-9")
The `.WireTUnitOutput(output)` extension enables debug mode and routes per-request diagnostics
to the current test's output.
+When overriding `Client` and using external clusters, check `ExternalApiKey` to wire
+authentication:
+
+```csharp
+public override ElasticsearchClient Client => this.GetOrAddClient((c, output) =>
+{
+ var settings = new ElasticsearchClientSettings(new StaticNodePool(c.NodesUris()))
+ .WireTUnitOutput(output);
+
+ if (ExternalApiKey != null)
+ settings = settings.Authentication(new ApiKey(ExternalApiKey));
+
+ return new ElasticsearchClient(settings);
+});
+```
+
For multiple clusters that share the same client setup, use a base class:
```csharp
@@ -228,3 +313,4 @@ public class SecurityCluster : ElasticsearchCluster
| Client access | `cluster.GetOrAddClient(...)` in test | `cluster.Client` on cluster |
| Parallel control | `ElasticXunitRunOptions.MaxConcurrency` | `[ParallelLimiter]` |
| Cluster partitioning | `Nullean.Xunit.Partitions` | `SharedType.Keyed` |
+| External cluster | `IntegrationTestsMayUseAlreadyRunningNode` | `TEST_ELASTICSEARCH_URL` env var or `TryUseExternalCluster()` override |