Search is an essential part of any modern application. Without a first-class emphasis on great search, many applications aren’t much better than a spreadsheet. Luckily for application developers, we’re spoiled for options for delivering an excellent search experience to our users. One of the options I’ve been playing with recently is Typesense, which aims to be an open-source alternative to commercial-service Algolia.

In this post, we’ll look at how you can play around with Typesense within the context of .NET using Testcontainers. Testcontainers is a library that makes spinning up containers so simple you’ll wonder how you ever lived without it. Let’s get started.

What is Typesense

Search is challenging to get right, with many commercial options. The most notable commercial offerings include Elasticsearch and Algolia, which come with licensing costs or are search-as-a-service solutions. While great choices in their own right, the options might not fit your particular goals for finding a search solution. Typesense is an open-source alternative to the aforementioned options, with many great features developers expect from a search provider.

Typesense provides development teams with a search engine that can perform search-as-you-type, autocomplete, faceted navigation, geo-search, recommendations, and more. Developers can also run Typesense within a docker container within their organization’s infrastructure. There are also SDKs for most languages that make managing a search index easier from within your technology stack of choice. For folks reading this post, yes, there is a .NET SDK too.

Typesense operates on a “batteries-included” philosophy, hoping to give developers all the essential features they need right out of the box. This means you can get a “good enough” experience within minutes while having the ability to fine-tune the experience over time.

To take Typesense for a test drive, we’ll use Testcontainers to spin up an instance of the search provider and poke at it.

What is Testcontainers

Testcontainers is an open-source framework for providing lightweight and disposable instances of any imaginable dependency that can run within a Docker container. You can test against the actual technology and avoid mocks and stubs altogether. This framework has a first-class .NET library to allow any developer to pull a Docker image, configure a container instance, run it, and dispose of it.

The following section, we will set up a unit test class using xUnit, Testcontainers, and Typesense.

xUnit, Testcontainers, and Typesense sample

Note: The samples are using newer C# language features like primary constructors and target-type inference. If the code doesn’t compile, you likely need set your language version to a newer version or adapt the code.

We’ll need to create a new xUnit class library with the following dependencies: Testcontainers, Typesense, and xUnit. Once these dependencies are added to the class library, we can start by creating a TypesenseFixture class.

The TypesenseFixture class will create the container and the configuration needed to connect to our container instance.

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.Options;
using Typesense;
using Typesense.Setup;
using Xunit;

namespace TypesenseWithTestcontainer;

public class TypesenseFixture: IAsyncLifetime
{
    private const int ContainerPort = 8108;
    private const string ApiKey = "typesense-api-key";

    public TypesenseFixture()
    {
        TypesenseContainer = new ContainerBuilder()
            .WithImage("typesense/typesense:0.25.1")
            .WithPortBinding(ContainerPort, true)
            .WithEnvironment("TYPESENSE_API_KEY", ApiKey)
            .WithEnvironment("TYPESENSE_DATA_DIR", "/tmp")
            .WithWaitStrategy(Wait.ForUnixContainer()
                .UntilHttpRequestIsSucceeded(r => r
                    .ForPort(ContainerPort)
                    .ForPath("/health")
                    .WithHeader("TYPESENSE-API-KEY", ApiKey)
                )
            )
            .Build();
    }

    public Config ConnectionConfig { get; private set; }
    public IContainer TypesenseContainer { get; }

    public TypesenseClient GetClient()
    {
        var options = new OptionsWrapper<Config>(ConnectionConfig);
        var client = new TypesenseClient(options, new HttpClient());
        return client;
    }

    public async Task InitializeAsync()
    {
        await TypesenseContainer.StartAsync();
        
        var port = TypesenseContainer
            .GetMappedPublicPort(ContainerPort)
            .ToString();
        
        ConnectionConfig = new Config(
            new Node[] { new(TypesenseContainer.Hostname, port) },
            ApiKey
        );
    }

    public Task DisposeAsync()
        => TypesenseContainer.DisposeAsync().AsTask();
}

Essential steps that are happening in the use of ContainerBuilder:

  1. We set the image name for typesense/typesense:0.25.1. This name matches an image in the Docker image registry.
  2. We bind the internal port 8081 to a random port on our host.
  3. We set the environment variables of TYPESENSE_API_KEY and TYPESENSE_DATA_DIR. These variables are required to start up the container successfully.
  4. We wait for Typesense to start by repeatedly hitting the /health endpoint until the response is successful.

Once the container has started successfully, we can use the container information to produce a Typesense configuration for our client.

Our first step is to see if we can call our instance of Typesense.

using System.Text.Json.Serialization;
using Typesense;
using Xunit;
using Xunit.Abstractions;

namespace TypesenseWithTestcontainer;

public class TypesenseTests(TypesenseFixture fixture, ITestOutputHelper output) :
    IClassFixture<TypesenseFixture>
{
    [Fact]
    public async Task CanQueryTypesenseForHealth()
    {
        var client = fixture.GetClient();
        var result = await client.RetrieveHealth();

        Assert.NotNull(result);
    }
}

The preceding test should pass if everything is set up correctly. Let’s expand our test with a more fun Typesense example.

First, we’ll need a document to store in Typesense.

public class Product(string id, string name, string manufacturer, double price)
{
	[JsonPropertyName(nameof(Id))]
	public string Id { get; set; } = id;
	[JsonPropertyName(nameof(Name))]
	public string Name { get; set; } = name;
	[JsonPropertyName(nameof(Manufacturer))]
	public string Manufacturer { get; set; } = manufacturer;
	[JsonPropertyName(nameof(Price))]
	public double Price { get; set; } = price;
	
	public static Product[] Samples { get; } = {
		new("1", "iPhone 15", "Apple", 1500),
		new("2", "Pixel 8 Pro", "Google", 1300),
		new("3", "Playstation 5", "Sony", 500),
		new("4", "XBox Series X", "Xbox", 500),
		new("5", "Switch", "Nintendo", 300)
	};
}

Typesense uses System.Text.Json with CamelCase naming. In the case of my test, I wanted to retain the original casing, so I used the JsonPropertyName attribute to set the name of each field explicitly. The Product class also defines some sample data for use in our test.

We’ll also want to add IAsyncLifetime to our TypesenseTests class. This will allow us to load our document collection and clear it on each test.

public async Task InitializeAsync()
{
	var client = fixture.GetClient();

	var schema = new Schema(nameof(Product), new Field[]
	{
		new(nameof(Product.Id), FieldType.String),
		new(nameof(Product.Name), FieldType.String, false),
		new(nameof(Product.Manufacturer), FieldType.String, true, false, true),
		new(nameof(Product.Price), FieldType.Float, false)
	});

	await client.CreateCollection(schema);

	foreach (var product in Product.Samples) {
		await client.CreateDocument(nameof(Product), product);
	}
}

public async Task DisposeAsync()
{
	var client = fixture.GetClient();
	await client.DeleteCollection(nameof(Product));
}

Finally, let’s add our test and search for a product.

[Fact]
public async Task CanSearchForProducts()
{
	var client = fixture.GetClient();

	var results = await client
		.Search<Product>(
			nameof(Product),
			new("Sony", nameof(Product.Manufacturer))
		);
	
	Assert.Equal(1, results.Hits.Count);
	
	var product = results.Hits[0].Document;
	Assert.Equal("Sony", product.Manufacturer);
	
	output.WriteLine($"Found {product.Manufacturer} {product.Name} ({product.Price:C})");
}

Awesome! Here’s the complete test class for improved clarity.

using System.Text.Json.Serialization;
using Typesense;
using Xunit;
using Xunit.Abstractions;

namespace TypesenseWithTestcontainer;

public class TypesenseTests(TypesenseFixture fixture, ITestOutputHelper output) :
    IClassFixture<TypesenseFixture>, IAsyncLifetime
{
    [Fact]
    public async Task CanQueryTypesenseForHealth()
    {
        var client = fixture.GetClient();
        var result = await client.RetrieveHealth();

        Assert.NotNull(result);
    }

    [Fact]
    public async Task CanSearchForProducts()
    {
        var client = fixture.GetClient();

        var results = await client
            .Search<Product>(
                nameof(Product),
                new("Sony", nameof(Product.Manufacturer))
            );
        
        Assert.Equal(1, results.Hits.Count);
        
        var product = results.Hits[0].Document;
        Assert.Equal("Sony", product.Manufacturer);
        
        output.WriteLine($"Found {product.Manufacturer} {product.Name} ({product.Price:C})");
    }

    public class Product(string id, string name, string manufacturer, double price)
    {
        [JsonPropertyName(nameof(Id))]
        public string Id { get; set; } = id;
        [JsonPropertyName(nameof(Name))]
        public string Name { get; set; } = name;
        [JsonPropertyName(nameof(Manufacturer))]
        public string Manufacturer { get; set; } = manufacturer;
        [JsonPropertyName(nameof(Price))]
        public double Price { get; set; } = price;
        
        public static Product[] Samples { get; } = {
            new("1", "iPhone 15", "Apple", 1500),
            new("2", "Pixel 8 Pro", "Google", 1300),
            new("3", "Playstation 5", "Sony", 500),
            new("4", "XBox Series X", "Xbox", 500),
            new("5", "Switch", "Nintendo", 300)
        };
    }

    public async Task InitializeAsync()
    {
        var client = fixture.GetClient();

        var schema = new Schema(nameof(Product), new Field[]
        {
            new(nameof(Product.Id), FieldType.String),
            new(nameof(Product.Name), FieldType.String, false),
            new(nameof(Product.Manufacturer), FieldType.String, true, false, true),
            new(nameof(Product.Price), FieldType.Float, false)
        });

        await client.CreateCollection(schema);

        foreach (var product in Product.Samples) {
            await client.CreateDocument(nameof(Product), product);
        }
    }

    public async Task DisposeAsync()
    {
        var client = fixture.GetClient();
        await client.DeleteCollection(nameof(Product));
    }
}

Now, we can test the Typesense search engine from within .NET and explore the client’s capabilities.

As a note, you should do your document initialization code less frequently and possibly within the fixture. Also, explore xUnit’s Collection attribute to reduce the amount of containers created for larger test suites.

Conclusion

Typesense is a promising solution for providing your users an outstanding search experience. By using Testcontainers with the Typesense Docker image, you can quickly experiment with the capabilities of the search engine and what’s possible.

You can get the complete sample at my Typesense with Testcontainers GitHub repository.

I hope this post gives you a good starting point to explore and iterate on your solutions. As always, thank you for reading and sharing my posts. Cheers. :)