Search code examples
kotlinunit-testingamazon-s3junit5mockk

How to mock s3 put object request using mockK?


I have a function below which uploads data to s3

val s3Client = S3Client.create()
    
data class S3Object(
     val bucket: String,
     val key: String,
     val contents: String,
     val contentType: String
)
        
 fun putS3Object(s3Object: S3Object){
     val putObjectRequest = PutObjectRequest.builder()
                        .bucket(s3Object.bucket)
                        .key(s3Object.key.toString())
                        .contentType(s3Object.contentType)
                        .build()
     s3Client.putObject(putObjectRequest, RequestBody.fromString(s3Object.contents))
  }

I am mocking it as below:

every { S3Client.create()} returns s3Client

val s3Object = S3Object("test-bucket", "/test/key", "test-event", "application/json; charset=UTF-8")
val putRequest = PutObjectRequest.builder()
            .bucket(s3Object.bucket)
            .contentType(s3Object.contentType)
            .key(s3Object.key)
            .build()
val putObjectResponse = PutObjectResponse
            .builder()
            .build()
every{ s3Client.putObject(putObjectRequest, RequestBody.fromString(s3Object.contents))} answers {putObjectResponse}

I am creating the s3 PutObjectRequest and PutObjectResponse as part of the mock. However when I try to run my test I get the following mockK exception:

io.mockk.MockKException: no answer found for: S3Client(#4).putObject(PutObjectRequest(Bucket=test-bucket, ContentType=application/json; charset=UTF-8, Key="/test/key"), software.amazon.awssdk.core.sync.RequestBody@49322d04)


Solution

  • Firstly, is the call to s3Client.putObject(..) what you are trying to test.. that the data that gets sent to that method is correct? I will assume so.

    It'll be easier if you separate the Class under test versus the test class. So in the main code line you should have something like this:

    import software.amazon.awssdk.core.sync.RequestBody
    import software.amazon.awssdk.services.s3.S3Client
    import software.amazon.awssdk.services.s3.model.PutObjectRequest
    
    data class S3Object(
        val bucket: String,
        val key: String,
        val contents: String,
        val contentType: String,
    )
    
    class S3Service(
        // always in inject your dependencies then we can override then you override them in a test
        private val s3Client: S3Client = S3Client.create() 
    ) {
        fun putS3Object(s3Object: S3Object) {
            val putObjectRequest = PutObjectRequest.builder()
                .bucket(s3Object.bucket)
                .key(s3Object.key.toString())
                .contentType(s3Object.contentType)
                .build()
            s3Client.putObject(putObjectRequest, RequestBody.fromString(s3Object.contents))
        }
    }
    

    And now the test class:

    import io.kotest.matchers.shouldBe
    import io.mockk.clearAllMocks
    import io.mockk.every
    import io.mockk.mockk
    import io.mockk.slot
    import org.junit.jupiter.api.BeforeEach
    import org.junit.jupiter.api.Test
    import software.amazon.awssdk.core.sync.RequestBody
    import software.amazon.awssdk.services.s3.S3Client
    import software.amazon.awssdk.services.s3.model.PutObjectRequest
    import software.amazon.awssdk.services.s3.model.PutObjectResponse
    
    class S3ServiceTest {
    
        // you need a mock the client since this is what you want to mock to check the invocation
        private val s3Client = mockk<S3Client>()
        private lateinit var s3Service: S3Service
    
        @BeforeEach
        fun beforeEach() {
            // its supposedly marginally more efficient to define your mocks once and clear then not instantiate each time
            clearAllMocks()
            // inject the mock s3Client so you do not get the `S3Client.create()` one
            s3Service = S3Service(s3Client)
        }
    
        @Test
        fun `putObject call correctly formed`() {
            val s3Object = S3Object(
                "test-bucket",
                "/test/key",
                "test-event",
                "application/json; charset=UTF-8",
            )
            every { s3Client.putObject(any<PutObjectRequest>(), any<RequestBody>()) } returns mockk()
            s3Service.putS3Object(s3Object)
        }
    

    Now this is incomplete - it doesn't assert anything. But the point here is that in the line every { s3Client.putObject(any<PutObjectRequest>(),... I am setting up that Mockk will capture ANY input to the putObject(). It it wrong to instruct Mockk to expect a particular input in this case... rather you want to see what that in put is and then test it (see later).

    I needed to use <PutObjectRequest> because there are several s3Client.putObject() so mockk needs to know which you are listening to.

    And another change was the end of the line. You put this:

    } answers {putObjectResponse}
    

    this is ok, but two things

    1. for the test do you really care what the putObject() returns? You might do if this is part of a more complex test. Use returns mock() if you don't care
    2. There are two "endings" you can use here... returns OBJECT or answers { LAMBDA }. The second case is useful when you don't know what to return under after later, in this case you output is static is it is more efficient to use returns OBJECT...

    In the case of of needing a proper return do this:

            val putObjectResponse = PutObjectResponse
                .builder()
                .build()
            every { s3Client.putObject(any<PutObjectRequest>(), any<RequestBody>()) } returns putObjectResponse
    

    Now how can we actually see if the call to putObject was correctly made? For this you need a different feature called a slot which you use like this:

        @Test
        fun `putObject call correctly formed2`() {
            val s3Object = S3Object(
                "test-bucket",
                "/test/key",
                "test-event",
                "application/json; charset=UTF-8",
            )
            every { s3Client.putObject(any<PutObjectRequest>(), any<RequestBody>()) } returns mockk()
    
            val pubObjectRequestSlot = slot<PutObjectRequest>()
            every { s3Client.putObject(capture(pubObjectRequestSlot), any<RequestBody>()) } returns mockk()
    
            s3Service.putS3Object(s3Object)
    
            pubObjectRequestSlot.captured.bucket() shouldBe "test-bucket"
            pubObjectRequestSlot.captured.key() shouldBe "/test/key"
            // more assertions here...
            // and of course you can capture the RequestBody argument too
        }