ククログ

株式会社クリアコード > ククログ > 自動テストがなかったGo製Native Messaging Hostの自動テストの作り方

自動テストがなかったGo製Native Messaging Hostの自動テストの作り方

FirefoxやThunderbird、Chromium系ブラウザーなどの拡張機能では、Native Messagingという仕組みを使って、通常のAPIの範囲では行えないローカルファイルへの直接のアクセスなどを行えます。これは、ブラウザーが拡張機能からの求めに応じて「Native Messaging Host(以下NMH)」と呼ばれる特殊なネイティブアプリケーションを呼び出すことによって実現されます。

NMHはどのような言語で開発しても問題ありません1が、拡張機能がマルチプラットフォームで利用され得ることと、NMHは標準入出力を使う非GUIアプリケーションとして実装すればよいことから、当社では、単一ソースからマルチプラットフォームな実行ファイルを容易にクロスビルドできるGo言語(golang)を用いる場合が多いです。本記事では、このような前提で開発されたもののそれまで自動テストがなかったgolang製NMHに、後付けで自動テストを作る際の注意点を紹介します。

golangでのNative Messaging Hostの基本

何はともあれ実例を示します。以下は、「指定されたパスにあるファイルの内容を読み取って返す」だけのNMHの、golangでの最も単純な実装例です。

// main.go
package main

import (
  "encoding/json"
  "github.com/lhside/chrome-go"
  "io/ioutil"
  "log"
  "os"
)

// NMHが受け取るJSON形式のメッセージに対応する構造体の定義。
type Request struct {
  Path string `json:"path"`
}

// NMHが返すJSON形式のメッセージに対応する構造体の定義。
type Response struct {
  Contents string `json:"contents"`
  Error    string `json:"error"`
}

func main() {
  // ログ類を標準エラー出力に出力するよう設定する。
  log.SetOutput(os.Stderr)

  // 拡張機能からのメッセージを標準入力で受け取る。
  rawRequest, err := chrome.Receive(os.Stdin)
  if err != nil {
    log.Fatal(err)
  }

  // 入力を構造体の定義を元にJSONとしてパースする。
  request := &Request{
    Path: "",
  }
  if err := json.Unmarshal(rawRequest, request); err != nil {
    log.Fatal(err)
  }

  // パース結果を使って処理を行う。
  contents, errorMessage := FetchFile(request.Path)

  // 処理結果を元にJSON文字列を作る。
  response := &Response{
    Contents: contents,
    Error:    errorMessage,
  }
  body, err := json.Marshal(response)
  if err != nil {
    log.Fatal(err)
  }

  // JSON文字列を標準出力で拡張機能に返す。
  err = chrome.Post(body, os.Stdout)
  if err != nil {
    log.Fatal(err)
  }
}

func FetchFile(path string) (contents string, errorMessage string) {
  buffer, err := ioutil.ReadFile(path)
  if err != nil {
    return "", "Failed to fetch the file "+path+": "+err.Error()
  }
  return string(buffer), ""
}

当社でNMHを開発する際は、基本的にはこのように、NMH側の実装は最小限にとどめるそうなるように仕様を定める)場合が多いです。この例のような規模の実装の場合、「知らず知らずのうちに後退バグが発生していた」といった事態はまず起こらないので、自動テストが無くても特に問題になることはないでしょう。

ただ、後々要件が膨らんで、どうしてもNMH側の実装を厚くしなくてはならなくなることもあります。そのような経緯で後から自動テストを追加したくなった場合に、どうすればよいのか。ここからがこの記事の本題となります。

golangでのテストの基本

golangでの自動テストの作り方は公式のチュートリアル中で詳しく書かれていて、大まかには以下の要領です。

  1. テスト対象の実装のファイルと同じディレクトリーに、実装のファイルのbasename末尾に_testを加えた名前で、テスト記述用のファイルを置く2
  2. テストをテスト対象の実装と同じパッケージにし、testingモジュールをインポートする。
  3. Test何々のような名前で、t *testing.Tを引数に取るテスト関数を定義する。実装に含まれる各関数について、関数を実行し、結果を期待値と比較して、期待値が異なる場合はt.Fatalf()でテスト失敗を通知するように、テスト関数を記述する。
  4. go testでテストを実行する。

公式のチュートリアルでは期待値と実測値の比較は自分で書くようになっていますが、testifyというモジュールを使えば豊富なアサーションも利用できます。また、TestMainという機能を使うと、そのテストファイルの実行前後に初期化処理と終了処理も定義できます3

ただ、この仕組みに素直に則って素直にテストを書くだけだと、NMHのテストとしては不充分です。NMHはWebExtensionsアドオンから利用される外部モジュールにあたるため、WebExtensionsアドオンから受け取るメッセージの内容と、アドオンに返却されるメッセージの内容の両方の保証、つまり、main関数の入出力のテストが不可欠です。しかしながら、この仕組みではNHM内の通常の関数単体テストは容易に書けるものの、main関数の単体テストは書けないのです。

「main関数の動作を包括的に検証する」に近いことをする

このような場合、NMHの実装を以下のように変更することで、通常のテストの仕組みに則って「main関数の単体テスト」に相当することが可能になります。

  1. main関数の主要な処理を別関数に分離する。
  2. その関数では標準入出力を直接触らず、引数で与えられたio.Readerio.Writerを入出力として使うようにする。main関数は、標準入出力をその関数に引き渡して実行するだけの物とする。
  3. その関数内で発生したエラーはlog.Fatal()などで直接ハンドリングせず、すべて呼び出し元にreturnで返すようにする。

この方針に基づいて先の実装例を書き換えると、以下のようになります。

// main.go
package main

import (
  "encoding/json"
  "github.com/lhside/chrome-go"
  "io" // 追加。
  "io/ioutil"
  "log"
  "os"
)

type Request struct {
  Path string `json:"path"`
}

type Response struct {
  Contents string `json:"contents"`
  Error    string `json:"error"`
}

// 追加:入出力をまとめた、情報受け渡し用の構造体の定義。
type Context struct {
  Input    io.Reader
  Output   io.Writer
  ErrorOut io.Writer
}

func main() {
  // 通常の実行時は、構造体を標準入出力で初期化する。
  context := &Context{
    Input:    os.Stdin,
    Output:   os.Stdout,
    ErrorOut: os.Stderr,
  }

  // 入出力を添えて、main関数の主要な処理を分離した関数を実行する。
  if err := ProcessRequest(context); err != nil {
    log.Fatal(err)
  }
}

// 追加:main関数の主要な処理を分離した関数。
func ProcessRequest(context *Context) error {
  // ログ類の出力先を設定する。
  log.SetOutput(context.ErrorOut) // 引数で受け取ったio.Writerを使う。

  // 拡張機能からのメッセージを入力用のio.Reader経由で受け取る。
  rawRequest, err := chrome.Receive(context.Input) // 引数で受け取ったio.Readerを使う。
  if err != nil {
    return err // エラーは自分でlog.Fatalに渡さずに返す。
  }

  // 入力を構造体の定義を元にJSONとしてパースする。
  request := &Request{
    Path: "",
  }
  if err := json.Unmarshal(rawRequest, request); err != nil {
    return err
  }

  // パース結果を使って処理を行う。
  contents, errorMessage := FetchFile(request.Path)

  // 処理結果を元にJSON文字列を作る。
  response := &Response{
    Contents: contents,
    Error:    errorMessage,
  }
  body, err := json.Marshal(response)
  if err != nil {
    return err
  }

  // JSON文字列を出力用のio.Writer経由で拡張機能に返す。
  err = chrome.Post(body, context.Output) // 引数で受け取ったio.Writerを使う。
  if err != nil {
    return err
  }

  return nil
}

3つめの点(main関数内にあった処理中で発生したエラーはすべて呼び出し元にreturnで返すようにする)は地味に重要です。検証対象の関数内にlog.Fatal()を呼んでいる箇所があると、そこに処理が達した時点でテストの実行そのものが中断されてしまい、「どこでテストが失敗したのか」が分からなくなるため、原因究明はより一層難航することになります。ですので、単体テストの形で動作を検証する関数は、内部ではlog.Fatal()を呼ばず常に呼び出し元にエラー情報をreturnだけするようにして、返されたエラーはmain関数側でハンドリングするようにしましょう。

そのように編集した後であれば、NMHとしての入出力のテストを以下のように書くことができます。

// main_test.go
package main

import (
  "bytes"
  "encoding/binary"
  "github.com/stretchr/testify/assert"
  "io"
  "io/ioutil"
  "os"
  "path/filepath"
  "strings"
  "testing"
)

// 与えられた文字列から標準入力代わりのio.Readerを用意するユーティリティ。
func CreateInput(input string) io.Reader {
  // NMHが受け取るメッセージとして妥当な形式になるよう、
  // 先頭にメッセージの長さの情報を付与したバイナリーに変換する。
  buffer := new(bytes.Buffer)
  err := binary.Write(buffer, binary.LittleEndian, []byte(input))
  if err != nil {
    return nil
  }
  header := make([]byte, 4)
  binary.LittleEndian.PutUint32(header, uint32(buffer.Len()))
  message := append(header, buffer.Bytes()...)
  reader := bytes.NewReader(message)
  return reader
}

// io.Writerに出力された内容を読み取って文字列として返すユーティリティ。
func ReadOutput(output io.Reader) string {
  // NMHが出力したメッセージには先頭にメッセージの長さの情報が付与されているため、
  // それを取り除いた残りだけを文字列として取り出す。
  var length uint32
  if err := binary.Read(output, binary.LittleEndian, &length); err != nil {
    return ""
  }
  if length == 0 {
    return ""
  }
  message := make([]byte, length)
  if n, err := output.Read(message); err != nil || n != len(message) {
    return ""
  }
  return string(message)
}

// テスト関数。
func TestNativeMessagingCall_Success(t *testing.T) {
  // リポジトリー内の適当なファイルとして、親ディレクトリーにあるファイルを参照してみる。
  wd, _ := os.Getwd()
  path := filepath.Join(wd, "..", "manifest.json")
  inputMessage := `{`+
    `  "Path":"`+path+`"` +
    `}`
  // NMHに渡されるメッセージを伴った標準入出力をモックする。
  input := CreateInput(inputMessage)
  var output bytes.Buffer
  var errorOut bytes.Buffer
  context := &Context{
    Input:    input,
    Output:   &output,
    ErrorOut: &errorOut,
  }

  // main関数に代わって主要な処理を実行する。
  err := ProcessRequest(context)

  // 内部でエラーが発生しなかったことを確認する。
  assert.NoError(t, err)
  assert.Equal(t, "", errorOut.String())

  // 標準出力のモックに出力された内容が期待通りかを、参照したファイルの実際の内容と比較して確認する。
  fileContents, err := ioutil.ReadFile(path)
  assert.NoError(t, err)
  escapedContents := strings.ReplaceAll(
    strings.ReplaceAll(string(fileContents), `"`, `\"`),
    "\n", `\n`)
  assert.Equal(t, `{"contents":"`+escapedContents+`","error":""}`, ReadOutput(&output))
}

go testでテストを実行すると、期待した通りに検証されることを確認できます。

$ go test -v
=== RUN   TestNativeMessagingCall_Success
--- PASS: TestNativeMessagingCall_Success (0.01s)
PASS
ok      main    0.01s

マルチプラットフォームに対応したNative Messaging Hostでの注意点

冒頭で述べたとおり、拡張機能がマルチプラットフォームで利用され得ることから、NMHもマルチプラットフォームに対応しておきたい所です。golangでは容易にクロスビルド4を行えるため、NMHを開発するにはもってこいです。

ごく一般的な処理であれば、単一の実装でマルチプラットフォームに対応できますが、例えばWindowsのレジストリーを読み書きする処理など、場合によっては特定のプラットフォーム向けに固有の実装が必要になる場合があります。 このような場合、golangではファイルの冒頭に//go:build windowsのようなタグを記載して、main.goではReadEnterprisePolicyConfigs()という関数を呼び出す処理だけ書いておき、main_windows.goReadEnterprisePolicyConfigs()を実装する、といった要領で、特定プラットフォーム向けの実装を本体から分離しておけます。以下に例を示します。

// main_windows.go:Windows専用の実装

//go:build windows

package main

import (
  "golang.org/x/sys/windows/registry"
  "io"
  ...
)

func ReadIntegerRegValue(key registry.Key, valueName string) (data uint64, errorMessage string) {
  ...
}

func ReadStringsRegValue(key registry.Key, valueName string) (data []string, errorMessage string) {
  ...
}

func FetchEnterpriseConfigsAndResponseFromGPO(base registry.Key, keyPath string, configs *TbStyleConfigs) error {
  ...
}

// 実際にmain.goから呼ばれる関数はこれのみ。
func FetchEnterpriseConfigsAndResponse(output io.Writer) error {
  ...
}

単に実行用のバイナリーを作成するだけであれば、実装を用意してあるプラットフォーム向けにのみビルドすることになるので、これで問題ありません。

ただ、この状態で実装がまだ無いプラットフォームでテストを実行しようとすると、未定義の関数があるというエラーになりテストを実行できません。筆者の場合、Windows用のNMHをWSLのUbuntu上でビルドしている場面で、Windowsのレジストリーを読み書きする処理の実装が他のプラットフォーム向けには存在しないため、WSL上ではテストを実行できない状況が発生しました。

このような場合には、実装がまだない環境向けのスタブ実装を用意しておくのが手っ取り早くてよいです。例えば以下の要領です。

// main_other.go:未対応のプラットフォーム向けのスタブ実装

//go:build !windows
// ↑これで「Windowsでない環境向けの実装」であることを示す。
//   macOS用の実装も存在している場合は、「!windows && !darwin」として
//   「WindowsでもmacOSでもない環境向けの実装」であることを示す。

package main

import (
  "io"
)

// 実際にmain.goから呼ばれる関数。
func FetchEnterpriseConfigsAndResponse(output io.Writer) error {
  return nil
}

なお、この要領で特定プラットフォーム向けの実装を分離した場合、その分離された実装のテストは当該プラットフォームで実行するか、GOOS=windows go testのようにプラットフォームを明示して実行することになります。プラットフォーム依存のテストが多いと実行が面倒になってしまうので、一般化できる処理は可能な限りプラットフォーム非依存の実装の方に寄せておくとよいでしょう。

まとめ

以上、WebExtensions形式のアドオン用のNative Messaging Hostをgolangで開発する場合に、元々自動テストがなかった所に拡張機能と接する部分の自動テストを作成する際の知見をご紹介しました。

株式会社クリアコードは、お客さまからのお問い合わせやご依頼に基づいてFirefoxやThunderbird、Chrome、Edgeなどの拡張機能の開発も承る法人向けサポートを、有償にてご提供しています。 これらのアプリケーションの運用でお困りのことがあり、拡張機能での解決を検討されている企業のシステム管理・運用ご担当者さまは、是非ともお問い合わせフォームよりご連絡下さい。

  1. 極端な例では、Bash用のシェルスクリプトもNMHとして使用できます。

  2. 例えばFlexConfirmMailの場合、NMHの実装はwebextensions/native-messaging-host/host.goにあり、対応するテストはwebextensions/native-messaging-host/host_test.goに置くことになります。

  3. 参照先のドキュメントの例でいうと、m.Run() より前に初期化処理を、後に終了処理を記述します。

  4. 「Linux上で、Linux用だけでなくWindows用とmacOS用のバイナリーも生成する」といった具合に、実行環境と異なる環境のバイナリーを生成すること。