LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

This post is also available in the following languages. Japanese

Improving code quality - Session 41: "Architecture" under construction

The original article was published on September 5, 2024.

Hello, I'm Munetoshi Ishikawa, a mobile client developer for the LINE messaging app.

This article is the latest installment of our weekly series "Improving code quality". For more information about the Weekly Report, please see the first article.

"Architecture" under construction

Let's assume you are creating an application for sending and receiving messages. The data model for the messages to be sent and received is defined as follows.

class MessageModel internal constructor(
    val messageId: String,
    val sender: UserId,
    val contentModel: MessageContentModel
)

sealed interface MessageContentModel {
    class Text(
        val messageText: String
    ): MessageContentModel

    class Image(
        val imageUri: Uri,
        ...
    ): MessageContentModel

    ...

    class ExternalResource(
        val resourceId: ...,
        ...
    ): MessageContentModel
}

As indicated by MessageContentModel, there are several types of messages. In this case, the type is determined by the first character of messageId. (Furthermore, assume that the specification of this messageId cannot be changed due to historical reasons.)

  • "t": Text
  • "i": Image
  • ...
  • "e": External resource

For example, since the first character of the ID "t02af6d450d056" is "t", this is the ID of a text message.

Let's assume that the creation logic for instances of ExternalResource is completely different from other MessageContentModel instances. In that case, the class providing MessageModel (...Dao) may also be implemented separately for ExternalResource and others.

class MainMessageDao {
    fun queryMessageModel(messageId: String): MessageModel? { ... }
}

class ExternalResourceMessageDao {
    fun queryMessageModel(messageId: String): MessageModel? { ... }
}

If there is no message corresponding to messageId, the function queryMessageModel returns null. Therefore, passing the ID of a text message to ExternalResourceMessageModelDao.queryMessageModel will return null.

Using MainMessageDao and ExternalResourceMessageDao according to the message type is cumbersome and can cause bugs. Therefore, we created a class called MessageModelRepository that has the role of selecting the correct ...Dao as follows. Here, the supports function of each ...Dao returns a Boolean indicating whether it corresponds to the given messageId.

class MessageModelRepository(
    private val mainMessageDao: MainMessageDao,
    private val externalResourceMessageDao: ExternalResourceMessageDao,
    ...
) {
    fun queryMessageModel(messageId: String): MessageModel? {
        ... // other code
        val dao = when {
            MainMessageDao.supports(messageId) -> mainMessageDao
            ExternalResourceMessageDao.supports(messageId) -> externalResourceMessageDao
            else -> null
        }
        ...
        return dao?.queryMessageModel(messageId)
    }

    companion object {
        fun isMainMessage(messageId: String): Boolean = ...
        fun isExternalResourceMessage(messageId: String): Boolean = ...
    }
}

By doing this, the side using MessageModelRepository can obtain MessageModel without being aware of the existence of ...Dao.

Below is the test code for MessageModelRepository.

class MessageModelRepositoryTest {
    @get:Rule
    val mockitoRule: MockitoRule = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS)

    private val mockMainMessageDao: MainMessageDao = mock()

    private val mockExternalResourceMessageDao: ExternalResourceMessageDao = mock()

    private lateinit var subject: MessageModelRepository

    @Before
    fun setUp() {
        ... // Setup code
        subject = MessageModelRepository(
            mockMainMessageDao,
            mockExternalResourceMessageDao,
            ...,
        )
    }

    @Test
    fun queryMessageModel_returnsNull_forMalformedId() {
        val nullMessage = subject.queryMessageModel(MALFORMED_MESSAGE_ID)

        assertNull(nullMessage)
        verify(mockMainMessageDao, never()).queryMessageModel(any())
        verify(mockExternalResourceMessageDao, never()).queryMessageModel(any())
    }

    @Test
    fun queryMessageModel_returnsModelOfMainMessage() {
        mockMainMessageDao.stub {
            on { queryMessageModel(TEXT_MESSAGE_ID) } doReturn
                TestMessageModels.TEXT
            on { queryMessageModel(IMAGE_MESSAGE_ID) } doReturn
                TestMessageModels.IMAGE
            ...
        }

        assertIs(subject.queryMessageModel(TEXT_MESSAGE_ID))
        assertIs(subject.queryMessageModel(IMAGE_MESSAGE_ID))
        ...
    }

    @Test
    fun queryMessageModel_returnsModelOfExternalResource() {
        mockExternalResourceMessageDao.stub {
            on { queryMessageModel(EXTERNAL_RESOURCE_MESSAGE_ID) } doReturn
                TestMessageModels.EXTERNAL_RESOURCE
        }

        val model = subject.queryMessageModel(EXTERNAL_RESOURCE_MESSAGE_ID)
        assertIs(model)
    }

    companion object {
        private const val MALFORMED_MESSAGE_ID = "x123456"
        private const val TEXT_MESSAGE_ID = "t123456"
        ...
    }
}

This test confirms the following behaviors are correct.

  • If the format of the message ID is incorrect, null is returned and no DAO is used.
  • For types such as text and image, only MainMessageDao is used (guaranteed by Strictness.STRICT_STUBS).
  • For the external resource type, only ExternalResourceMessageDao is used (guaranteed by Strictness.STRICT_STUBS).

This test covers all cases of val dao = when {. What else should be tested in addition to this?

Tests dedicated to construction

The above test is sufficient to prove that "the current code is correct". However, it cannot detect bugs due to future changes. When a new MessageContentModel and its corresponding DAO are added, it is possible to forget to update MessageModelRepository. In that case, the new type of ID will simply be treated as an invalid ID, and the bug of implementation omission may be overlooked. For example, if MessageContentModel.Foo and FooMessageDao are added and the update of queryMessageModel is forgotten, it will become a bug, but it is difficult to notice it at runtime.

To prevent this, it is good to compare the "list of tested MessageContentModel" and the "list of defined MessageContentModel" as follows.

    ...

    @Test
    fun queryMessageModel_returnsModelOfMainMessage() {
        mockMainMessageDao.stub {
            on { queryMessageModel(TEXT_TEST_DATA.id) } doReturn
                TestMessageModels.TEXT
            on { queryMessageModel(IMAGE_TEST_DATA.id) } doReturn
                TestMessageModels.IMAGE
            ...
        }

        assertTypeOf(TEXT_TEST_DATA.type, subject.queryMessageModel(TEXT_TEST_DATA.id))
        assertTypeOf(IMAGE_TEST_DATA.type, subject.queryMessageModel(IMAGE_TEST_DATA.id))
        ...
    }

    @Test
    fun queryMessageModel_returnsModelOfExternalResource() {
        mockExternalResourceMessageDao.stub {
            on { queryMessageModel(EXTERNAL_RESOURCE_TEST_DATA.id) } doReturn
                TestMessageModels.EXTERNAL_RESOURCE
        }

        val model = subject.queryMessageModel(EXTERNAL_RESOURCE_TEST_DATA.id)
        assertTypeOf(EXTERNAL_RESOURCE_TEST_DATA.type, model)
    }

    @Test
    fun ensureCompletenessOfMessageContentModel() {
        val knownTypes: Set<KClass> = setOf(
            TEXT_TEST_DATA.type,
            IMAGE_TEST_DATA.type,
            ...,
            EXTERNAL_RESOURCE_TEST_DATA.type
        )
        ContentModel::class.sealedSubclasses.forEach { type ->
            assertTrue(
                type in knownTypes,
                "Unknown type: $type. Update [MessageModelRepository] to cover it."
            )
        }
    }

    private class TestData(val id: String, val type: KClass)

    companion object {
        @Suppress("UNUSED_PARAMETER") // `type` is to specify `T` without a type parameter.
        private inline fun  assertTypeOf(type: KClass, actual: Any?) =
            assertTrue(actual is T)

        private val TEXT_TEST_DATA = TestData("t123456", MessageContentModel.Text::class)
        private val IMAGE_TEST_DATA = TestData("i123456", MessageContentModel.Image::class)
        ...
        private val EXTERNAL_RESOURCE_TEST_DATA = TestData("e123456", MessageContentModel.ExternalResource::class)
    }

By doing this, when a new type is added, the test will fail with the message "You must also update MessageModelRepository", so the developer will know what to do and can prevent implementation omissions.

In a nutshell

Tests should be able to accommodate not only the current code but also future changes.

Keywords: test, completeness, specification update

List of articles on techniques for improving code quality