EElasticsearch Handbook

ORTA

Testing & CI

Elasticsearch entegrasyonlarının otomatik test edilmesi — Testcontainers ile integration test, analyzer doğrulama, mapping migration stratejisi.

Unit Tests Mock ES client Query builder logic Mapping validation Serialization tests Hızlı, CI-friendly Integration Tests Testcontainers (real ES) Index CRUD operations Analyzer _analyze verify Query result assertions Gerçek ES davranışı Contract Tests Mapping schema check Breaking change detect Version compatibility CI pipeline gate Deploy güvenliği CI Pipeline GitHub Actions Testcontainers svc Mapping diff check Deploy gate Otomatik doğrulama

Karar Rehberi

DurumÖneriÖrnek veya gerekçe
Testcontainers Uygun: Integration test (CI'da real ES) PR'da mapping doğrulama
Mock client Uygun: Business logic unit test Repository layer test
_analyze API test Uygun: Custom analyzer doğrulama Türkçe stemmer testi
Mapping diff Uygun: Breaking change tespiti Field type değişikliği
Snapshot test Uygun: Query JSON stabilite Regression guard
Load test Uygun: Pre-prod kapasite Bulk indexing benchmark
.NET Client (Testcontainers)
// NuGet: Testcontainers.Elasticsearch 4.x, xUnit
using Testcontainers.Elasticsearch;

public class ElasticsearchFixture : IAsyncLifetime
{
    private readonly ElasticsearchContainer _container = new ElasticsearchBuilder()
        .WithImage("docker.elastic.co/elasticsearch/elasticsearch:9.4.2")
        .WithEnvironment("xpack.security.enabled", "false")
        .WithEnvironment("discovery.type", "single-node")
        .WithEnvironment("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
        .Build();

    public ElasticsearchClient Client { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        var settings = new ElasticsearchClientSettings(
            new Uri(_container.GetConnectionString()));
        Client = new ElasticsearchClient(settings);

        // Wait for cluster health
        await Client.Cluster.HealthAsync(h => h
            .WaitForStatus(HealthStatus.Green)
            .Timeout(TimeSpan.FromSeconds(30)));
    }

    public async Task DisposeAsync() => await _container.DisposeAsync();
}

[CollectionDefinition("Elasticsearch")]
public class ElasticsearchCollection : ICollectionFixture<ElasticsearchFixture> { }

// === Integration Test ===
[Collection("Elasticsearch")]
public class ProductSearchTests
{
    private readonly ElasticsearchClient _client;

    public ProductSearchTests(ElasticsearchFixture fixture) => _client = fixture.Client;

    [Fact]
    public async Task Search_WithTurkishAnalyzer_FindsStemmedResults()
    {
        // Arrange: create index with Turkish analyzer
        await _client.Indices.CreateAsync("test-products", c => c
            .Settings(s => s.Analysis(a => a
                .Analyzers(an => an.Add("turkish_custom", new CustomAnalyzer
                {
                    Tokenizer = "standard",
                    Filter = new[] { "lowercase", "turkish_stop", "turkish_stemmer" }
                }))))
            .Mappings(m => m.Properties(p => p
                .Add("name", new TextProperty { Analyzer = "turkish_custom" }))));

        await _client.IndexAsync(new { name = "koşu ayakkabısı" }, i => i
            .Index("test-products").Id("1").Refresh(Refresh.True));

        // Act: search with stemmed form
        var result = await _client.SearchAsync<dynamic>(s => s
            .Index("test-products")
            .Query(q => q.Match(m => m.Field("name").Query("ayakkabı"))));

        // Assert
        Assert.Equal(1, result.Total);
    }

    [Fact]
    public async Task Analyzer_ProducesExpectedTokens()
    {
        // _analyze API ile doğrulama
        var response = await _client.Indices.AnalyzeAsync(a => a
            .Index("test-products")
            .Analyzer("turkish_custom")
            .Text("Türkiye'nin en güzel şehirleri"));

        var tokens = response.Tokens.Select(t => t.Token).ToList();
        Assert.Contains("türkiy", tokens);  // stemmed
        Assert.Contains("güzel", tokens);
        Assert.DoesNotContain("en", tokens); // stop word
    }
}

// === Mapping Migration Test ===
[Fact]
public async Task Mapping_ShouldNotHaveBreakingChanges()
{
    // Load expected mapping from version-controlled JSON
    var expectedMapping = File.ReadAllText("mappings/products-v3.json");
    var expected = JsonSerializer.Deserialize<JsonElement>(expectedMapping);

    // Get current mapping from ES
    var current = await _client.Indices.GetMappingAsync(g => g.Index("products"));
    var currentJson = JsonSerializer.Serialize(current.Indices["products"].Mappings);

    // Compare: new fields OK, type changes = FAIL
    var diff = MappingDiffChecker.Compare(expected,
        JsonSerializer.Deserialize<JsonElement>(currentJson));

    Assert.Empty(diff.BreakingChanges); // No type changes, no removed fields
    // diff.AddedFields is OK (non-breaking)
}
CI Pipeline (GitHub Actions)
# .github/workflows/elasticsearch-tests.yml
name: ES Integration Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      elasticsearch:
        image: docker.elastic.co/elasticsearch/elasticsearch:9.4.2
        env:
          discovery.type: single-node
          xpack.security.enabled: "false"
          ES_JAVA_OPTS: "-Xms512m -Xmx512m"
        ports:
          - 9200:9200
        options: >-
          --health-cmd "curl -s http://localhost:9200/_cluster/health | grep -q green"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 10

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'

      - name: Run unit tests
        run: dotnet test tests/Unit --logger trx

      - name: Run integration tests
        env:
          ELASTICSEARCH_URL: http://localhost:9200
        run: dotnet test tests/Integration --logger trx

      - name: Mapping diff check
        run: |
          # Export current mapping
          curl -s http://localhost:9200/products/_mapping > current-mapping.json
          # Compare with committed mapping
          python scripts/mapping-diff.py mappings/products-latest.json current-mapping.json

Örnek: Bir fintech ekibi her PR'da Testcontainers ile ES integration test çalıştırır. Bir geliştirici price field'ını integer'dan text'e değiştirmeye çalıştığında mapping diff checker CI'da FAIL verir — breaking change production'a ulaşmadan yakalanır.

Anti-Pattern: Mock ile ES Davranışını Test Etme

// ❌ YANLIŞ: Mock ile analyzer/mapping davranışı test edilemez
[Fact]
public void Search_ShouldReturnResults()
{
    var mockClient = new Mock<ElasticsearchClient>();
    mockClient.Setup(c => c.SearchAsync<Product>(It.IsAny<Action<SearchRequestDescriptor<Product>>>(), default))
        .ReturnsAsync(new SearchResponse<Product> { Documents = fakeProducts });

    // BU TEST ANLAMSIZ:
    // - Analyzer tokenization doğrulanmıyor
    // - Mapping type mismatch yakalanmıyor
    // - Bool query scoring davranışı test edilmiyor
    // - Production'da 0 sonuç dönen sorgu burada "başarılı"
    var result = await _service.SearchAsync("spor ayakkabı");
    Assert.NotEmpty(result); // Her zaman geçer — gerçek ES davranışını yansıtmaz
}
// ✅ DOĞRU: Gerçek ES instance ile test — analyzer ve mapping dahil
[Collection("Elasticsearch")]
public class SearchBehaviorTests
{
    private readonly ElasticsearchClient _client;
    public SearchBehaviorTests(ElasticsearchFixture fixture) => _client = fixture.Client;

    [Fact]
    public async Task Search_WithTurkishAnalyzer_MatchesStemmedForm()
    {
        // Bu test GERÇEK analyzer davranışını doğrular:
        // "koşu ayakkabısı" indexlendiğinde "ayakkabı" araması sonuç döner mü?
        await _client.IndexAsync(new Product { Name = "koşu ayakkabısı" },
            i => i.Index("test-products").Id("1").Refresh(Refresh.True));

        var result = await _client.SearchAsync<Product>(s => s
            .Index("test-products")
            .Query(q => q.Match(m => m.Field(f => f.Name).Query("ayakkabı"))));

        Assert.Equal(1, result.Total);
        // Eğer analyzer yanlış configure edilmişse → 0 sonuç → test FAIL
    }
}

Kural: Mock'lar iş mantığı (business logic) testleri içindir — "kullanıcı yetkisi var mı?", "fiyat hesaplaması doğru mu?" gibi. ES'in davranışını (analyzer, scoring, mapping) test etmek istiyorsanız gerçek ES gerekir.

Testcontainers ES startup süresi ~15-30s. CI'da toplam test süresini azaltmak için: tüm integration test'leri tek fixture (shared container) ile çalıştırın, her test kendi index'ini oluştursun.

Performance Benchmark Testing

.NET Client (BenchmarkDotNet)
// NuGet: BenchmarkDotNet 0.14+
// Bulk indexing throughput benchmark
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net100)]
public class ElasticBulkBenchmarks
{
    private ElasticsearchClient _client = null!;
    private List<Product> _products = null!;

    [Params(1000, 5000, 10000)]
    public int BatchSize { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        _client = new ElasticsearchClient(
            new ElasticsearchClientSettings(new Uri("http://localhost:9200"))
                .RequestTimeout(TimeSpan.FromSeconds(60)));

        _products = Enumerable.Range(0, BatchSize)
            .Select(i => new Product
            {
                Id = Guid.NewGuid().ToString(),
                Name = $"Product {i}",
                Price = Random.Shared.NextDouble() * 1000
            }).ToList();
    }

    [Benchmark(Baseline = true)]
    public async Task<int> BulkIndex_Default()
    {
        var response = await _client.BulkAsync(b => b
            .Index("bench-products")
            .IndexMany(_products));
        return response.Items.Count;
    }

    [Benchmark]
    public async Task<int> BulkIndex_Optimized()
    {
        // refresh=false, pipeline=none, optimized for throughput
        var response = await _client.BulkAsync(b => b
            .Index("bench-products")
            .IndexMany(_products)
            .Refresh(Refresh.False)
            .Pipeline(""));
        return response.Items.Count;
    }

    [Benchmark]
    public async Task<int> BulkIndex_Chunked()
    {
        // Büyük batch'leri chunk'lara böl (memory pressure azalt)
        int indexed = 0;
        foreach (var chunk in _products.Chunk(1000))
        {
            var response = await _client.BulkAsync(b => b
                .Index("bench-products")
                .IndexMany(chunk)
                .Refresh(Refresh.False));
            indexed += response.Items.Count;
        }
        return indexed;
    }
}

// Çalıştır: dotnet run -c Release --project Benchmarks.csproj
// Örnek çıktı:
// |           Method | BatchSize |      Mean |     Gen0 | Allocated |
// |----------------- |---------- |----------:|---------:|----------:|
// | BulkIndex_Default|      5000 | 145.3 ms  | 2000.000 |    12.4 MB|
// |BulkIndex_Optimized|     5000 |  98.7 ms  | 1800.000 |    11.2 MB|
// | BulkIndex_Chunked|      5000 | 112.1 ms  |  800.000 |     4.8 MB|

Örnek: E-ticaret platformu, ürün kataloğu reindex sırasında bulk throughput'u optimize etmek istiyor. Benchmark sonuçları: Refresh.False ile %32 hız artışı, chunked approach ile %61 daha az memory allocation. CI'da regression testi olarak her release'de çalıştırılır.