0

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.

1 Answer 1

1

After a few days of digging and a few questions to ChatGPT I got the right answer. The problem was in synchtonizxation. Here is the code to fix it:

public async Task<ArticleElastic> SaveAsync(ArticleElastic entity)
{
    await _elasticsearchClient.CreateAsync(entity, i => i.Id(entity.Id).Index(IndexName).Refresh(Refresh.True));
    return entity;
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.