金融取引プロトコル「FIX」のGoによるテスト戦略とその実装

ogp

はじめに

この記事はenechain Advent Calendar 2024の3日目の記事です。

本日はenechainのGXデスクとeClearデスクのバックエンドエンジニアの @eji が担当します。

最近FIXプロトコルを使ったサービスとテストを書く機会がありました。 今回はその経験をもとに、FIXプロトコルを使ったサービスのテストをどのように行ったかを紹介します。

FIXプロトコルについて

概要

FIXプロトコルの FIXFinancial Information eXchange の略で、投資銀行や証券取引所などで利用されている金融取引のためのプロトコルです。

金融取引業務や金融商品がプロトコルの中に組み込まれており、金融商品の参照や買い注文の入札、約定後の支払管理などがFIXプロトコルで実現されています。

多くの金融取引所や金融取引ツールがFIXプロトコルを前提としているため、 FIXプロトコルでAPIを公開したりツールを作ることで素早く金融取引所に参入できるメリットがあります。

FIXプロトコルの中身

FIXプロトコルはTCPの上で実現されており、次のようにタグ番号と値を = でペアにし、制御文字SOH(0x01)を末尾につけたフィールドを連結したメッセージでやり取りします。 制御文字SOHは表示できない文字のため、パイプ文字 | で表現されることが多いです。

{タグ番号0}={値0}|{タグ番号1}={値1}| ... {タグ番号N}={値N}|

次の図のように、FIXプロトコルはクライアントサーバーモデルで動作します。

FIXプロトコルのAPIを利用する(クライアント)側を Initiator 、APIを提供(サーバー)側を Acceptor と呼びます。

FIX client-server model

メッセージは双方向でやり取りされます。

また、リクエストメッセージとレスポンスメッセージは必ず1対1に対応しておらず、 1つのメッセージで複数のメッセージが返ってくる場合もあります。

そのため、FIXプロトコルを使ったサービスを作る場合は、リクエストのメッセージとそれに対応するメッセージの対応づけの管理が重要になります。

テスト戦略

今回私達はFIXプロトコルを扱うサービス(Initiator)を作る上で、次のような課題がありました。

  • 連携先のサービス(Acceptor) のサンドボックス環境に接続してテストできるが、稼働時間が決まっているため任意の時間にテストできない
  • サンドボックス環境が毎回同じ結果を返すわけではないため、テストの安定性が低い
  • サンドボックス環境に負荷をかけてしまうため、テスト自動化が難しい

この課題の解決に以下のいずれかの方法が考えられました。

Testing strategy

  • A: FIX連携するGoのモジュールをテストダブルに差し替える
  • B: 通信先のAcceptorをテストダブルに差し替える

自分達のチームにはFIXプロトコル経験者がおらず、Aがすぐに実現できる方法でした。

しかし、肝心のFIXプロトコルでやりとりする部分は、実際に連携先システムと繋がないと正常に動くか分からないという課題が残ります。

Bであれば、FIXプロトコルで接続して通信する部分のコードを変えずにテストできます。

また、将来的にFIXプロトコルのライブラリを差し替えも容易になるため、Bの方法でテストすることにしました。

既存のテストライブラリ

私達のシステムはGoで書かれているため、テストライブラリもGoで書かれている方がCIへの組み込みやテストのデバッグに有用です。 そこで、まずGoで利用できるFIXプロトコルのテストライブラリを探しました。

しかし、Goで利用できるFIXプロトコルのテストライブラリを見つけることができませんでした。

FIXプロトコルの知識はあまりない状態でしたが、自分でテストライブラリを作ることで知識が深まるだろうと考え、 テストライブラリを自作することにしました。

作成したテストライブラリ

Acceptorをどのように立ち上げるか

テスト対象のInitiatorのコードを全く変えずにテストできるように、AcceptorのテストダブルはTCPで通信できる必要があります。

Goの場合、goroutineと net パッケージを使うことで、Dockerなどを使わなくてもAcceptorのテストダブルを動かすことができます。

今回はFIXプロトコルのテストをどう書くかに焦点を当てているため、テストライブラリの中身は省略します。 (今後機会があれば、別の記事で詳しく説明したいと考えています)

func TestFIXSample(t *testing.T) {
  t.Run("FIX protocol sample test", func (t *testing.T) {
    // 空いているポートを取得し、内部でgoroutineを使ってFakeのAcceptorを起動
    fakeAcceptor := fake.NewAcceptor()
    port := fakeAcceptor.Start(ctx, t)
 
    // 起動したAcceptorのポートを指定してテスト対象のInitiatorを初期化
    initiator := sample.NewInitiator(ctx, port)
  })
}

これでFakeのAcceptorに接続してテストできるようになりました。

Acceptorの振る舞いをどのように定義するか

次に、Acceptorの振る舞いを定義する必要があります。

Acceptorの振る舞いを定義する方法には次のような方法が考えられます。

  • A: テスト前にAcceptorの振る舞いを定義する
  • B: テスト中にAcceptorの振る舞いを定義する

モックのように、テスト前にAcceptorの振る舞いを定義する方法(A)があります。

    // 例
    fakeAcceptor := fake.NewAcceptor()
    fakeAcceptor.InitMock("リクエスト1", "レスポンス1")
    fakeAcceptor.InitMock("リクエスト2", "レスポンス2")
    ...

しかし、FIXプロトコルはリクエストとレスポンスが1対1でないため、テスト前にAcceptorの振る舞いを定義することが難しいです。 また、メッセージに送信時刻を含める必要があったり、メッセージの送信タイミングを変えることによる異常系のテストが難しいです。

そのため、テスト中にAcceptorの振る舞いを定義する方法(B)を採用しました。

    // 例
    // Initiatorからのリクエストメッセージを受信
    msg := fakeAcceptor.GetRecvMsg(ctx, t)
    // Acceptorが受信したメッセージを検証
    assert.Equal(t, "A", msg.GetFieldValue(35))
    ...

    // レスポンスメッセージを送信
    ackLogonMsg := fixmsg.NewLogonMsg()
    fakeAcceptor.SendMsg(ackLogonMsg, t)
    ...
 
    // 直後に想定外のメッセージを送信
    unexpectedMsg := fixmsg.NewNewOrderSingleMsg()
    fakeAcceptor.SendMsg(unexpectedMsg, t)
    ...
 
    // Initiatorから異常検知のメッセージを受信
    msg = fakeAcceptor.GetRecvMsg(ctx, t)
    // メッセージがRejectされたことを検証
    assert.Equal(t, "3", msg.GetFieldValue(35))

この方法は次のようなメリットがありました。

  • Acceptorの振る舞いを上から順に定義できるため、振る舞いがわかりやすい
  • 異常系のテストが書きやすい
    • Initiatorからメッセージを受信した後にAcceptorが異常終了する
    • 不正なメッセージを送信する
    • ソケットが切断される(適切にリトライされるかをテストできる)

テストコードの例

作成したテストライブラリは、最終的に次のような形でテストを書くことができるようになりました。

以下はLogonとLogoutのテストコードの例です(説明のためエラーチェックなどは省いています)。

func TestLogonLogout(t *testing.T) {
  t.Run("logon, logout test", func(t *testing.T) {
    ctx := context.Background()   
    // FakeのAcceptorを起動
    fakeAcceptor := fake.NewAcceptor()
    port := fakeAcceptor.Start(ctx, t)

    // テスト対象のInitiatorを起動
    stopFunc, err := sample.RunInitiator(ctx, port)
    require.NoError(t, err)

    // InitiatorからLogonメッセージを受信
    msg := fakeAcceptor.GetRecvMsg(ctx, t)
    assert.Equal(t, "A", msg.GetFieldValue(35))

    // InitiatorにLogonメッセージの返答を送信
    ackLogonMsg := fixmsg.NewLogonMsg()
    fakeAcceptor.SendMsg(ackLogonMsg, t)

    // Initiatorからそのほかのメッセージは受信しないことを検証
    fakeAcceptor.GetRecvMsg(ctx, t)

    // Initiatorを停止
    stopFunc()

    // InitiatorからLogoutメッセージが送信されることを検証
    msg = fakeAcceptor.GetRecvMsg(ctx, t)
    assert.Equal(t, "5", msg.GetFieldValue(35))
  })
}

テストライブラリの導入結果

テストライブラリを自作して導入した結果、次のような効果が得られました。

  • FIXプロトコルの理解促進
  • 変更容易性の向上

FIXプロトコルの理解促進

FIXプロトコルのテストライブラリを作ったことで、FIXプロトコルの理解が深まりました。

ただし、この方法は作成にコストがかかり、安定した品質になるまで時間がかかるため、オススメできません。 もし、FIXプロトコルのテストライブラリがあれば、それを利用することをオススメします。

変更容易性の向上

通信先のAcceptorをテストダブルに差し替えてテストしたことで、FIXプロトコルのやり取りの振る舞いを担保できるようになりました。 これにより、Initiatorのコードを安心して変更できるようになりました。

また、FIXプロトコルのライブラリのバージョンアップによる振る舞いの変更が検知できるようになり、他のライブラリへの差し替えも容易になりました。

今後の課題

自作したFIXのテストライブラリは次のような課題があります。

  • 暗号化が未対応
  • 未検証のユースケースが多い

今後は、これらの課題を解決するために、テストライブラリの改善を進めていきたいと考えています。

まとめ

今回はFIXプロトコルを使ったサービスのテストについて、実例を交えて説明しました。

金融取引は不具合が許されないため、テストの重要性がとても高いです。 今後も安定したサービスを提供するために、テストの自動化やテストライブラリの改善を進めていきたいと考えています。

enechainでは、事業拡大のために随時仲間を募集しています。興味がある方はぜひお声がけください!

herp.careers

herp.careers