[Unit] Testing Supabase in Kotlin using Test Containers - PART 2

In this article we continue diving into TestContainers and Supabase, and run unit tests against a full local self-hosted Supabase.

[Unit] Testing Supabase in Kotlin using Test Containers - PART 2
Supabase <3 Kotlin

TL;DR : You can run a full Supabase instance inside Test Containers quite easily. See this repository.

In my last article, I was listing a few attempts I had done at running tests against my Kotlin Supabase application. The way the Supabase-Kt library is built makes it hard to mock, and I ended up building a minimal Docker Compose setup that was mimicking a Supabase instance.

In this second part, we're gonna push the madness further and actually run a FULL SUPABASE instance locally, still using Test Containers.

Right after I finished pushing my repository last week, I realised that Supabase actually offered a Docker Compose file to self-host their platform. So I decided to push the madness further and see how easy it was to use that file inside TestContainers. In short : Relatively easy.

The setup

The setup isn't actually much different from my homecrafted Docker Compose. version. Here it is in its entirety.

A few notable things:

  • I'm relying on a local clone of Supabase, and point a ComposeContainer to the src/test/resources/supabase/docker/docker-compose.yml file.
  • The setup uses an .env file, so I use a dotenv implementation to grab the parameters there and make the code slightly dynamic.
  • I have to run a database statement to populate and flush my database in between tests. The Docker Compose setup from Supabase comes with persistent volumes, which needs to be accounted for.
  • I don't have test for those here, but all services (auth, functions, storage), ... should actually be supported, given that we're running a full local instance.
import io.github.cdimascio.dotenv.dotenv
import io.github.jan.supabase.SupabaseClient
import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.testcontainers.containers.ComposeContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.io.File
import java.sql.DriverManager


@Testcontainers
class MainKtTest {

    @Test
    fun testEmptyPersonTable(){
        runBlocking {
            val result = getPerson(supabaseClient)
            assertEquals(0, result.size)
        }
    }

    @Test
    fun testSavePersonAndRetrieve(){
        val randomPersons = listOf(Person("Jan", 30), Person("Jane", 42))

        runBlocking {
            val result = savePerson(randomPersons, supabaseClient)
            assertEquals(2, result.size)
            assertEquals(randomPersons, result.map { it.toPerson() })

            val fetchResult = getPerson(supabaseClient)
            assertEquals(2, fetchResult.size)
            assertEquals(randomPersons, fetchResult.map { it.toPerson() })
        }
    }

    companion object {

        private const val DOCKER_COMPOSE_FILE = "src/test/resources/supabase/docker/docker-compose.yml"
        private const val ENV_LOCATION = "src/test/resources/supabase/docker/.env" // We grab the JWT token from here

        val dotenv = dotenv{
            directory = File(ENV_LOCATION).toString()
        }

        private val jwtToken = dotenv["SERVICE_ROLE_KEY"]
        private val dbPassword = dotenv["POSTGRES_PASSWORD"]
        private val db = dotenv["POSTGRES_DB"]

        private lateinit var supabaseClient: SupabaseClient

        @Container
        var container: ComposeContainer = ComposeContainer(File(DOCKER_COMPOSE_FILE))
            .withExposedService("kong", 8000)
            .withExposedService("db", 5432)   // Handy but not required

        @JvmStatic
        @AfterAll
        fun tearDown() {
            val dbUrl = container.getServiceHost("db", 5432) + ":" + container.getServicePort("db", 5432)

            val jdbcUrl = "jdbc:postgresql://$dbUrl/$db"
            val connection = DriverManager.getConnection(jdbcUrl, "postgres", dbPassword)

            try {
                val query = connection.prepareStatement(
                    """
            drop table public.person;
        """
                )

                query.executeQuery()
            } catch (ex: Exception) {
                println(ex)
            }
        }

        @JvmStatic
        @BeforeAll
        fun setUp() {
            val supabaseUrl = container.getServiceHost("kong", 8000) + ":" + container.getServicePort("kong", 8000)
            val dbUrl = container.getServiceHost("db", 5432) + ":" + container.getServicePort("db", 5432)

            supabaseClient = createSupabaseClient(
                supabaseUrl = "http://$supabaseUrl",
                supabaseKey = jwtToken
            ) {
                install(Postgrest)
            }

            val jdbcUrl = "jdbc:postgresql://$dbUrl/$db"
            val connection = DriverManager.getConnection(jdbcUrl, "postgres", dbPassword)


            try {
                val query = connection.prepareStatement(
                    """
                create table
                    public.person (
                                    id bigint generated by default as identity not null,
                                    timestamp timestamp with time zone null default now(),
                                    name character varying null,
                                    age bigint null
                ) tablespace pg_default;
                """
                )

                query.executeQuery()
            } catch (ex: Exception) {
                println("Error is fine here. This should actually run only once")
                println(ex) // Might be fine, this should actually run only once
            }
        }
    }
}

To achieve those results, a few manual steps are required. The Docker Compose file provided by Supabase uses container_name parameters, which aren't supported by Test Containers.

I needed to :

  • Clone the Supabase repository locally
  • Copy the env file
  • Run some magic to remove container_name
  • Once in a while, the Supabase repository will have to be pulled

The results

The results are as outrageous as I expected them to be, if not more : All tests are running fine, though it takes almost 1 minute to run them. The ComposeContainer is starting no less than 12 containers (!!!) so it is to be expected.

Obviously, that setup is not to be used for unit testing. That being said, I find it absolutely freaking cool to be able to recreate your complete environment locally that easily, and I'd definitely consider that an option for bigger integration tests. The confidence I didn't have with my home brewed Docker Compose file is much higher now, given that it's directly provided by Supabase. No network needed to run my tests, pretty cool.

Gradle running those massive tests just fine

What more

My original complete intent was to build a small layer on top of the Docker Compose file, kinda like AtomoicJar does it with its modules. It would have been cool to have a simple interface for a Supabase instance to start, while providing a locally for starting scripts, user roles, maybe a new set of credentials, ...

Here is how they describe it for NGinx for example. I would have loved to have something similar :

@Rule
public NginxContainer<?> nginx = new NginxContainer<>(NGINX_IMAGE)
    .withCopyFileToContainer(MountableFile.forHostPath(tmpDirectory), "/usr/share/nginx/html")
    .waitingFor(new HttpWaitStrategy());

All of the implementation I've seen extend from GenericContainer though, not ComposeContainer so I've decided to hold that off and keep it simple for now.

Could maybe be something for the future, who knows.

In conclusion

That was a fun experiment, in which I've learnt more about TestContainers 😊. I'm as happy as usual with the way Supabase shows love for their users. Providing a seemless Docker Compose like this allows for a great experience. And I'm also impressed with TestContainers and how they can run such complex flaws without breaking a sweat!

If anything, I'd like them to at least ignore the container_name parameter if possible. I've seen many folks being blocked by it, and I can imagine many cases, like this one where people are not in control of their compose file. I don't necessarily ask for support, but an option to ignore without throwing an exception would be great.

That's it folks, till next time!