Skip to content

Add Elastic.Elasticsearch.TUnit — TUnit integration for ephemeral Elasticsearch clusters#75

Merged
Mpdreamz merged 7 commits intomasterfrom
feature/tunit-integration
Feb 17, 2026
Merged

Add Elastic.Elasticsearch.TUnit — TUnit integration for ephemeral Elasticsearch clusters#75
Mpdreamz merged 7 commits intomasterfrom
feature/tunit-integration

Conversation

@Mpdreamz
Copy link
Copy Markdown
Member

@Mpdreamz Mpdreamz commented Feb 17, 2026

Summary

Elastic.Elasticsearch.TUnit — a new package that integrates ephemeral Elasticsearch clusters with TUnit.

Define a cluster, write tests, and get a real Elasticsearch instance spun up automatically. No boilerplate, no custom test frameworks, no marker interfaces.

Minimal example — this is the entire setup

public class MyTestCluster() : ElasticsearchCluster("latest-9");

[ClassDataSource<MyTestCluster>(Shared = SharedType.Keyed, Key = nameof(MyTestCluster))]
public class MyTests(MyTestCluster cluster)
{
    [Test]
    public async Task InfoReturnsNodeName()
    {
        var info = await cluster.Client.InfoAsync();
        await Assert.That(info.Name).IsNotNull();
    }
}

The base class provides a default Client with debug mode enabled and per-request diagnostics routed to the currently executing test. Override Client when you need custom authentication, serialization, or connection settings.

What you get out of the box

  • Sane defaults everywhere — one-liner cluster definition, built-in client with debug mode, automatic download/install/start/teardown
  • Per-test output capture — each test's request/response diagnostics appear in that test's TUnit output via TestContext.Current, even though the client is shared across all tests. This means you can inspect individual test output in your IDE
  • Bootstrap diagnostics that don't fight TUnit — cluster startup output bypasses TUnit's per-test capture and writes directly to the terminal so you can always see what Elasticsearch is doing during startup:
    • Interactive terminals — a periodic heartbeat every 5 seconds showing the cluster name, location, elapsed time, and the last Elasticsearch log line. Keeps your terminal clean while still showing progress during the 60+ second startup
    • CI / non-interactive — full verbose ANSI-colored output with per-node log lines, so you have the complete picture in CI logs
    • Configurable via ShowBootstrapDiagnostics (true = always full, false = silent, null = auto-detect)
  • Bootstrap result always in TestContext — whether the cluster started successfully or failed, the result summary is written to TestContext.Current so it shows up in your IDE's test output. On failure, per-node diagnostics (started status, port, version, last exception) are included
  • Version-skip and custom skip attributes[SkipVersion("<8.0.0", "reason")] evaluates against the running cluster's actual version; extend SkipTestAttribute for arbitrary skip conditions
  • Parallel limiting[ParallelLimiter<ElasticsearchParallelLimit>] caps test concurrency per Environment.ProcessorCount
  • Cluster sharing — TUnit's SharedType.Keyed means the cluster starts once and is shared across all test classes that reference the same key, with serialized startup across different cluster types

More involved example

public abstract class MyClusterBase : ElasticsearchCluster
{
    protected MyClusterBase() : base(new ElasticsearchConfiguration("latest-9")
    {
        ShowElasticsearchOutputAfterStarted = false,
    }) { }
}

public class TestCluster : MyClusterBase
{
    protected override void SeedCluster()
    {
        var response = Client.Info();
    }
}

[SkipVersion("<6.2.0", "")]
[ClassDataSource<TestCluster>(Shared = SharedType.Keyed, Key = nameof(TestCluster))]
public class SkippableTests(TestCluster cluster)
{
    [Test]
    public async Task SomeTest()
    {
        var info = await cluster.Client.InfoAsync();
        await Assert.That(info.IsValidResponse).IsTrue();
    }
}

Interactive output

Locally you will get progress updates every 5 seconds to see where it is in the bootstrapping without overwhelming the console.

image

…lasticsearch clusters

The existing xUnit integration (`Elastic.Elasticsearch.Xunit`) requires substantial ceremony:
a custom `TestFramework`, a custom `TestAssemblyRunner`, a third-party partitioning library,
custom test discoverers, and marker interfaces. TUnit's built-in primitives eliminate all of
this, making integration tests against Elasticsearch dramatically simpler to set up and run.

## Why

Getting a one-node Elasticsearch cluster into a TUnit test should be a one-liner cluster
definition and a single attribute — no assembly-level wiring, no marker interfaces, no
special `[I]` / `[U]` attributes. This library makes that possible:

```csharp
public class MyCluster() : ElasticsearchCluster("latest-9");

[ClassDataSource<MyCluster>(Shared = SharedType.Keyed, Key = nameof(MyCluster))]
public class MyTests(MyCluster cluster)
{
    [Test]
    public async Task InfoReturnsNodeName()
    {
        var client = cluster.GetOrAddClient((c, output) =>
        {
            var settings = new ElasticsearchClientSettings(new StaticNodePool(c.NodesUris()))
                .EnableDebugMode()
                .OnRequestCompleted(call => output.WriteLine(call.DebugInformation));
            return new ElasticsearchClient(settings);
        });

        await Assert.That(client.Info().Name).IsNotNull();
    }
}
```

## Bootstrap diagnostics

Cluster startup takes 60+ seconds. Rather than silence or a wall of logs, the library
auto-detects the environment and picks the right output level:

**Interactive terminal (default)** — periodic heartbeat so you know it's alive:
```
Bootstrapping MyTestCluster in /tmp/es-tunit-abc [5s]  [INFO ][o.e.n.Node] initializing...
Bootstrapping MyTestCluster in /tmp/es-tunit-abc [10s] [INFO ][o.e.e.NodeEnvironment] using [1] ...
Bootstrapping MyTestCluster in /tmp/es-tunit-abc [45s] [INFO ][o.e.h.AbstractHttpServerTransport] ...
```

**CI (GitHub Actions, TeamCity, Jenkins, etc.)** — full verbose ANSI-colored output of every
log line, matching what `LineHighlightWriter` produces but writing directly to the process
stdout to bypass TUnit's per-test capture.

On failure, node-level diagnostics (started status, port, version, last exception) are written
to both the terminal and TUnit's test output system.

## Per-test client diagnostics

`GetOrAddClient` accepts an optional `TextWriter` that dynamically routes to whichever TUnit
test is currently executing. The client is cached per-cluster, but each test sees its own
request/response debug output:

```csharp
var client = cluster.GetOrAddClient((c, output) =>
{
    var settings = new ElasticsearchClientSettings(new StaticNodePool(c.NodesUris()))
        .EnableDebugMode()
        .OnRequestCompleted(call => output.WriteLine(call.DebugInformation));
    return new ElasticsearchClient(settings);
});
```

## Version-skip and custom skip conditions

```csharp
[SkipVersion("<8.0.0", "Requires 8.x")]
[Test]
public async Task NewFeatureTest() { }
```

Evaluated via a `[BeforeEvery(Test)]` hook that resolves the cluster version from the test
class's `ClassDataSource` and checks `[SkipVersion]` / `SkipTestAttribute` subclasses.

Includes minimal and complex example projects demonstrating multi-cluster setups,
`[ParallelLimiter]`, version skipping, base class patterns, and `SeedCluster()` overrides.
Move client configuration from test methods to a `Client` property on
the cluster class. Tests simply call `cluster.Client` instead of
inlining `GetOrAddClient` in every test method. Removes the now-unused
`EphemeralClusterExtensions` helper and `IMyCluster` interface from
the complex example.
The non-generic ElasticsearchCluster now ships a virtual Client property
with debug mode and per-test output wiring out of the box. Clusters that
extend the non-generic base inherit it automatically — override to
customize. This makes the minimal cluster definition a true one-liner.

Transitive dependencies (Elastic.Clients.Elasticsearch, Ephemeral) are
removed from the example csproj files since they flow through the TUnit
package. README updated to reflect the simplified getting-started.
….Core` + `Elastic.TUnit.Elasticsearch`

Separate the core TUnit cluster integration (lifecycle, hooks, skip attributes,
diagnostics) from the `Elastic.Clients.Elasticsearch` convenience layer so users
who don't need the official client can depend on the Core package alone.

- `Elastic.TUnit.Elasticsearch.Core`: barebones package, no ES client dependency
- `Elastic.TUnit.Elasticsearch`: recommended package with default Client and
  `.WireTUnitOutput()` extension
The `generateApiChanges` step fails for packages targeting net8.0/net10.0
because it looks for a netstandard2.0 build output that doesn't exist.
Skip the diff when the source directory is absent.
@Mpdreamz Mpdreamz merged commit a48a061 into master Feb 17, 2026
3 checks passed
@Mpdreamz Mpdreamz deleted the feature/tunit-integration branch February 17, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant