Why a test, not a feature
When I decided to contribute to supabase-kt — the Kotlin Multiplatform client for Supabase — I didn’t start by hunting for a big feature. Big features in an unfamiliar codebase mean long reviews, design debates, and a high chance of “thanks, but we’re going a different direction.” I wanted something small, self-contained, and obviously useful: the kind of change a maintainer can read in one sitting and merge with confidence.
So I went looking for gaps the maintainer had already acknowledged. That’s the cheapest possible signal that a contribution is wanted.
Finding the task
I scanned the repository for the markers that maintainers leave when they know something is missing: TODO, FIXME, open issues tagged “good first issue,” and tests that existed for one half of an API but not the other.
One line stood out, in Storage/src/commonTest/kotlin/BucketApiFlowTest.kt:
//TODO: Add tests for downloading as flow
It was perfect. Right above it sat a complete set of upload-as-flow tests — byte-array uploads, channel uploads, upserts, signed-URL uploads. The download side of the same API (downloadAuthenticatedAsFlow, downloadPublicAsFlow) had zero coverage. The maintainer had literally written down the task and left a template right next to it.
The approach
The whole strategy was mirror, don’t invent. The upload tests already established the house style: a mocked Supabase client, Ktor’s MockEngine for HTTP, and Turbine to assert on the emitted Flow statuses. I followed the same shape so the new tests would read as if the original author had written them.
I added four tests covering the two download functions across their two input modes:
downloadAuthenticatedAsFlowanddownloadPublicAsFlow- each in a ByteArray variant and a ByteWriteChannel (streaming) variant
The ByteArray variant asserts the full status sequence — Progress → Success → ByteData — and checks the decoded bytes. The channel variant asserts Progress → Success (no ByteData is emitted when streaming) and then verifies the bytes actually written to the channel.
The interesting part: downloads aren’t uploads
This is where a “mechanical” task taught me something. My first instinct was to copy the upload assertion verbatim, including this check on the progress event:
assertEquals(expectedData.size.toLong(), progressStatus.contentLength)
It passed for uploads. It failed for downloads, reporting contentLength = 0. Why?
Both progress callbacks have the same shape — (bytesTransferred, contentLength) — but the contentLength comes from opposite ends of the wire:
- Upload (
onUpload): the body is ours. We hand Ktor a payload of known size, so it setsContent-Lengthon the outgoing request. The value is always known. - Download (
onDownload):contentLengthcomes from the response’sContent-Lengthheader — set by the server. My mocked response only setContent-Type, andMockEnginedoesn’t invent aContent-Length. So the callback receivednull, which the SDK maps to0.
A second wrinkle: for streaming (channel) downloads, the progress callback fires at engine-chosen points, so a streamed download can emit several Progress statuses, and the final byte count isn’t guaranteed to arrive before Success. I wrote a small awaitProgress() helper that drains progress emissions and hands back the terminal status, rather than assuming exactly one.
The fix for the contentLength assertion was the realistic one: add a Content-Length header to the mocked response, the way a real server does. That kept the assertion meaningful instead of just deleting it.
Verifying before claiming “done”
Kotlin Multiplatform has no single test task — the plugin registers one per target (jvmTest, iosX64Test, macosArm64Test, …) plus an allTests umbrella. I ran the JVM suite locally:
./gradlew -DLibrariesOnly=true :storage-kt:jvmTest --tests "BucketApiFlowTest"
I ran it several times to confirm the streaming tests weren’t flaky, and checked that detekt (the project’s linter) stayed clean.
The pull request
I opened the PR from my fork into the project’s master branch, kept the description short and factual, and was upfront about how the work was done. The maintainer merged it.
What I took away
- The best first contributions are pre-approved by the codebase itself — a
TODO, an asymmetric API, an open issue. You’re not pitching; you’re finishing. - Matching the existing style matters as much as correctness. A test that looks native to the file is easier to trust and merge.
- “Mechanical” tasks still teach you the system. I came in to copy-paste an upload test and left understanding how Ktor surfaces transfer progress, why upload and download metadata differ, and how KMP structures its test tasks.
Small scope, real learning, merged code. A good first step into an open-source project.