3
\$\begingroup\$

I'm implementing an interface to the Force.com REST API, with very specific ideas about which parts of the API I need. My problem is with testability of consumers of this API abstraction.

The first thing I came up with was a Gateway, one method per action:

interface SalesforceApiGateway {
    fun getLastUpdated(type: ObjectType, backTill: Duration = Duration.ofDays(1)): LastUpdated
    fun getDetails(type: ObjectType, uuid: String, fields: List<String> = emptyList()): Details

    data class LastUpdated(val error: Throwable? = null, val response: LastUpdatedResponse? = null)
    data class Details(val error: Throwable? = null, val response: DetailsResponse? = null)
}

Consumers would get an instance of this interface @Injected, so tests could easily implement the interface and pass that to the unit under test.

But adding more object types and keeping responses meaningful and statically typed became increasingly difficult. Which is why I switched to the following:

interface SalesforceApiClient: HttpClient, BackendAuthenticator<SalesforceCredentials> {}

sealed class SalesforceRequest<T>(val apiClient: SalesforceApiClient) {
    fun getLastUpdated(backTill: Duration = Duration.ofDays(1)): List<String> {
        val req: HttpGet = buildHttpRequest()
        val resp = apiClient.execute(req)
        return ObjectMapper().readValue(resp.entity.content, 
            object: TypeReference<List<String>>() {})
    }

    fun getDetails(uuid: String): T {
        val req: HttpGet = buildDetailRequest(uuid)
        val resp = apiClient.execute(req)
        return ObjectMapper().readValue(resp.entity.content, getValueClass())
    }

    abstract fun getObjectType(): String
    abstract fun getFields(): List<String>
    abstract fun getValueClass(): Class<T>

    class UserRequest(api: SalesforceApiClient): SalesforceRequest<User>(api) {
        override fun getObjectType() = "USER"
        override fun getFields() = User::class.members.map { it.name }
        override fun getValueClass(): Class<User> = User::class.java
    }
}

Voila, typed responses and a set of requests you can reason about at compile time. API consumers are happy now.

But how do I test around this beast? Of course, I can mock SalesforceApiClient, which is just the sum of HttpClient and BackendAuthenticator. But I feel dirty leaking knowledge about the structure of Force.com REST responses into unit tests for users of UserRequest. This kind of feeling tells me there's something wrong my class design. How can I do better?

\$\endgroup\$
3
  • \$\begingroup\$ Welcome to Code Review! Nice first question! \$\endgroup\$
    – syb0rg
    Jun 7 '16 at 13:18
  • \$\begingroup\$ What is ObjectMapper? \$\endgroup\$ Jun 18 '18 at 20:42
  • 1
    \$\begingroup\$ @IgorGanapolsky Oh, didn't mention that. That's from Jackson's main class for parsing: github.com/FasterXML/jackson-databind \$\endgroup\$
    – mabi
    Jun 25 '18 at 10:50
1
\$\begingroup\$

I haven't found a way to properly test/mock SalesforceRequest when exercising it's consumers.

What it did do was make me realize

  1. Naming the class Request and then providing multiple "request"-like methods on it was a mistake. Each SalesforceRequest subtype was a full blown API provider.

  2. Following from 1, I took the generics approach to make the SalesforceApiGateway type-safe (which was the initial driver for the request classes).

I ended up with the following interface:

interface SalesforceObject {
    val Id: String
    val CreatedById: String
    val LastModifiedDate: Instant
}

interface SalesforceApiGateway {
    val API_VERSION: String
    val MAX_RECENT_DAYS: Duration

    fun <T: SalesforceObject> getLastUpdated(type: KClass<T>, backTill: Duration = Duration.ofDays(1)): LastUpdated

    fun <T: SalesforceObject> getLastUpdated(type: KClass<T>, backTill: Instant): LastUpdated
            = getLastUpdated(type, Duration.between(backTill, Instant.now(Clock.systemUTC())))

    fun <T: SalesforceObject> getDetails(type: KClass<T>, uuid: String, fields: List<String> = type.members.map { it.name }): Details<T>

    data class LastUpdatedResponse(val ids: List<String> = emptyList(),
                                   val latestDateCovered: String = ISO_DATES.format(ZonedDateTime.now(Clock.systemUTC())))
    data class LastUpdated(val error: Throwable? = null, val response: LastUpdatedResponse? = null)

    data class DetailsResponse<T>(val wrapped: T)
    data class Details<T>(val error: Throwable? = null, val response: DetailsResponse<T>? = null)
    companion object {
        val ISO_DATES = DateTimeFormatter.ISO_OFFSET_DATE_TIME
    }
}

The upside is that I can now use Kotlin's awesome defaulted parameters to allow my gateway consumers to not care about fields they want to select, but advanced, future uses can still override that choice. It also allows me to express that I want gateway implementations to be in charge of properly de-serializing responses to the correct requested type. Plus, I still get to keep my interface, so testing is trivial by implementing a dummy.

There's several things that were suggested in the process:

  1. Use the Result class to model success/failure instead of custom "result" classes. I wasn't able to wrap my head around their model of creating a result from an existing basic type (like my List<String>). Maybe next time.

  2. Using Retrofit (thanks orangy for mentioning that!) to generate the code for the gateway. However, Jake Wharton confirmed Retrofit won't allow me to template my method, resulting n methods per SalesforceObject. I tried it and just copy and pasting my methods made my skin crawl.

\$\endgroup\$
2
  • \$\begingroup\$ Have you used MockWebServer, or are you testing against production server? \$\endgroup\$ Jun 19 '18 at 13:38
  • \$\begingroup\$ @IgorGanapolsky the project is discontinued, but it didn't use production. I'd have to take a deeper look at what I used to mock the responses. \$\endgroup\$
    – mabi
    Jun 25 '18 at 10:49

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.