Build. Translate. Understand.

曲レコメンデーション問題の仕様化

2025-04-08 21:30:25

原文: Characterising song recommendations by Mark Seemann

仕様化テストとミューテーションテストを用いた既存の動作の解明。一例として。

この記事は、私が「曲レコメンデーション」問題と呼ぶ、与えられた問題に対する複数の設計アプローチを紹介する連載記事の一部です。簡潔に言えば、この問題は膨大な「スクロブル」(再生履歴)のリポジトリに基づいてユーザーに曲を推薦するというものです。この問題は元々Oleksii Holubによって提案されました。彼は関数型プログラミング(FP)に適していない問題の例としてこれを挙げています。

導入記事で述べたように、私はこの機会を活用して代替的なFP設計を示したいと思います。しかし、その前に、Oleksii Holubのコード例の動作版と、信頼性の高いテストスイートが必要です。この記事ではそれについて説明します。

この記事のコードはほとんどが、この連載記事に付属する.NETリポジトリのmasterブランチからのものですが、一部のコードはそのブランチの中間コミットから取られています。

推測された詳細情報

元の記事はコードのみを示しており、既存のコードベースへのリンクはありません。Oleksii Holubにコピーを持っているか尋ねることもできたと思いますが、そのようなコードベースの存在は保証されていません。いずれにせよ、総合的なコードスニペットから完全なコードベースを推測することは、それ自体が興味深い取り組みです。

最初のステップとして、例のコードをコードベースにコピーしました。しかし、最初はコンパイルできなかったため、欠けていた依存関係をいくつか推測する必要がありました。それは3つの値オブジェクトと1つのインターフェースだけでした:

public sealed record Song(int Id, bool IsVerifiedArtist, byte Rating);

public sealed record Scrobble(Song Song, int ScrobbleCount);

public sealed record User(string UserName, int TotalScrobbleCount);

public interface SongService
{
    Task<IReadOnlyCollection<User>> GetTopListenersAsync(int songId);
    Task<IReadOnlyCollection<Scrobble>> GetTopScrobblesAsync(string userName);
}

これらの型宣言はシンプルですが、いくつか注目すべき点があります。まず、SongScrobbleUserはC#のレコードであり、これは言語の比較的新しい機能です。もしあなたが他のC系言語や古いバージョンのC#を使っているなら、通常の言語構文を使って同様の不変の値オブジェクトを実装できますが、一行構文の代わりにより多くのコードが必要になります。F#の開発者はもちろんレコードの概念に馴染みがあり、Haskellにも同様の機能があります。

上記の型宣言についてもう一つ注目すべき点は、SongServiceがインターフェースであるにもかかわらず、Iプレフィックスがないことです。これは文法的には正当ですが、C#では慣用的ではありません。私は元のコードサンプルの名前をそのまま使用したので、それが直接の理由です。Oleksii Holubはこの型をベースクラスとして意図していた可能性がありますが、様々な理由から私はインターフェースを好みます。ただし、この特定の例ではあまり違いはないでしょう。C#の命名規則に慣れている読者が混乱する可能性があるため、あえて指摘しています。一方、Javaプログラマーにとっては馴染み深いものでしょう。

記憶している限り、コードをコンパイルさせるために必要だった唯一の変更は、RecommendationsProviderクラスにコンストラクタを追加することでした:

public RecommendationsProvider(SongService songService)
{
    _songService = songService;
}

これは基本的なコンストラクタインジェクションであり、アンダースコアプレフィックスは冗長だと私は思いますが、元の例にできるだけ忠実であるためにそのままにしました。

これでコードはコンパイルできるようになりました。

テストダブル

この連載の目的は、同じ動作を実装する複数の代替設計を提示することです。これは、コードをリファクタリングする際に、既存の機能を損なっていないことを確認する必要があることを意味します。

“リファクタリングするための本質的な前提条件は[…]堅固なテスト”

現在、テストがないので、いくつか追加する必要があります。RecommendationsProviderは注入されたSongServiceを大いに活用しているため、テストでは有意義な検証を行うためにその依存関係を提供する必要があります。スタブとモックはカプセル化を破壊するため、代わりに状態ベースのテストフェイクオブジェクトを使うことを好みます。

いくつかの実験を経て、以下のFakeSongServiceを作成しました:

public sealed class FakeSongService : SongService
{
    private readonly ConcurrentDictionary<int, Song> songs;
    private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, int>> users;
 
    public FakeSongService()
    {
        songs = new ConcurrentDictionary<int, Song>();
        users = new ConcurrentDictionary<string, ConcurrentDictionary<int, int>>();
    }
 
    public Task<IReadOnlyCollection<User>> GetTopListenersAsync(int songId)
    {
        var listeners =
            from kvp in users
            where kvp.Value.ContainsKey(songId)
            select new User(kvp.Key, kvp.Value.Values.Sum());
 
        return Task.FromResult<IReadOnlyCollection<User>>(listeners.ToList());
    }
 
    public Task<IReadOnlyCollection<Scrobble>> GetTopScrobblesAsync(
        string userName)
    {
        var scrobbles = users
            .GetOrAdd(userName, new ConcurrentDictionary<int, int>())
            .Select(kvp => new Scrobble(songs[kvp.Key], kvp.Value));
 
        return Task.FromResult<IReadOnlyCollection<Scrobble>>(scrobbles.ToList());
    }
 
    public void Scrobble(string userName, Song song, int scrobbleCount)
    {
        users.AddOrUpdate(
            userName,
            new ConcurrentDictionary<int, int>(
                new[] { KeyValuePair.Create(song.Id, scrobbleCount) }),
            (_, scrobbles) => AddScrobbles(scrobbles, song, scrobbleCount));
 
        songs.AddOrUpdate(song.Id, song, (_, _) => song);
    }
 
    private static ConcurrentDictionary<int, int> AddScrobbles(
        ConcurrentDictionary<int, int> scrobbles,
        Song song,
        int scrobbleCount)
    {
        scrobbles.AddOrUpdate(
            song.Id,
            scrobbleCount,
            (_, oldCount) => oldCount + scrobbleCount);
        return scrobbles;
    }
}

並行辞書を使用していることについて疑問に思われるかもしれませんが、これは実装を書きやすくするためであり、実装をスレッドセーフにする必要があるからではありません。実際、この実装はスレッドセーフではないと確信しています。しかし、それは問題ではありません。テストでは共有の可変状態を使用しません。

GetTopListenersAsyncGetTopScrobblesAsyncメソッドはインターフェースを実装しており、Scrobbleメソッド(ここでは「スクロブルする」という動詞)はテストがFakeSongServiceにデータを入力できるようにするバックドアです。

最初のテスト

「プロダクションコード」はC#ですが、テストはF#で書くことにしました。その理由は2つあります。

一つ目は、様々なFP設計をC#とF#の両方で提示したかったからです。テストをF#で書くことで、C#コードベースをF#の代替実装に置き換えることが容易になります。

二つ目の理由は、プロパティベースのテストフレームワークの能力を活用して、多くのランダム生成テストケースを作成したかったからです。変更を加えたときにエラーを見逃してしまうような、特定の例だけのテストにならないという確信を得るために、これが重要だと考えました。RecommendationsProvider APIは非同期であるため、Task値のプロパティを実行できる.NETのプロパティベースフレームワークで私が知っていたのはFsCheckだけでした。C#からFsCheckを使用することも可能ですが、F# APIの方がより強力です。

しかし、まず手始めに、FsCheckを使わない基本的なテストを書きました:

[<Fact>]
let ``No data`` () = task {
    let srvc = FakeSongService ()
    let sut = RecommendationsProvider srvc
    let! actual = sut.GetRecommendationsAsync "foo"
    Assert.Empty actual }

これは自明なケースであると同時にエッジケースですが、明らかにSongServiceにデータがなければ、RecommendationsProviderは曲を推薦できません。

通常仕様化テストで行うように、テストが失敗するようにテスト対象システム(SUT)を一時的に妨害しました。これは同語反復的なアサーションを書いていないことを確認するためです。適切な理由でテストが失敗するのを確認した後、妨害を元に戻してコードをコミットしました。

プロパティベースのテストへの移行

上記のNo dataテストでは、特定の入力値"foo"は重要ではありません。他の任意の文字列でも良いはずなので、これをプロパティに変換してみましょう。

この特定の場合、userNameは任意の文字列でも良いのですが、「現実的な」ユーザー名を生成するカスタムジェネレータを作成するのが適切かもしれません。簡略化のために、ユーザー名は1〜20文字で、英数字から構成され、最初の文字は必ずアルファベットであると仮定します:

module Gen =
    let alphaNumeric = Gen.elements (['a'..'z'] @ ['A'..'Z'] @ ['0'..'9'])
 
    let userName = gen {
        let! length = Gen.choose (1, 19)
        let! firstLetter = Gen.elements <| ['a'..'z'] @ ['A'..'Z']
        let! rest = alphaNumeric |> Gen.listOfLength length
        return firstLetter :: rest |> List.toArray |> String }

厳密に言えば、ユーザー名が互いに区別できる限り、コードは機能するはずなので、このジェネレータは必要以上に制約が厳しいかもしれません。なぜこのような制約を設けたのでしょうか?理由は2つあります:まず、FsCheckが反例を見つけると、プロパティが失敗した原因となる値が表示されます。20文字の英数字文字列は、改行や非表示文字を含む任意の文字列よりも理解しやすいです。2つ目の理由は、後で代替実装のメモリ使用量を測定する予定があり、データが現実的なサイズを持つことが望ましいと考えたからです。人間が選択するユーザー名は、平均して20文字を超えることはないだろうと判断しました。

これで先ほどのNo dataテストをFsCheckのプロパティとして書き直すことができます:

[<Property>]
let ``No data`` () =
    Gen.userName |> Arb.fromGen |> Prop.forAll <| fun userName ->
    task {
        let srvc = FakeSongService ()
        let sut = RecommendationsProvider srvc
 
        let! actual = sut.GetRecommendationsAsync userName
 
        Assert.Empty actual } :> Task

GetRecommendationsAsyncメソッドにランダムなユーザー名を供給するだけのためにこれは過剰かもしれないと思われるかもしれません。個別に見ればそう思うかもしれませんが、この編集はFsCheckのインフラを整備する良い機会でした。これでさらに多くのプロパティを追加するための基盤ができました。

完全なカバレッジ

GetRecommendationsAsyncメソッドの循環的複雑度は3しかないため、完全なコードカバレッジを達成するためには多くのテストは必要ありません。100%コードカバレッジ自体が目標であるべきではありませんが、テストされていないコードベースにテストを追加する場合、これは信頼性の指標として役立ちます。低い循環的複雑度にもかかわらず、このメソッドはフィルタリングとソートを含め、実際にはかなり複雑です。100%カバレッジは最低限の目標と考えています。

上記のNo dataテストは3つの分岐のうちの1つを検証します。完全なカバレッジを達成するには、最大でもあと2つのテストが必要です。ここではそのうちの最も単純なものだけを紹介します。

次のテストケースには、既に示したGen.userNameに加えて、新しいFsCheckジェネレータが必要です。

let song = ArbMap.generate ArbMap.defaults |> Gen.map Song

シンプルな一行のコードとしては、これはフェアベーンの閾値に近いかもしれませんが、このジェネレータに名前を付けることでテストの可読性が向上すると考えました。

[<Property>]
let ``One user, some songs`` () =
    gen {
        let! user = Gen.userName
        let! songs = Gen.arrayOf Gen.song
        let! scrobbleCounts =
            Gen.choose (1, 100) |> Gen.arrayOfLength songs.Length
        return (user, Array.zip songs scrobbleCounts) }
    |> Arb.fromGen |> Prop.forAll <| fun (user, scrobbles) ->
    task {
        let srvc = FakeSongService ()
        scrobbles |> Array.iter (fun (s, c) -> srvc.Scrobble (user, s, c))
        let sut = RecommendationsProvider srvc
 
        let! actual = sut.GetRecommendationsAsync user
 
        Assert.Empty actual } :> Task

このテストは単一ユーザーのためのスクロブルを作成し、それらをフェイクデータストアに追加します。TIEファイター構文を使用して、ジェネレータとテスト本体を接続しています。

すべてのスクロブル数が1から100の間で生成されるため、どれも10,000以上にはならず、したがってテストは推薦が返されないことを期待します。

「なぜスクロブル数に別の範囲を選ばなかったのか」と疑問に思われるかもしれません。正直に言うと、この段階ではまだテストの表現方法を模索していて、最初のステップとして完全コードカバレッジを目指していました。このテストのアサーションは弱いものの、GetRecommendationsAsyncメソッドの別の分岐パスを検証しています。

被テストシステムを完全にカバーするには、あと1つのテストを書くだけで十分でした。そのメソッドはより複雑なので、詳細は割愛します。興味があれば、サンプルのソースコードリポジトリを参照してください。

ミューテーションテスト

コードカバレッジは目標として有用ではないと考えていますが、ツールとしては啓発的です。この場合、完全なカバレッジを達成したことがわかったので、より高い目標を目指すには別の技術に頼る必要があることを認識しました。

次の技術としてミューテーションテストを選びました。GetRecommendationsAsyncメソッドはOrderByDescendingTakeWhereなどのLINQメソッドを多用しています。.NET用のStrykerはLINQに対応しているため、自動的なコード変異の中で、例えばWhereTakeを削除した場合にどうなるかをテストします。

Strykerの用語は少し子供っぽいと感じますが、少なくとも緑の評価(合格)を得るまで「ミュータントを殺す」という目標を設定しました。

アサーションを追加(つまり事後条件を強化)し、テストを追加することで、その目標に近づけることがわかりました。例よりもプロパティを定義する方が簡単な場合もありますが、逆の場合もあります。今回は単一の具体例を追加する方が簡単だと判断しました:

[<Fact>]
let ``One verified recommendation`` () = task {
    let srvc = FakeSongService ()
    srvc.Scrobble ("cat", Song (1, false, 6uy),     10)
    srvc.Scrobble ("ana", Song (1, false, 5uy),     10)
    srvc.Scrobble ("ana", Song (2,  true, 5uy), 9_9990)
    let sut = RecommendationsProvider srvc
 
    let! actual = sut.GetRecommendationsAsync "cat"
 
    Assert.Equal<Song> ([ Song (2, true, 5uy) ], actual) } :> Task

これは3つのスクロブルをデータストアに追加しますが、そのうち1つだけが検証される(true値で示される)ので、テストはこの1つだけが推薦されることを期待します。

曲番号2は「わずか」9,990回の再生しかありませんが、ユーザーanaはちょうど10,000回の再生数があり、ぎりぎり基準を満たしていることに注目してください。このような精密な例を5つ慎重に追加することで、「すべてのミュータントを殺す」ことができました。

結果として、8つのテストができました:3つのFsCheckプロパティと5つの通常のxUnit.net事実(fact)です。

すべてのテストは、直接および間接的な入力をテスト対象システム(SUT)に提供し、GetRecommendationsAsyncの戻り値を検証するのみで機能します。モックスタブがSUTと注入されたSongServiceのインタラクションに関して意見を持つことはありません。これにより、テストが信頼できる回帰テストスイートを構成し、SUTを完全に書き換えても実装の詳細から十分に分離されているという確信が得られます。

特異な動作

既存のコードベースにテストを追加すると、元のプログラマーが見落としていたエッジケースを発見することがあります。GetRecommendationsAsyncメソッドはあくまでコード例なので、Oleksii Holubがカジュアルにコーディングしたことを責めるつもりはありませんが、コードにはいくつかの特異な動作があることがわかりました。

例えば、重複排除が行われていないため、テストコードで謝罪を入れる必要がありました:

[<Fact>]
let ``Only top-rated songs`` () = task {
    let srvc = FakeSongService ()
    // 評価値を10以下にスケーリングする。
    [1..20] |> List.iter (fun i ->
        srvc.Scrobble ("hyle", Song (i, true, byte i / 2uy), 500))
    let sut = RecommendationsProvider srvc
 
    let! actual = sut.GetRecommendationsAsync "hyle"
 
    Assert.NotEmpty actual
    // ユーザーは1人だけだが、関連する曲が20曲あるため、実装上は同じ曲のリストに対して
    // 20回ループ処理を行うことになり、結果として(重複を含む)合計400曲となる。
    // これを評価で並べ替えると、上位200曲(つまり評価が5~10の曲)のみが残る。
    // なお、これは仕様化テストであり、実際の推薦システムのあるべき動作を
    // 必ずしも反映しているわけではない点に留意。
    Assert.All (actual, fun s -> Assert.True (5uy <= s.Rating)) } :> Task

このテストは1人のユーザーに対して20のスクロブルを作成します:評価0のもの1つ、評価1のもの2つ、評価2のもの2つというように、評価10の曲が1つまであります。

GetRecommendationsAsyncの実装では、これらの20曲を使って同じトップソングを持つ「他のユーザー」を探します。この場合、ユーザーは1人だけなので、20曲それぞれについて同じ20曲が返され、合計400になります。

これは私のFakeSongService実装が洗練されていないからだと反論されるかもしれません。当然、「元の」ユーザーの曲は返すべきではないと。しかし、GetTopListenersAsyncメソッドのシグネチャを見てください:

Task<IReadOnlyCollection<User>> GetTopListenersAsync(int songId);

このメソッドは入力としてsongIdのみを受け取り、サービスがステートレスだと仮定すると、「元の」ユーザーが誰かを知る術はありません。

この特異な動作を修正すべきでしょうか?実際のシステムでは適切かもしれませんが、この文脈ではそのまま残す方が良いと考えています。実際のシステムには、レガシーなビジネスルールなどの形で特異な動作が含まれることがよくあるため、システムが奇妙な振る舞いをすることも現実的です。この連載の目的はこの特定のシステムをリファクタリングすることではなく、リファクタリングが正当化されるほど複雑なシステムの代替設計を紹介することです。コードを単純化すると、その目的が損なわれるかもしれません。

示したように、この動作を維持するための自動テストがあります。これでコードに大きな変更を加える準備が整ったと考えています。

結論

マーティン・ファウラーが書いているように、リファクタリングの本質的な前提条件は信頼できるテストスイートです。日々、何百万もの開発者がテストされていない変更を本番環境にデプロイすることで、この原則を覆しています。変更を加える他の方法も確かにあります。手動テスト、A/Bテスト、本番環境でのテストなどです。これらの方法が特定の状況で機能することもあるでしょう。

そうした現実世界の考慮事項と対照的に、私には実ユーザーを持つ本番システムはありません。プロダクトオーナーや手動テスト部門もありません。私にできる最善のことは、実装ではなく振る舞いを、それを維持するのに十分な詳細さで記述したと確信できるほど、十分な仕様化テストを追加することです。マイケル・フェザーズレガシーコード改善ガイドで呼ぶところのソフトウェアバイス(万力)です。

「現実世界」のほとんどのシステムには自動テストが不足しています。レガシーコードベースにテストを追加することは難しい作業なので、この連載で約束した実際の設計変更に取り組む前に、この作業を文書化することには価値があると考えました。これで下準備が整ったので、次に進むことができます。

次の2つの記事では、F#とHaskellで同等のコードベースを確立するためのさらなる基礎作業を行います。C#の例だけに興味がある場合は、この連載の最初の記事に戻り、目次を使って次のC#の例に直接ジャンプすることができます。

次回: 曲レコメンデーションのF#への移植


インデックスへ戻る