Uploading a file to S3 - Integration test
In the previous article of the series, I covered the unit tests for this functionality. However, that isn't enough to ensure our feature works on this occasion because we are integrating it with another service.
To create our controlled version of AWS S3, we will use Test Containers and Localstack.
Test Containers will help us to create docker containers by code, it's very convenient and easy to use to have everything defined in our tests. Localstack is a curated mock server of AWS, it's very complete and easy to use.
The following code is how we run Localstack in our test and how we set it up to be able to use S3. We also save the local endpoint to access S3 because we have to use it in several places during the test and this way makes the code cleaner.
companion object {
private lateinit var s3Endpoint: URI
@Container
val localstack: LocalStackContainer =
LocalStackContainer(DockerImageName.parse("localstack/localstack:3.0"))
.withServices(LocalStackContainer.Service.S3)
@JvmStatic
@BeforeAll
fun beforeAll() {
localstack.execInContainer("awslocal", "s3", "mb", "s3://my-bucket")
s3Endpoint = localstack.getEndpointOverride(LocalStackContainer.Service.S3)
}
}
First, we're going to briefly review the code we're going to test
class UploadFile(private val uploader: FileUploader) {
suspend operator fun invoke(file: File): Result<Unit> {
return file
.takeIf { it.exists() }
?.run { uploader(File(path)) }
?: Result.failure(FilePathNotExists(file.path))
}
}
We need a file that exists, once that is checked, we upload it to S3 and ensure the result is a success
The first version of a successful integration test will be the one you can find below
@Test
fun `should upload file successfully`() {
val file = `given an existing file`()
runTest {
val result = `when the file is uploaded to S3`(file)
`then the upload should be successful`(result)
}
}
We can find again the runTest
keyword which we talked about in the previous article. We need it because the code inside includes a suspended function.
private suspend fun `when the file is uploaded to S3`(file: File) =
UploadFile(
S3FileUploader(
S3ClientConfig(
bucketName = "my-bucket",
region = localstack.region,
url = s3Endpoint.toURL(),
credentials =
StaticCredentialsProvider {
accessKeyId = localstack.accessKey
secretAccessKey = localstack.secretKey
},
),
),
).run {
this(file = file)
}
To ensure the result is correct we're using a function similar to the one for the unit test.
private fun `then the upload should be successful`(result: Result<Unit>) {
assertEquals(Result.success(Unit), result)
}
With this test, we could say that there was no exception during the execution of the happy path, but are we sure the file is correctly uploaded? I'll add another check to be sure.
@Test
fun `should upload file successfully`() {
val file = `given an existing file`()
runTest {
val result = `when the file is uploaded to S3`(file)
`then the upload should be successful`(result)
}
`then the content on S3 should be the same and the content uploaded`(file)
}
This new step forces us to create a new instance of an S3Client directly on the test, why? because we now don't have another way to access the files uploaded to our local version of S3 (localstack).
private fun `then the content on S3 should be the same and the content uploaded`(file: File) {
val s3Client =
S3Client {
region = localstack.region
endpointUrl =
Url {
scheme = Scheme.parse(s3Endpoint.toURL().protocol)
host = Host.parse(s3Endpoint.toURL().host)
port = s3Endpoint.toURL().port
}
credentialsProvider =
StaticCredentialsProvider {
accessKeyId = localstack.accessKey
secretAccessKey = localstack.secretKey
}
}
runBlocking {
s3Client.use {
it.getObject(
GetObjectRequest {
bucket = "my-bucket"
key = "file.txt"
},
) { response ->
assertNotNull(response.body)
response.body?.let { body ->
assertEquals(
InputStreamReader(file.inputStream(), StandardCharsets.UTF_8).readText(),
InputStreamReader(body.toInputStream(), StandardCharsets.UTF_8).readText(),
)
}
}
}
}
}
This code is very similar to the one we use in production to upload an S3 file, with. the difference is that here we create a GetObjectRequest
with the name of the bucket and the file that we just uploaded.
Finally, to ensure the file in S3 is the same one that we uploaded we compare their content.
You can find the complete example on GitHub
Subscribe to my newsletter
Read articles from Isabel Garrido Cardenas directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Isabel Garrido Cardenas
Isabel Garrido Cardenas
๐ Senior Software Engineer | Barcelona, Spain ๐ ๐ ๏ธAfter several years of experience developing APIs in high-traffic environments and learning about testing, best practices, architecture, and more with PHP, I changed to Kotlin because the programming language is just a tool that allows us to give value to our users. ๐ค Sharing knowledge is one of my passions. Iโve participated in different online and in-person events talking about testing, also taught about it at university for three years, as well as architecture and best practices. Those are topics that I also share with my teammates. ๐ฉ๐ปโ๐ป After starting with Kotlin, I also create a couple of courses on how to start with Kotlin and develop an API following ports&adapters architecture with Kotlin. ๐In recent years, I joined Step4ward as a mentor and now as a co-organizer, a community dedicated to helping grow and succeed women in technical careers.