ORTA
Testing & CI
Elasticsearch entegrasyonlarının otomatik test edilmesi — Testcontainers ile integration test, analyzer doğrulama, mapping migration stratejisi.
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.