🦴

「スケルトンテスト」でテストの作成を自分に強制する

に公開

こんにちは、ナレッジワークのソフトウェアエンジニアの石川宗寿 (munetoshi) です。
この記事は、KNOWLEDGE WORK Blog Sprint の第 27 日目の記事です。

TL;DR:

スケルトンコード (型や関数の枠組みだけ定義した未実装のコード) を書くときは、「未実装であること」を確認するテストも一緒に書くことをおすすめします。私はこれを「スケルトンテスト」と呼んでいます[1]

スケルトンテストを書いておくことで、「コードを実装したにもかかわらずテストを更新しなかった場合に、テストが失敗する」という状況を作れます。結果として、開発者は実装時にテストコードの修正を強制されるわけです。

はじめに: スケルトンコードとは?

スケルトンテストの前に、スケルトンコードについて説明します。

スケルトンコードとは、型や関数のシグネチャ (名前、引数/戻り値の型) や依存関係だけを先に定義し、具体的なロジックは空やダミーになっているコードです。例えば以下の Kotlin のコードでは、FooService というクラスが定義されていて、「FooRepository を使う」ことや「FooRequest を受け取って FooResponse を返す関数 doFoo がある」ことが分かりますが、doFoo の具体的なロジックは省略されています。ここで、TODO は Kotlin の標準ライブラリの関数で、呼び出されると NotImplementedError を投げます。便利ですね。

class FooService(
    private val fooRepository: FooRepository
) {
    fun doFoo(request: FooRequest): FooResponse =
        TODO("Implement super great wonderful logic at #12345")
}

複雑な実装を始める前に、このようなスケルトンコードだけのプルリクエスト[2]を作ることで、レビューアは設計や実装計画などの高い抽象度に集中してレビューできます[3]。結果として、間違いが発覚したときの手戻りを小さく抑えられるでしょう。もちろん、設計の議論のためには Design Doc を書く[4]など、他にも様々な手段がありますが、スケルトンコードはそれらと組み合わせることも可能です。

さらに、スケルトンコードを書くことで、プルリクエストのサイズを小さく保ちやすくなったり、個々の実装を複数の開発者で分担しやすくなったり 、プルリクエスト数を稼いで仕事した気になれたり するといった利点もあります。この記事の主題であるスケルトンテストは、スケルトンコードを利用していることが前提です。

スケルトンテストで未実装をテストする

スケルトンコードが本当に未実装であるかを確認するテストが、私の定義する「スケルトンテスト」です。
先ほどの FooService の例で言うと、doFoo 関数が NotImplementedError を投げることを確認すれば良さそうです。以下のテストでは、assertFailsWith<NotImplementedError> を使って例外が投げられることを確認しています。

class FooServiceTest {
    @Test
    fun `doFoo should be unimplemented`() {
        val request = FooRequest("dummy", 0xDEADBEEF)

        val subject = FooService(FooRepository())
        
        assertFailsWith<NotImplementedError> {
            subject.doFoo(request)
        }
    }
}

このテストは、以下のように doFoo が実装されたときに失敗するようになっています。

class FooService(
    private val fooRepository: FooRepository
) {
    fun doFoo(request: FooRequest): FooResponse {
        /* ... snip ... */
        val result = someSuperGreatWonderfulLogic(request.someValue)

        return FooResponse.from(result)
    }
}

したがって、CI を通すためには、実装者はテストコードを更新しなければなりません。例えば、以下のように doFoo の動作を確認するテストに書き換える必要があります。

class FooServiceTest {
    @Test
    fun `doFoo returns success response with valid request`() {
        val request = FooRequest("key-12345", 42)
        val subject = FooService(FakeFooRepository())

        val actual = subject.doFoo(request)

        assertTrue(actual.isSuccess)
    }

    @Test
    fun `doFoo returns error response with empty key`() {
        val request = FooRequest("", 42)
        val subject = FooService(FakeFooRepository())

        val actual = subject.doFoo(request)

        assertFalse(actual.isSuccess)
    }
}

スケルトンテストの利点

このスケルトンテストには、大きく 3 つの利点があります。

  1. テストへの注目を促す 👀
  2. テストの雛形として機能する 🫥
  3. レビューの関心事を分離できる ✂️

1. テストへの注目を促す 👀

スケルトンテストは、実装とテストの同期を強制します。もし、スケルトンテストを書かずに実装だけを進めようとすると、「テストは後で書けばよい」と考えてしまうかもしれません。一方で、スケルトンテストがあると、CI を通すためには実装と共にテストを正しく書き換えるか、スケルトンテストそのものを削除する必要があります。このとき、「実装したのにテストを書かない」のと「スケルトンテストを削除し、それに代わるテストを書かない」のでは、罪悪感の度合いが大きく異なることから、実装とテストの同期が自然に促進されます。

もちろん、これは実装者が自分自身である場合に限りません。実装を他の開発者に委譲する場合や、複数人で分担する場合にも有効です。スケルトンテストがあることで、他の開発者にもテストを書くことを強制できます。

コードレビューにおいても、スケルトンテストの存在がテストへの注目を促します。テストがないプルリクエストをレビューするとき、レビューアは「別のプルリクエストに分けてテストを書くのかな」などと想像してしまうかもしれません。しかし実際には、「後からテストを書く」といったことは忘れられがちです。実装者も、暗にそれを期待してしまうなんてことも...は考えすぎでしょうか。いずれにせよ、単にテストを書かないことよりも、テストが削除されたことの方がレビューアの注意を引きやすく、テストの欠如を防ぐ効果があります。

2. テストの雛形として機能する 🫥

「テストファイルをゼロから作る」のと「既にあるテストファイルを修正する」のとでは、後者の方が気軽にできるでしょう。スケルトンテストは、実装者がテストを書き始めるための雛形[5]を提供し、テスト作成の心理的ハードルを下げやすくなります。

また、雛形があることで、複数の開発者で作業を分担するときや、AI エージェントに並行して実装させるときに、コンフリクトが起きにくくなるという利点もあります。スケルトンテストを書くときに、複数のテストケースで使われる共通のフェイクやフィクスチャを用意しておいても良いかもしれません。

3. レビューの関心事を分離できる ✂️

スケルトンコードを使うことで、設計や実装計画などを先にレビューできるのと同様に、スケルトンテストでもレビューの関心事を分離できます。

  • スケルトンコードのレビュー時: テストの対象や設計の妥当性に集中してレビューできる (「この関数にテストは必要か?」や「このインターフェースはテストしやすいか?」など)
  • 実装コードのレビュー時: ケースの網羅性やテストの正確性に集中してレビューできる (「主要なエッジケースはカバーされているか?」や「テストで期待する結果は正しいか?」など)

これにより、レビューアはそれぞれのフェーズで異なる観点に集中でき、レビューの質が向上します。さらに、テスト設計に間違いがあったとしても、手戻りを小さく抑えやすくなります。

スケルトンテストのバリエーション

先述の Kotlin の例では、ランタイムエラーを利用して未実装を表現しましたが、デフォルト実装の戻り値やエラー値を利用する方法もあります。

以下のコードは、gRPC の rpc を Go 言語で実装する例です。DoFoo が未実装であることを gRPC の Unimplemented ステータスコードで表現しており、スケルトンテストでは返されたステータスコードを検証しています。

foo_service.go:

type FooService struct {
    // Unimplemented yet
}

func (s *FooService) DoFoo(
    ctx context.Context,
    req *pb.FooRequest
) (*pb.FooResponse, error) {
    return nil, status.Error(codes.Unimplemented, "Not implemented yet")
}

foo_service_test.go:

func TestFooService_DoFoo(t *testing.T) {
    t.Run("should return Unimplemented error", func(t *testing.T) {
        service := MustFooService()
        req := &pb.FooRequest{SomeField: "dummy"}

        _, err := service.DoFoo(context.Background(), req)

        assert.Equal(t, codes.Unimplemented, status.Code(err))
    })
}

スケルトンテストの注意点

当然ながら、スケルトンテストはスケルトンコードとセットで利用することが前提です。つまり、スケルトンテストが有効かどうかはスケルトンコードが書きやすいかどうかに依存します。

例えば新規機能の追加などで、型や関数を新たに定義するといった場合は、スケルトンコードが書きやすく、スケルトンテストも有効になりやすいです。また、リファクタリングであっても、移行先のコードを新規に作成し、フィーチャーフラグなどで切り替えるといった手法を使うときにも有効です。

しかし、既存のコードを直接変更するような機能修正やリファクタリング、あるいは、スケルトンコードが不要なほど小さな機能を実装する場合は、スケルトンテストもあまり有効ではありません。このような場合は、無理にスケルトンテストを書こうとせず、通常のテスト作成フローを踏むことをおすすめします。

まとめ

スケルトンコードを書く際には、ぜひ「スケルトンテスト」もセットで作成してみてください。スケルトンテストを書くことで、テストへの注目を促し、テスト作成の心理的ハードルを下げ、レビューの関心事を分離できます。結果として、実装とテストの同期を取りやすくなり、テストの質も向上するでしょう。

KNOWLEDGE WORK Blog Sprint、明日 9/28 の執筆者はセキュリティエンジニアの moneymog さんです。
お楽しみに!

脚注
  1. スケルトンコード用のテストではなく、テストのスケルトン (テストの枠組みだけを定義し、テストロジックを書いてない状態) を「スケルトンテスト」と言うこともあるようです。 ↩︎

  2. ここでは、単にレビュー/マージの単位のことを「プルリクエスト」と言っています。 ↩︎

  3. スケルトンコードを利用したレビューの具体例については、Knowledge Work Developers Blog の記事もご参照ください。この記事中の「石川宗寿さん」は私のことです。 ↩︎

  4. Design Doc については、こちらもご参照ください。Design Doc の書き方 ↩︎

  5. 「スケルトンテスト」は、スケルトンのテストというだけでなく、テストのスケルトンとしての役割も持つということです。ややこしいですね。 ↩︎

株式会社ナレッジワーク

Discussion