I use NUnit and Testcontainers to test my custom method for fetching data from Elasticsearch. The code to interact with elasric is this
using ArticleStorage.Api.Models;
using ArticleStorage.Api.Models.Elastic;
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.Core.Search;
using Elastic.Transport;
namespace ArticleStorage.Api.Repositories.Elastic
{
public class ArticleElasticRepository : IArticleElasticRepository
{
private const string IndexName = "articles";
private readonly ElasticsearchClient _elasticsearchClient;
public ArticleElasticRepository(IConfiguration configuration)
{
_elasticsearchClient = new
(
new ElasticsearchClientSettings(new Uri(configuration.GetConnectionString("Elastic") ?? throw new ArgumentNullException("Elastic connection string is not set in configuration")))
.DefaultMappingFor<ArticleElastic>(i => i.IndexName(IndexName).IdProperty(a => a.Id))
.EnableDebugMode()
.PrettyJson()
.RequestTimeout(TimeSpan.FromMinutes(2))
.ServerCertificateValidationCallback(CertificateValidations.AllowAll)
.DefaultIndex(IndexName)
);
}
public ArticleElasticRepository(ElasticsearchClientSettings elasticsearchClientSettings)
{
_elasticsearchClient = new(elasticsearchClientSettings);
}
public async Task<List<Hit<ArticleElastic>>> SearchAsync(string query, int page, int size, string sortBy, Order order)
{
if (sortBy.Equals(nameof(ArticleElastic.Title), StringComparison.CurrentCultureIgnoreCase))
{
sortBy += ".keyword";
}
FieldSort fieldSort = order == Order.Asc ? new FieldSort { Order = SortOrder.Asc, Field = sortBy } : new FieldSort { Order = SortOrder.Desc, Field = sortBy };
var articlesResponse = await _elasticsearchClient.SearchAsync<ArticleElastic>(s => s
.Indices(IndexName)
.From(page * size)
.Size(size)
.Query(q => q
.Bool(
bq => bq
.Should(
shq => shq
.Match(mq => mq.Field(f => f.Title).Query(query)),
shq => shq
.Match(mq => mq.Field(f => f.Content).Query(query))
)
)
)
.Sort(so => so.Field(fieldSort))
);
return [.. articlesResponse.Hits];
}
public async Task DeleteAsync(ArticleElastic entity)
{
await _elasticsearchClient.DeleteAsync(entity, i => i.Id(entity.Id).Index(IndexName));
}
public async Task DeleteAllAsync()
{
await _elasticsearchClient.DeleteByQueryAsync<ArticleElastic>(d => d
.Indices(IndexName)
.Query(q => q.MatchAll(maq => maq.QueryName("match_all_articles")))
);
}
public async Task<List<ArticleElastic>> FindAllAsync(int page, int size, string sortBy, Order order)
{
if (sortBy.Equals(nameof(ArticleElastic.Title), StringComparison.CurrentCultureIgnoreCase))
{
sortBy += ".keyword";
}
FieldSort fieldSort = order == Order.Asc ? new FieldSort { Order = SortOrder.Asc, Field = sortBy } : new FieldSort { Order = SortOrder.Desc, Field = sortBy };
return [.. (await _elasticsearchClient.SearchAsync<ArticleElastic>(s => s
.Indices(IndexName)
.From(page * size)
.Size(size)
.Sort(so => so.Field(fieldSort))
))
.Documents];
}
public async Task<List<ArticleElastic>> FindAllByIdsAsync(params long[] ids)
{
var articlesResponse = await _elasticsearchClient.SearchAsync<ArticleElastic>(s => s
.Indices(IndexName)
.Query(q => q
.Ids(i => i
.Values(new Ids(ids.Select(id => new Id(id))))
)
)
);
return [.. articlesResponse.Documents];
}
public async Task<ArticleElastic?> FindByIdAsync(long id)
{
return (await _elasticsearchClient.GetAsync<ArticleElastic>(id, i => i.Id(id).Index(IndexName))).Source;
}
// TODO Proper error handling
public async Task<ArticleElastic> SaveAsync(ArticleElastic entity)
{
CreateResponse response = await _elasticsearchClient.CreateAsync(entity, i => i.Id(entity.Id).Index(IndexName));
return entity;
}
public async Task<ArticleElastic> UpdateAsync(ArticleElastic entity)
{
await _elasticsearchClient.IndexAsync(entity, i => i.Id(entity.Id).Index(IndexName));
return entity;
}
public async Task<bool> IndexExistsAsync()
{
return (await _elasticsearchClient.Indices.ExistsAsync(IndexName)).Exists;
}
public async Task<string> CreateIndexAsync()
{
var createIndexResponse = await _elasticsearchClient.Indices.CreateAsync<ArticleElastic>(i => i
.Index(IndexName)
.Mappings(m => m
.Properties(p => p
.Text(k => k.Title, c => c.Fields(f => f.Keyword("keyword")))
.Text(t => t.Content)
.Date(k => k.PublishedDate)
)
)
);
return createIndexResponse.Index;
}
}
}
The code for testing is this:
using ArticleStorage.Api.Models;
using ArticleStorage.Api.Models.Elastic;
using ArticleStorage.Api.Repositories.Elastic;
using Elastic.Clients.Elasticsearch;
using Elastic.Transport;
using Testcontainers.Elasticsearch;
namespace ArticleStorage.Api.Tests.Repositories.Elastic
{
[TestFixture]
[Parallelizable(scope: ParallelScope.Fixtures)]
public class ArticleElasticRepositoryTest
{
private ElasticsearchContainer _elasticContainer = new ElasticsearchBuilder()
.WithImage("docker.elastic.co/elasticsearch/elasticsearch:9.0.0")
.WithExposedPort(9200)
.WithEnvironment("discovery.type", "single-node")
.Build();
private ArticleElasticRepository _articleElasticRepository;
private ArticleElastic _articleElastic1 = new()
{
Id = 1,
Title = "Test title 1",
Content = "Test content 1",
PublishedDate = DateTimeOffset.MinValue.AddDays(1),
};
[OneTimeSetUp]
public async Task Init()
{
await _elasticContainer.StartAsync();
ElasticsearchClientSettings elasticsearchClientSettings = new ElasticsearchClientSettings(new Uri(_elasticContainer.GetConnectionString()))
.DefaultMappingFor<ArticleElastic>(i => i.IndexName("articles").IdProperty(a => a.Id))
.EnableDebugMode()
.PrettyJson()
.RequestTimeout(TimeSpan.FromMinutes(2))
.DefaultIndex("articles")
.ServerCertificateValidationCallback(CertificateValidations.AllowAll);
_articleElasticRepository = new ArticleElasticRepository(elasticsearchClientSettings);
if (!(await _articleElasticRepository.IndexExistsAsync()))
{
await _articleElasticRepository.CreateIndexAsync();
}
}
[OneTimeTearDown]
public async Task Destroy()
{
await _elasticContainer.StopAsync();
await _elasticContainer.DisposeAsync();
}
[SetUp]
public async Task SetUp()
{
_articleElastic1.Id = 1;
_articleElastic1.Title = "Test title 1";
_articleElastic1.Content = "Test content 1";
_articleElastic1.PublishedDate = DateTimeOffset.MinValue.AddDays(1);
await _articleElasticRepository.SaveAsync(_articleElastic1);
}
[TearDown]
public async Task TearDown()
{
await _articleElasticRepository.DeleteAllAsync();
}
[Test]
public async Task ShouldReturnArticleByQuery_WhenArticleIsPresent()
{
var result = await _articleElasticRepository.SearchAsync("Test title 1", 0, 10, "publishedDate", Order.Desc);
Assert.That(result.Select(h => h.Source), Is.EqualTo([_articleElastic1]));
}
[Test]
public async Task ShouldNotReturnArticleByQuery_WhenArticleIsNotPresent()
{
var result = await _articleElasticRepository.SearchAsync("asdf", 0, 10, "publishedDate", Order.Desc);
Assert.That(result, Is.Empty);
}
}
}
All methods to interact with Elasticsearch are async. When I run tests as usual, I always get the following error for test to check if data exists in the index:
Assert.That(result.Select(h => h.Source), Is.EqualTo([_articleElastic1]))
Expected is <<>z__ReadOnlySingleElementList`1[ArticleStorage.Api.Models.Elastic.ArticleElastic]> with 1 elements, actual is <System.Linq.Enumerable+ListSelectIterator`2[Elastic.Clients.Elasticsearch.Core.Search.Hit`1[ArticleStorage.Api.Models.Elastic.ArticleElastic],ArticleStorage.Api.Models.Elastic.ArticleElastic]>
Values differ at index [0]
However, when I put a breakpoint and try to debug the reason for that, the test succeeds. What is to be done to make tests work normally?
I already tried to make tests sequential by blocking async code, it did not help.