エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

IntelliJ IDEA AIツール比較: Copilot vs AI Assistant

【マルチデバイスチーム ブログリレー3日目】

マルチデバイスチーム(以下、マルデバ)の田根です。
主にサーバーサイドとインフラを担当していますが、必要とあればスマホアプリの開発も行うことがあります。

エムスリーではAIの活用を推進しています。 サーバーサイドおよびインフラ開発には IntelliJ IDEA を利用しています。 そこで IntelliJ IDEA上で手軽に導入できる代表的 AI ツールとして「JetBrains AI Assistant」と「GitHub Copilot」をピックアップし、実際に試用したうえで比較します。

AIツールの比較

JetBrains AI Assistant

  • 提供元:JetBrains
  • モデル:GPT-4、Claude、Gemini、社内ホスト型LLM
  • 料金:月額課金。JetBrains All Products Pack など

GitHub Copilot

  • 提供元:GitHub(Microsoft)
  • モデル:GPT-4、Claude、Geminiなど
  • 料金:月額課金(個人・チームプランあり)

AIに実行させるタスク

今回の比較では Kotlin 製のサーバーサイドのテスト未実装なロジックに対し、AI を用いてユニットテストコードを自動生成し、その後リファクタリングまで行います。
対象のクラス(「関数が1つだけ」の比較的シンプルな実装です)

@Component
class ClinicLogicImpl(
    private var clinicClient: ClinicClient,
) : ClinicLogic {
    override fun getAllCategories(account: Account): List<Category> =
        clinicClient
            .getTop(account)
            ?.let { top ->
                val tags = top.categories.associate { it.id to it.name }
                val contents = top.contents.map { Content(it.id, it.title, it.linkUrl, it.contentImage, it.note, tags[it.categoryId]) }
                val recommended = Category(id = 0, name = "おすすめ", shortName = "おすすめ", readableMore = false, contents = contents)
                val categories =
                    top.categories.map { category ->
                        val topContents = top.contents
                            .filter { content -> content.categoryId == category.id }
                            .map { Content(it.id, it.title, it.linkUrl, it.contentImage, it.note, null) }
                        Category(id = category.id, name = category.name, shortName = category.name, readableMore = false, contents = topContents)
                    }
                listOf(recommended) + categories
            }.orEmpty()
}

モデル:GPT-4.1

手順:

  1. /explain コマンドで処理概要をAIに説明
  2. テストコードの生成
  3. リファクタリング

explain

まずは /explain コマンドで説明させてみます。

JetBrains AI Assistant

このファイルは、クリニック関連のカテゴリ一覧取得ロジックの具体的な実装を提供しています。アカウント情報からカテゴリとそのコンテンツを取得・整形し、特定のおすすめカテゴリも含めて返却する役割を持っています。

GitHub Copilot

このコードは、ClinicLogicImpl というクラスの実装で、Spring Boot の @Component アノテーションが付与されています。これは依存性注入の対象となることを意味します。ClinicLogicImplClinicLogic インターフェースを実装しており、ClinicClient をコンストラクタインジェクションで受け取ります。

主なメソッドは getAllCategories(account: Account): List<Category> です。このメソッドは、clinicClient.getTop(account) を呼び出してトップデータを取得し、null でなければ処理を続けます。

val tags = top.categories.associate { it.id to it.name }

ここでは、カテゴリIDとカテゴリ名のマップを作成しています。

次に、全コンテンツを Content オブジェクトに変換し、カテゴリ名を付与したリストを作成します。

val contents = top.contents.map { Content(it.id, it.title, it.linkUrl, it.contentImage, it.note, tags[it.categoryId]) }

「おすすめ」カテゴリ(ID: 0)を作成し、全コンテンツを含めます。

val recommended = Category(id = 0, name = "おすすめ", shortName = "おすすめ", readableMore = false, contents = contents)

その後、各カテゴリごとに該当するコンテンツのみを抽出し、カテゴリリストを作成します。

val categories = top.categories.map { category ->
    val topContents = top.contents
        .filter { content -> content.categoryId == category.id }
        .map { Content(it.id, it.title, it.linkUrl, it.contentImage, it.note, null) }
    Category(id = category.id, name = category.name, shortName = category.name, readableMore = false, contents = topContents)
}

最後に、「おすすめ」カテゴリと各カテゴリを結合したリストを返します。もし clinicClient.getTop(account) が null の場合は空リストを返します。全体として、アカウントに紐づくカテゴリとコンテンツの一覧を組み立てて返すロジックです。

総評

JetBrains AI Assistant は要点を絞ったシンプルな説明が得意で、正確性も高いです。
一方、GitHub Copilot は網羅的かつ詳細な説明を行いますが、今回はやや冗長に感じました。
今回のようなシンプルなクラスでは JetBrains AI Assistant が手早く理解をサポートしてくれますが、より複雑なロジックでは GitHub Copilot の詳細説明が有用でしょう。

テストコードの生成

JetBrains AI Assistant

@SpringBootTest
class ClinicLogicImplTest {

    @MockBean
    lateinit var clinicClient: ClinicClient

    @Autowired
    lateinit var clinicLogicImpl: ClinicLogicImpl

    @Test
    fun `getAllCategories should return empty list when ClinicClient returns null`() {
        val mockAccount = Account(id = "test-account-id", name = "Test Account")
        Mockito.`when`(clinicClient.getTop(mockAccount)).thenReturn(null)

        val result = clinicLogicImpl.getAllCategories(mockAccount)

        assertEquals(0, result.size)
    }

    @Test
    fun `getAllCategories should return categories with recommended when ClinicClient returns valid data`() {
        val mockAccount = Account(id = "test-account-id", name = "Test Account")
        val mockCategories = listOf(
            Category(id = 1, name = "Health", shortName = "Health", readableMore = true, contents = emptyList()),
            Category(id = 2, name = "Fitness", shortName = "Fitness", readableMore = true, contents = emptyList())
        )
        val mockContents = listOf(
            Content(
                id = 1,
                title = "Health Tips",
                linkUrl = "http://example.com",
                contentImage = "image1.jpg",
                note = "Stay healthy!",
                categoryId = 1
            ),
            Content(
                id = 2,
                title = "Fitness Guide",
                linkUrl = "http://example.com",
                contentImage = "image2.jpg",
                note = "Stay fit!",
                categoryId = 2
            )
        )
        val mockTop = Top(categories = mockCategories, contents = mockContents)
        Mockito.`when`(clinicClient.getTop(mockAccount)).thenReturn(mockTop)

        val result = clinicLogicImpl.getAllCategories(mockAccount)

        assertEquals(3, result.size)
        assertEquals("おすすめ", result[0].name)
        assertEquals(2, result[0].contents.size)
        assertEquals("Health", result[1].name)
        assertEquals(1, result[1].contents.size)
        assertEquals("Fitness", result[2].name)
        assertEquals(1, result[2].contents.size)
    }

    @Test
    fun `getAllCategories should properly map tags to recommended contents`() {
        val mockAccount = Account(id = "test-account-id", name = "Test Account")
        val mockCategories = listOf(
            Category(
                id = 101,
                name = "Mental Health",
                shortName = "Mental",
                readableMore = true,
                contents = emptyList()
            ),
        )
        val mockContents = listOf(
            Content(
                id = 11,
                title = "Mindfulness Tips",
                linkUrl = "http://example.com",
                contentImage = "image11.jpg",
                note = "Stay mindful!",
                categoryId = 101
            )
        )
        val mockTop = Top(categories = mockCategories, contents = mockContents)
        Mockito.`when`(clinicClient.getTop(mockAccount)).thenReturn(mockTop)

        val result = clinicLogicImpl.getAllCategories(mockAccount)

        assertEquals(2, result.size)
        assertEquals("おすすめ", result[0].name)
        assertEquals("Mental Health", result[0].contents[0].categoryName)
    }

    @Test
    fun `getAllCategories should handle empty category list`() {
        val mockAccount = Account(id = "test-account-id", name = "Test Account")
        val mockTop = Top(categories = emptyList(), contents = emptyList())
        Mockito.`when`(clinicClient.getTop(mockAccount)).thenReturn(mockTop)

        val result = clinicLogicImpl.getAllCategories(mockAccount)

        assertEquals(1, result.size)
        assertEquals("おすすめ", result[0].name)
        assertEquals(0, result[0].contents.size)
    }
}

GitHub Copilot

class ClinicLogicImplTest {

    private val clinicClient: ClinicClient = mock(ClinicClient::class.java)
    private val clinicLogic = ClinicLogicImpl(clinicClient)
    private val account = mock(Account::class.java)

    @Test
    fun returnsRecommendedAndCategoryListWhenTopIsPresent() {
        val categories = listOf(
            Category(id = 1, name = "カテゴリ1", shortName = "カテゴリ1", readableMore = false, contents = emptyList()),
            Category(id = 2, name = "カテゴリ2", shortName = "カテゴリ2", readableMore = false, contents = emptyList())
        )
        val contents = listOf(
            Content(id = 10, title = "タイトル1", linkUrl = "url1", contentImage = "img1", note = "note1", categoryName = null, categoryId = 1),
            Content(id = 20, title = "タイトル2", linkUrl = "url2", contentImage = "img2", note = "note2", categoryName = null, categoryId = 2)
        )
        val top = Top(categories = categories, contents = contents)
        `when`(clinicClient.getTop(account)).thenReturn(top)

        val result = clinicLogic.getAllCategories(account)

        assertThat(result).hasSize(3)
        assertThat(result[0].name).isEqualTo("おすすめ")
        assertThat(result[0].contents).hasSize(2)
        assertThat(result[1].name).isEqualTo("カテゴリ1")
        assertThat(result[1].contents).hasSize(1)
        assertThat(result[2].name).isEqualTo("カテゴリ2")
        assertThat(result[2].contents).hasSize(1)
    }

    @Test
    fun returnsEmptyListWhenTopIsNull() {
        `when`(clinicClient.getTop(account)).thenReturn(null)

        val result = clinicLogic.getAllCategories(account)

        assertThat(result).isEmpty()
    }

    @Test
    fun returnsRecommendedCategoryWithNoContentsWhenTopHasNoContents() {
        val categories = listOf(
            Category(id = 1, name = "カテゴリ1", shortName = "カテゴリ1", readableMore = false, contents = emptyList())
        )
        val top = Top(categories = categories, contents = emptyList())
        `when`(clinicClient.getTop(account)).thenReturn(top)

        val result = clinicLogic.getAllCategories(account)

        assertThat(result).hasSize(2)
        assertThat(result[0].name).isEqualTo("おすすめ")
        assertThat(result[0].contents).isEmpty()
        assertThat(result[1].name).isEqualTo("カテゴリ1")
        assertThat(result[1].contents).isEmpty()
    }

    @Test
    fun assignsCategoryNameToRecommendedContents() {
        val categories = listOf(
            Category(id = 1, name = "カテゴリ1", shortName = "カテゴリ1", readableMore = false, contents = emptyList())
        )
        val contents = listOf(
            Content(id = 10, title = "タイトル1", linkUrl = "url1", contentImage = "img1", note = "note1", categoryName = null, categoryId = 1)
        )
        val top = Top(categories = categories, contents = contents)
        `when`(clinicClient.getTop(account)).thenReturn(top)

        val result = clinicLogic.getAllCategories(account)

        assertThat(result[0].contents[0].categoryName).isEqualTo("カテゴリ1")
    }
}

総評

両ツールともに生成されたテストコードの品質はほぼ同等で、必要なカバレッジも確保できました。
ただし、出力をそのまま貼り付けるとコンパイルエラーが発生したため、一部コードを手動で修正する必要があります。

リファクタリング

テストコードの作成が完了したため、AIにリファクタリングを依頼しました。
結果として得られたコードは量が多いため、一部のみ抜粋して紹介します。

JetBrains AI Assistant

リファクタリング内容:

  • 関数抽出:「おすすめ」カテゴリ生成、および通常カテゴリ生成の処理をそれぞれcreateRecommendedCategorycreateCategoryList関数として抽出し、メインロジックをわかりやすく整理します。
  • 変数名の明確化:可読性向上のため、変数名を補足説明的なものへ変更しました(例: tagscategoryTags)。
  • 定数抽出:「おすすめ」カテゴリのID・名前を定数化します。
 override fun getAllCategories(account: Account): List<Category> =
        clinicClient.getTop(account)
            ?.let { top ->
                val categoryTags = top.categories.associate { it.id to it.name }
                val recommendedCategory = createRecommendedCategory(top, categoryTags)
                val categories = createCategoryList(top)
                listOf(recommendedCategory) + categories
            }.orEmpty()

これにより、関数・変数の役割が明確になり、今後の拡張や保守が容易になります。

GitHub Copilot

ClinicLogicImpl の getAllCategories メソッドを読みやすくリファクタリングします。 処理を小さな関数に分割し、変数名も明確にします。 このリファクタリングにより、各処理の意図が明確になり、テストや保守がしやすくなります。

override fun getAllCategories(account: Account): List<Category> {
        val top = clinicClient.getTop(account) ?: return emptyList()
        val categoryIdNameMap = top.categories.associate { it.id to it.name }
        val recommendedContents = createRecommendedContents(top.contents, categoryIdNameMap)
        val recommendedCategory = createRecommendedCategory(recommendedContents)
        val categoryList = createCategoryList(top.categories, top.contents)
        return listOf(recommendedCategory) + categoryList
    }

総評

両ツールのリファクタリング結果はほぼ同等でした。
GitHub Copilot は早期リターンを採用しており、若干可読性が向上しています。
ただし、対象ロジックがシンプルだったため、大きな差は見られませんでした。

まとめ

  1. 説明の詳細さ

    • GitHub Copilot:要点を網羅した詳細な解説を提供
    • JetBrains AI Assistant:シンプルかつ的確に説明
  2. コード生成

    • 両ツールとも高品質なコードを生成
    • GitHub Copilot のほうが若干可読性が高いと感じた
  3. 使い勝手

    • JetBrains AI Assistant:IntelliJ IDEA の UI に統合され、テストクラスをシームレスに生成可能
    • GitHub Copilot:チャットベースでの操作&生成コードは手動貼り付けが必要(AgentMode(Preview)を利用すれば手動貼り付け不要)
  4. 総合評価
    現状では両者に大きな差は見られず、用途や好みに応じて使い分けて問題ありません。

  5. 今後の展望

    • JetBrains からは AI コーディングエージェント「Junie」が提供されており、さらなる機能強化が期待されます
    • GitHub Copilot も AI エージェント機能「Agent Mode」のプレビューを公開しています
      今後はこれら AI エージェントの活用が一層の注目点となるでしょう。

We are hiring!

エムスリーでは、スマホアプリエンジニアを大大大絶賛募集しています!
アプリの開発だけではなく、「バックエンドも学びたい」「インフラ構築に挑戦したい」――そんな意欲あふれるエンジニアを歓迎します!

speakerdeck.com

jobs.m3.com