(macOS) pip を使えるようにする

pip は Python のパッケージ管理システム。 日常的に Python で本格的に開発する人でなくても、各種ツールを使用するとき pip が必要になる場面は少なくないと思う。 (ちなみに私も日常的には使わない人)

で、最近マシンを新しく買い換えて、環境構築がてら pip を利用しようとしたら軽くハマったのでメモ。 一昔前はそんなに困らなかったと思うのだが...😢

環境

  • OS: macOS Sonoma 14.4

課題

pip コマンドを利用したい。

homebrew で brew install pip でインストールすればよいと思いきや、pip 自体は存在せず、代わりに python をインストールするようにと指示される。

$ pip
bash: pip: command not found

$ brew install pip
Warning: No available formula with the name "pip". Did you mean pipx, pig, pit, pop, pup, php, pcp, zip, sip or vip?
pip is part of the python formula:
  brew install python

(python はもうインストールされているのだけれど...と思いつつ) brew install python を実行すると、すでに python はインストールされていると表示される。

$ brew install python
Warning: python@3.12 3.12.3 is already installed and up-to-date.
To reinstall 3.12.3, run:
  brew reinstall python@3.12

エラー表示はその通りなのだが、では pip を利用するにはどうしたらよいのか。

解決方法

いくつか方法はあると思うが、 pyenv を使うのが手軽。

brew install pyenv
pyenv install -l     # 利用可能なバージョンの一覧を得る
pyenv install 3.12.3    # バージョンは適宜変更
pyenv global 3.12.3     # 特定バージョンを使用するよう指定

これで pip コマンドが利用できるようになる。

$ pip -V
pip 24.0 from /Users/egawata/.pyenv/versions/3.12.2/lib/python3.12/site-packages/pip (python 3.12)

testify の require を使う

golang.org/stretchr/testifyrequireパッケージを使用する。 assert と同様のテストを行いつつ、テストに失敗した場合は後続のテストの実行を止めることができる。

Package require implements the same assertions as the assert package but stops test execution when a test fails. (require パッケージは、assert パッケージと同一のアサーションを実装し、かつテストに失敗したときはテストの実行を停止します)

なお testify/assert については過去に以下で記事を書いているのでよろしければ。

例えば割り算を行う関数を持つプログラムを書いたとする。

package main

import (
    "errors"
    "fmt"
    "log"
)

func main() {
    a := 8
    b := 2
    r, err := div(a, b)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%d / %d = %d\n", r.dividend, r.divisor, r.quotient)
}

type DivResult struct {
    dividend int
    divisor  int
    quotient int
}

func div(a, b int) (*DivResult, error) {
    if b == 0 {
        return nil, errors.New("division by zero")
    }
    return &DivResult{a, b, a / b}, nil
}

ここで関数 div は、割り算の結果を DivResult という構造体に格納して返すようになっている。 また除数が 0 の場合は結果自体は nil とし、error を別途返すようにしている。

さて、この関数 div のテストを書いてみる。 testify/assert だけを使うと以下のようになるかもしれない。

package main

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestDiv(t *testing.T) {
    got, err := div(8, 4)
    assert.NoError(t, err)
    assert.Equal(t, 2, got.quotient)
}

1番目のテストは、diverror を返さないことをチェックしている。 そして2番めのテストは、結果の DivResult に含まれる quotient が正しいことをチェックしている。 このテストは現状では PASS するし、何の問題もなさそうだ。

しかし、もしうっかり div 関数の実装を以下のようにしてしまったらどうだろうか?

func div(a, b int) (*DivResult, error) {
    if b != 0 {    // <<--- うっかり条件を逆にしてしまった
        return nil, errors.New("division by zero")
    }
    return &DivResult{a, b, a / b}, nil
}

もちろんテストは通らなくなる。それどころか panic まで発生してしまう。

=== RUN   TestDiv
    main_test.go:11: 
                Error Trace:    /tmp/testify_require/main_test.go:11
                Error:          Received unexpected error:
                                division by zero
                Test:           TestDiv
--- FAIL: TestDiv (0.00s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
        panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x4b0267c]

goroutine 19 [running]:
testing.tRunner.func1.2({0x4b7d420, 0x4cd5050})
        /Users/egawata/ghq/github.com/golang/go/src/testing/testing.go:1545 +0x24a
testing.tRunner.func1()
        /Users/egawata/ghq/github.com/golang/go/src/testing/testing.go:1548 +0x397
panic({0x4b7d420?, 0x4cd5050?})
        /Users/egawata/ghq/github.com/golang/go/src/runtime/panic.go:612 +0x132
testify_assert.TestDiv(0x0?)
        /tmp/testify_require/main_test.go:12 +0x5c
testing.tRunner(0xc0000831e0, 0x4badc80)
        /Users/egawata/ghq/github.com/golang/go/src/testing/testing.go:1595 +0xff
created by testing.(*T).Run in goroutine 1
        /Users/egawata/ghq/github.com/golang/go/src/testing/testing.go:1648 +0x3ad
FAIL    testify_assert  0.276s
FAIL

関数の実装が間違っているのだからテストが FAIL するのは構わない。 しかし以下の2点で問題がある。

  • そもそもテスト実行中に panic を起こしたくない
  • 1番目のテストが error を返した時点で2番目のテストを行う意味がなくなり、無駄なテストを実行している
    • div 関数は1番目の戻り値で計算結果を返すが、エラー発生時には値に意味がないという意図の nil になるから

解決方法としてまず考えられるのは、1番目のテストが通ったことを確認してから後続のテストを行うというもの。 assert のテスト関数は、テストが成功/失敗すると、それぞれ true / false を返すので、その結果を利用できる。

func TestDiv(t *testing.T) {
    got, err := div(8, 4)
    if !assert.NoError(t, err) {  // <<--- assert.NoError の結果をチェック
        t.Fatal(err)
    }
    assert.Equal(t, 2, got.quotient)
}

これで1番目のテストに失敗したら2番目に進まないようにできる。 しかし冗長さがあるのは否めない。そもそも testify/assert を使いたい理由って

if (条件) {
   t.Fatal(err)
}

みたいな冗長な記述を簡略化したかったということなのではないか。これでは元の木阿彌だ。

そこで testify に同梱されている require パッケージを使う。 使い方は testify/assert と全く同じで、assertrequire に変えるだけでよい。

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"    // <-- 追加
)

func TestDiv(t *testing.T) {
    got, err := div(8, 4)
    require.NoError(t, err)     // <-- require に変更
    assert.Equal(t, 2, got.quotient)
}
=== RUN   TestDiv
    main_test.go:12: 
                Error Trace:    /tmp/testify_require/main_test.go:12
                Error:          Received unexpected error:
                                division by zero
                Test:           TestDiv
--- FAIL: TestDiv (0.00s)
FAIL
FAIL    testify_assert  0.249s
FAIL

require.NoError のテストが失敗した時点で後続のテストがキャンセルされる。

停止されるテストの範囲

ここで FAIL 時に停止されるのは、同一テスト関数内の後続テスト。他のテスト関数は影響を受けない。

  • TestXXX のような関数内で require パッケージによりアサーション関数が実行された場合は、TestXXX 内の後続のテストは行われない。
    • 他の TestYYY などのテストは引き続き実行される。
  • t.Run() などでサブテストが作成され、その中でテスト関数が実行されている場合は、そのサブテスト内のテストのみが停止となる。
func TestDiv(t *testing.T) {
    t.Run("test1", func(t *testing.T) {
        got, err := div(8, 4)
        require.NoError(t, err)          // <-- これが fail するとする
        assert.Equal(t, 2, got.quotient) // <-- 実行されない
    })

    t.Run("test2", func(t *testing.T) {
        assert.Equal(t, 3, 1+2) // <-- 実行される
    })
}

func TestYYY(t *testing.T) {
    assert.Equal(t, 8, 2*4) // <-- 実行される
}
=== RUN   TestDiv
=== RUN   TestDiv/test1
    main_test.go:13: 
                Error Trace:    /tmp/testify_require/main_test.go:13
                Error:          Received unexpected error:
                                division by zero
                Test:           TestDiv/test1
=== RUN   TestDiv/test2
--- FAIL: TestDiv (0.00s)
    --- FAIL: TestDiv/test1 (0.00s)
    --- PASS: TestDiv/test2 (0.00s)
=== RUN   TestYYY
--- PASS: TestYYY (0.00s)
FAIL
FAIL    testify_assert  0.317s
FAIL

test2testYYY は実行されている。

補足

ちなみにどうしてこのような挙動になるかというと、require 内のそれぞれの関数内で assert によるアサーションを実行し、失敗したら (*testing.T).FailNow() を呼び出しテストを停止させているから。(つまりこの記事の1番目の解決方法と同じようなことを中でやっている)

github.com

エラーのラッピング

注:本記事は元々 Qiita で公開していたものの再掲です。

Go 1.13 で追加された、エラーラッピングについて。

概要

  • エラーを他のエラーでラップすることが可能になる
    • 元のエラーの型やフィールド値を保持しつつ新しいエラーを生成できる
    • ラップされたエラーは、あとから取り出すことが可能
  • 呼び出し先の深い場所で起きたエラーの種類を判別しやすくなる

使い方

エラーをラップする

innerErr := fmt.Errorf("Inner Error")
newErr := fmt.Errorf("Wrapped: %w", innerErr)

従来使用してきた fmt.Errorf() でできる。 通常は fmt.Errorf()errors.errorString 側のエラーを返すが、以下の条件を満たす場合、fmt.wrapError 型のエラーを返す。

  • フォーマット指定子に %w を使用する。文字列内での位置は問わない。
  • その %w を置き換えるのが error 型である(上のinnerErr)

fmt.wrapError とは

ラップ機能が追加された error 型。

通常 errors.New() が返す errorString 型は、エラー内容を単純な文字列として保持している。 この方法でエラーをネストさせる場合、エラー文字列を連結していくという方法をとらざるを得ない。結果的に以下のようになると思う。

err := fmt.Errorf("Inner Error")
newErr := fmt.Errorf("Wrapped: %s", err)
fmt.Println(newErr)   // 'Wrapped: Inner Error'

これだと、エラーの種類の判別がやや面倒になってしまう。(上記で言うと、Inner Error の発生がエラーの根本原因になっているかどうかを判定する場面など。文字列のパースが必要になる。)

しかし fmt.wrapError は以下の仕組みを持っているため、元のエラーを単独で管理することが可能となる。

  • fmt.wrapError 構造体には、元のエラーを保持するフィールドがある(err)
type wrapError struct {
    msg string
    err error       // ここに元のエラーが入る
}
  • Unwrap() を実装している。これはラップされた元のエラーを返す。
func (e *wrapError) Unwrap() error {
    return e.err
}

このため、アンラップ時に元のエラーを取り出すことが簡単になっている

エラーをアンラップする

あるエラーからラップされた中身のエラーを取り出すには、errors.Unwrap() を使う。

errors.Unwrap(newErr error) error

iErr := fmt.Errorf("Inner Error")
newErr := fmt.Errorf("Wrapped: %w", iErr)   // "Wrapped: Inner Error"
innerErr := errors.Unwrap(newErr)   // "Inner Error"
  • Unwrap() の引数で渡されたエラーが Unwrap() を実装していれば、その戻り値を返す
    • newErrfmt.wrapError 型。これは Unwrap() を実装していて、newErr.erriErr を返す。
  • Unwrap() が未実装なら nil を返す

エラーを探索する

ラップされた一連のエラーの中から、目的のエラーを探し出すことができる。以下の2つのメソッドが提供されている。

  • errors.Is(err, target error)
  • errors.As(err error, target interface{}

errors.Is : 特定のエラーを探す

  • エラーチェーンの中に、特定のエラーが存在するかどうかを調べる。存在すると判定される条件は以下のいずれか。
    • err == target が成立する err がエラーチェーンに存在する
    • errIs() が実装されていて、 err.Is(target)true を返す err がエラーチェーンに存在する
  • 例えば A -> B -> C の順にラップしたとして、A をラップしているかどうかを判定できる。

err == target が成立する err がエラーチェーンに存在する例

https://play.golang.org/p/GoL2silcyCF

func main() {
    errA := fmt.Errorf("First Error")   // これをラップしているかどうかを判定したい

    errB := fmt.Errorf("Wrapped: %w", errA)   // B <- A
    fmt.Printf("errB wraps errA = %v\n", errors.Is(errB, errA))

    errC := fmt.Errorf("Wrapped: %w", errB)   // C <- B <- A
    fmt.Printf("errC wraps errA = %v\n", errors.Is(errC, errA))

    errX := fmt.Errorf("Another error")       // A をラップしてない
    fmt.Printf("errX wraps errA = %v\n", errors.Is(errX, errA))
}

実行結果

errB wraps errA = true
errC wraps errA = true
errX wraps errA = false

errA == errB が成立するためには、同一インスタンスである必要がある。以下の例で、エラー文字列が同一で型も同一のインスタンスを2つ作成しているが、別インスタンスの場合は同一と判定されない。文字列が同じだから同一、ということではないので注意。

https://play.golang.org/p/X71IvCmYi6s

   errA := fmt.Errorf("Error Y")
    errB := fmt.Errorf("Error Y")    // errA と全く同じ文字列
 
    errX := fmt.Errorf("Wrap: %w", errA)
 
    fmt.Printf("errX wraps errA : %v\n", errors.Is(errX, errA))
    fmt.Printf("errX wraps errB : %v\n", errors.Is(errX, errB))

実行結果

errX wraps errA : true
errX wraps errB : false

なので通常は、グローバル空間でエラーオブジェクトを生成しておき、それを使い回すという方法で比較することになると思う。

err.Is() が実装されていて、true を返す例

2つの err が通常なら等価と判断されない場合であっても、任意の条件で同一とみなしたい場合は、Is() を実装すると良い。 以下の例では、errAerrB が同一インスタンスどころか同一の型ですらないが、結果的に同一のエラーと判定させている。

https://play.golang.org/p/sZtPmqjg7Ms

type myError struct {
    error
}

// 文字列が一致していれば同一と判断
func (e myError) Is(target error) bool {
    return e.Error() == target.Error()
}

func main() {
    errA := &myError{fmt.Errorf("Error E")}
    errB := fmt.Errorf("Error E")

    errX := fmt.Errorf("Wrapped %w", errA)
    fmt.Printf("errX wraps errB : %v\n", errors.Is(errX, errB))
}

実行結果

errX wraps errB : true

errXerrA をラップしている。errAerrB は型が異なるが、Is() で等価と判断されている。 結果 errXerrB に相当するものをラップしている、とみなされる。

errors.Is() は、特定のエラーがエラーチェーン内にあるかどうかを判定するだけ。その特定のエラーを取得し、さらになんらかの形で利用したい場合は、この次の errors.As を使用すると良い。

errors.As : 特定の型を持つエラーが含まれるか判定し、それを取得する

errors.As(err error, target interface{}) を使う。 エラーチェーンを辿っていき、target に代入可能なエラーが見つかったらそれを target で受け取る。 戻り値は bool で、目的のエラーが見つかった場合に true、見つからなければ false が返る。 エラーに時刻などの付加情報を追加し、それを利用したい場合はこちらが便利。

(代入可能の詳細は こちら )

target は以下のいずれかを指定可能。

  • Interface 型へのポインタ
  • error 型を実装した型へのポインタ

error 型を実装した独自のエラー型を使用した例。

https://play.golang.org/p/fr_jLSk6gd_z

type myError struct {
    t time.Time
}

func (e myError) Error() string {
    return fmt.Sprintf("[%s] myError found!", e.t.Format("2006/1/2 15:04:05"))
}

func main() {
    var innerErr myError

    err := wrap1()

    if errors.As(err, &innerErr) {
        fmt.Printf("%s\n", innerErr)
    } else {
        fmt.Printf("Unknown error : %s\n", err)
    }
}

func wrap1() error {
    err := wrap2()
    return fmt.Errorf("wrap1: %w", err)
}

func wrap2() error {
    return myError{t: time.Now()}
}

余談

fmt.Errorf%w を2つ以上指定するとどうなるか

(追記: 2022/12/07)

go1.20 から、2つ以上のエラーのラッピングが可能となるようです。

https://github.com/golang/go/issues/53435 https://twitter.com/inancgumus/status/1597571791941414912

以下の記述は、それ以前のエラーラッピングに関するものです。


2つ以上のエラーを同時にラップしようとするとどうなるのか。結論から言うとラップできず通常の errors.errorString が返ってくる。当然 Unwrap() でラップしたエラーを取り出すこともできなくなる。

https://play.golang.org/p/NqfHjIzApCQ

   errA := fmt.Errorf("Error A")
    errB := fmt.Errorf("Error B")
    errC := fmt.Errorf("Error C")

    errX := fmt.Errorf("1: %w, 2: %w, 3: %w", errA, errB, errC)
    fmt.Printf("%#v\n", errX)

    errU := errors.Unwrap(errX)
    fmt.Println(errU)

実行結果

&errors.errorString{s:"1: Error A, 2: %!w(*errors.errorString=&{Error B}), 3: %!w(*errors.errorString=&{Error C})"}
<nil>

ラップしたエラーの型がもはや fmt.wrapError ではなく errors.errorString になっている。

そもそも複数のエラーを指定することは意図的に考慮の対象外となっている模様。うっかりエラーを見落とすなど想定外の挙動に繋がりかねないので注意する必要がある。

https://github.com/golang/go/blob/688aa748579f07552a50d2534eccb16afda4174b/src/fmt/print.go#L579-L587

// It is invalid to use %w other than with Errorf, more than once, or with a non-error arg.

testify の assert を使ってテストを書く

注: 本記事は元々 Qiita にて公開していたものの再掲です。

概要

  • testify
  • golang でテストをより記述しやすくするためのパッケージ
  • その中で assert パッケージは、結果値の妥当性をチェックする関数を提供する

本記事で使用したバージョン

  • go: 1.16
  • testify 1.7.0

テストの書き方と結果出力

import

import (
    // ... 他の必要なパッケージ

    "github.com/stretchr/testify/assert"
)

引数の指定

(例) Equal 関数

assert.Equal(t, expected, got, description)
  • 1番目の引数は、testing.T
  • そのあと、テスト関数ごとに指定の引数を追加する
    • 2つの値を比較するとき、大抵は 期待される値 実際の値 の順だが、たまに逆の関数もある
    • 指定の順番を間違えると、テスト失敗時の出力で逆に表示されてしまう
  • 引数の末尾にテストの説明を任意で追加することができる。(なくてもよい)
type Member (
    Name string
    Grade int
)

func TestEqual(t *testing.T) {
    expected := &Member{Name: "Niko", Grade: 3}

    assert.Equal(t,
        expected,
        &Member{Name: "Niko", Grade: 2},
        "%s is 3rd grade", expected.Name)
}

結果表示

  • テストに失敗すると、以下のような内容が出力される
=== RUN   TestEqual
    assert_test.go:27:
                Error Trace:
                Error:          Not equal:
                                expected: &assertion_test.Member{Name:"Niko", Grade:2}
                                actual  : &assertion_test.Member{Name:"Niko", Grade:3}

                                Diff:
                                --- Expected
                                +++ Actual
                                @@ -2,3 +2,3 @@
                                  Name: (string) (len=4) "Niko",
                                - Grade: (int) 2
                                + Grade: (int) 3
                                 })
                Test:           TestEqual
                Messages:       Niko is 3rd grade
--- FAIL: TestEqual (0.00s)

関数

以下の関数で、カッコ内でプレフィクス Not または No がついているものは、元のテストの逆の結果になることのテスト関数。

等価性チェック

Equal(NotEqual)

Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
  • 実際値が期待値と一致していることをテストする
  • 値はスカラー値だけでなく、構造体のインスタンス等でも比較可能
    • 唯一、function 同士の比較はできない
type Member struct {
    Name  string
    Grade int
}

func TestEqual(t *testing.T) {
    num := 111
    str := "Honoka"
    arr := []string{"Kotori", "Umi", "Hanayo"}
    niko := &Member{
        Name:  "Niko",
        Grade: 3,
    }

    assert.Equal(t, 111, num)
    assert.NotEqual(t, 222, num)
    assert.Equal(t, "Honoka", str)
    assert.Equal(t, []string{"Kotori", "Umi", "Hanayo"}, arr)
    assert.Equal(t, &Member{Name: "Niko", Grade: 3}, niko, "%s is 3rd grade", niko.Name)
}

EqualValues(NotEqualValues)

EqualValues(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
  • 2つの値が同一かどうかをテストする
  • Equal との違いは、型が同一でなくても、型変換により同一とみなせる場合はテストが成功する点
func TestEqualValues(t *testing.T) {
    assert.EqualValues(t, uint(123), int(123))
}

Same(NotSame)

Same(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
  • 2つのポインタ値が同一オブジェクトを指していることをテストする
  • Same であるためには以下の両方の条件を満たす必要がある
    • ポインタが同一オブジェクトを指している(ポインタ値が同一)
    • 同一の型である
func TestSame(t *testing.T) {
    eri := &Member{
        Name:  "Eri",
        Grade: 3,
    }
    ptrEri := eri

    type Student *Member
    sEri := Student(eri)

    assert.Same(t, eri, ptrEri)
    assert.NotSame(t, &Member{Name: "Eri", Grade: 3}, ptrEri)
    // assert.Same(t, eri, sEri, "not same types") // fail
}
  • 一番下のテストは、ポインタは同一(0xc0000b61f8)だが、型が異なるため失敗する
    assert_test.go:42:
                Error Trace:
                Error:          Not same:
                                expected: 0xc0000b61f8 &assertion_test.Member{Name:"Eri", Grade:3}
                                actual  : 0xc0000b61f8 &assertion_test.Member{Name:"Eri", Grade:3}
                Test:           TestSame
                Messages:       not same types: *assertion_test.Member, assertion_test.Student
--- FAIL: TestSame (0.00s)

Exactly

  • 2つのオブジェクトが同一かどうかをテストする
  • TODO: すみません Equal との違いが不明です

特定の条件を満たす値かどうか

Nil(NotNil)

Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
  • オブジェクトが nil であるかをテストする

Empty(NotEmpty)

Empty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
  • オブジェクトの内容が空であることをテストする
  • 次項の「EmptyZero の違い」も参照

Zero(NotZero)

Zero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool
  • 値がその型のzero valueであることをテストする
EmptyZero の違い
  • Zero は、値そのものが zero value であることをチェックする
    • array, slice, map, channel, ポインタの場合
      • 値が nil かどうか
  • Empty は、その値が指し示すものが「空」とみなせるかどうかをチェックする
    • array, slice, map, channel の場合
      • 長さが 0 であるかどうか
    • ポインタの場合
      • それが指し占めす値が空であれば、空とみなされる
func TestEmptyZero(t *testing.T) {
    i := 0
    ip := &i

    assert.Empty(t, i)
    assert.Empty(t, ip)    // ip が指し示す i は zero value

    assert.Zero(t, i)
    assert.NotZero(t, ip)  // ip 自体は値を持つ

    var se := []string{}   // []string{}
    var sn []string        // []string(nil)

    assert.Empty(t, se)
    assert.Empty(t, sn)

    assert.NotZero(t, se)  //  空の slice を持つ
    assert.Zero(t, sn)     //  slice 自体がない
}

True/False

True(t TestingT, value bool, msgAndArgs ...interface{}) bool
  • オブジェクトの値が true または false と判定されるかどうかをテストする

Len

Len(t TestingT, object interface{}, length int, msgAndArgs ...interface{}) bool
  • オブジェクトの長さが期待通りであることをテストする
  • built-in 関数の len() を実行可能なオブジェクトに対してのみ適用できる
    • 適用できない場合はテストに失敗する
func TestLen(t *testing.T) {
    assert.Len(t, "Rin", 3)
}

Regexp(NotRegexp)

Regexp(t TestingT, rx interface{}, str interface{}, msgAndArgs ...interface{}) bool
  • 実際に得られた文字列が、指定の正規表現に合致することをテストする
  • 期待される正規表現(rx)は、*regexp.Regexp 型、もしくは string で表現された正規表現のどちらでもよい
func TestRegexp(t *testing.T) {
    str := "nikko nikko nii"
    assert.Regexp(t, regexp.MustCompile("^(nikko )+nii$"), str)
    assert.Regexp(t, "^(nikko )+nii$", str)
    assert.NotRegexp(t, "^(nikko ){3}nii$", str)
}

リスト

Contains/NotContains

Contains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool
  • オブジェクトが特定の値を含むことをテストする
  • 適用可能なのは、string、リスト(array, slice 等 len() の適用が可能なもの)、map に限られる
    • それ以外のオブジェクトに適用した場合はテストに失敗する
  • string の場合、部分文字列として含まれるかどうかをテストする
  • リストの場合、特定の要素がリスト内に存在するかどうかをテストする
  • map の場合、特定の key が map 内に含まれるかどうかをテストする
    • map の value は比較の対象外
func TestContains(t *testing.T) {
    assert.Contains(t, "Maki Nishikino", "Maki")
    assert.Contains(t, []string{"Maki", "Hanayo", "Rin"}, "Maki")
    assert.Contains(t, map[string]int{"Maki": 1, "Honoka": 2, "Niko": 3}, "Maki")
    // map の value は対象外
    assert.NotContains(t, map[string]int{"Maki": 1, "Honoka": 2, "Niko": 3}, 3)
}

Subset(NotSubset)

Subset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool)
  • リスト型のオブジェクトに対して、期待されたサブセットが含まれるかどうかをテストする
func TestSubset(t *testing.T) {
    members := []string{"Kotori", "Kayo", "Eri"}
    assert.Subset(t, members, []string{"Kotori", "Eri"})
    assert.NotSubset(t, members, []string{"Kotori", "Yukiho"})
}

ElementsMatch

ElementsMatch(t TestingT, listA, listB interface{}, msgAndArgs ...interface{}) (ok bool)
  • リスト型のオブジェクトに対して、すべての要素が含まれていることをテストする
  • 要素の順番は問わない。ただし個数は一致する必要がある
func TestElementsMatch(t *testing.T) {
    members := []string{"Kotori", "Kayo", "Eri", "Kayo"}
    assert.ElementsMatch(t, members, []string{"Eri", "Kayo", "Kayo", "Kotori"})
    //assert.ElementsMatch(t, members, []string{"Eri", "Kotori", "Kayo", "Kotori"})  //fail
}

誤差の判定

WithinDuration

WithinDuration(
    t TestingT,
    expected, actual time.Time,
    delta time.Duration,
    msgAndArgs ...interface{}) bool
  • time.Time で表される時刻と、期待する時刻との誤差が一定以下であることをテストする
func TestWithinDuration(t *testing.T) {
    now := time.Now()
    expected := now.Add(2 * time.Second)

    assert.WithinDuration(t, expected, now, time.Duration(5*time.Second))
    //assert.WithinDuration(t, expected, now, time.Duration(1*time.Second)) // fail
}

InDelta

InDelta(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool
  • 両者の誤差が一定値(delta)以下であることをテストする。
func TestInDelta(t *testing.T) {
    val := 10.0 / 2.998

    assert.InDelta(t, 10.0, val*2.99, 0.1)
}

InDeltaSlice

InDeltaSlice(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool
  • InDelta のスライス版
  • 2つのスライスに含まれる同一位置の数値同士を比較し、その誤差が一定値(delta)以下であることをテストする
  • テストされる側(actual)のすべての要素に対してテストが通った場合に、InDeltaSlice のテストも通る
func TestInDeltaSlice(t *testing.T) {
    expect := []float32{1.0, 2.0, 3.0}
    actual := []float32{1.03, 1.98, 3.01}

    assert.InDeltaSlice(t, expect, actual, 0.03)
}

InDeltaMapValues

InDeltaMapValues(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool
  • 比較対象は map 型
  • すべての key に対応する値を期待値、実際値で比較し、その誤差が範囲内に収まっていることをテストする
  • key が完全に一致しない場合は FAIL となる
func TestInDeltaMapValues(t *testing.T) {
    expected := map[string]float64{"width": 2.5, "height": 7.5}
    actual := map[string]float64{"width": 2.47, "height": 7.53}

    assert.InDeltaMapValues(t, expected, actual, 0.1)
}

InEpsilon

InEpsilon(t TestingT, expected, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool
  • 期待値対する実測値の誤差が一定割合以内に収まっていることをテストする
  • InDelta と似ているが、InEpsilon では誤差の「割合」をチェックする
func TestInEpsilon(t *testing.T) {
    assert.InEpsilon(t, 10000, 10090, 0.01)
    assert.InEpsilon(t, 100, 100.9, 0.01)
    // assert.InEpsilon(t, 1, 0.11, 0.01)  <-- fail
}

InEpsilonSlice

InEpsilonSlice(t TestingT, expected, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool
  • InEpsilon のスライス版

ファイル

FileExists(NoFileExists)

FileExists(t TestingT, path string, msgAndArgs ...interface{}) bool
  • 指定したファイルが存在することをテストする
  • 指定したパスがディレクトリである場合はテスト失敗となる
func TestFileExists(t *testing.T) {
    // exists.txt はすでに存在し、missing.txt はないものとする
    assert.FileExists(t, "./exists.txt")
    assert.NoFileExists(t, "./missing.txt")

    // existsdir は存在するディレクトリ
    assert.NoFileExists(t, "./existsdir")
}

DirExists(NoDirExists)

func DirExists(t TestingT, path string, msgAndArgs ...interface{}) bool {
  • 指定したディレクトリが存在することをテストする
  • 指定したパスがファイルであった場合はテストに失敗する
func TestDirExists(t *testing.T) {
    // existsdir は存在するディレクトリ、missingdir はないものとする
    assert.DirExists(t, "./existsdir")
    assert.NoDirExists(t, "./missingdir")

    // exists.txt はファイル
    assert.NoDirExists(t, "./exists.txt")
}

JSON, YAML

JSONEq

JSONEq(t TestingT, expected string, actual string, msgAndArgs ...interface{}) bool
  • 実際値のJSON文字列が、期待されるJSON文字列と内容的に同一であることをテストする
    • 文字列内での順番が異なってもかまわない
func TestJSONEq(t *testing.T) {
    expected := `{"name": "Nozomi", "age": 17, "blood": "O"}`
    actual := `{"age": 17, "blood": "O", "name": "Nozomi"}`

    assert.JSONEq(t, expected, actual)
}

YAMLEq

YAMLEq(t TestingT, expected string, actual string, msgAndArgs ...interface{}) bool
  • 実際値のYAML文字列が、期待されるYAML文字列と内容的に同一であることをテストする
    • 文字列内での順番が異なってもかまわない
func TestYAMLEq(t *testing.T) {
    expected := `---
name: Eri
age: 17
blood: B`
    actual := `---
blood: B
age: 17
name: Eri`

    assert.YAMLEq(t, expected, actual)
}

状態変化

Eventually

Eventually(
    t TestingT,
    condition func() bool,
    waitFor time.Duration,
    tick time.Duration,
    msgAndArgs ...interface{}) bool
  • 一定時間内(waitFor)に条件が満たされた状態になることをテストする
  • 条件が満たされているかをチェックするための関数(condition)を引数で渡す
    • 条件が満たされたら true、そうでなければ false を返す
    • この関数は tick で指定した時間おきに呼び出される
func TestEventually(t *testing.T) {
    var content []byte
    go func() {
        res, err := http.Get("https://example.com")
        if err != nil {
            return
        }
        defer res.Body.Close()
        content, _ = io.ReadAll(res.Body)
    }()

    // コンテンツが読み込まれたかをチェックする関数
    checkCompleted := func() bool {
        if len(content) > 0 {
            return true
        }
        return false
    }

    assert.Eventually(t, checkCompleted, time.Second*5, time.Second*1)
}

Never

Never(
    t TestingT,
    condition func() bool,
    waitFor time.Duration,
    tick time.Duration,
    msgAndArgs ...interface{}) bool
  • Eventually の逆。一定時間内に条件が満たされないことをテストする。

エラーおよび Panic

Error(NoError)

Error(t TestingT, err error, msgAndArgs ...interface{}) bool
  • error が存在する(nil でない)ことをテストする
func TestError(t *testing.T) {
    divideFunc := func(a, b int) (int, error) {
        if b == 0 {
            return 0, fmt.Errorf("division by zero")
        }
        return a / b, nil
    }

    _, err := divideFunc(5, 0)
    assert.Error(t, err)

    _, err = divideFunc(5, 2)
    assert.NoError(t, err)
}

EqualError

EqualError(t TestingT, theError error, errString string, msgAndArgs ...interface{}) bool
  • 得られたエラーが、期待される文字列表現と合致するかどうかをテストする
  • テスト対象(theError)のエラーの文字列表現が errString と完全一致していればよい
  • 他の assert 関数と引数の順番が異なるので注意
    • 実際に得られたエラー 期待されるエラー文字列 の順
var firstGrades map[string]string = map[string]string{
    "Hanayo": "Koizumi",
    "Rin":    "Hoshizora",
    "Maki":   "Nishikino",
}

type ErrorNotFirstGrade struct{}

func (e *ErrorNotFirstGrade) Error() string {
    return "not a first grade member"
}

func getLastName(firstName string) (string, error) {
    ln, ok := firstGrades[firstName]
    if !ok {
        return "", new(ErrorNotFirstGrade)
    }

    return ln, nil
}

func TestEqualError(t *testing.T) {
    _, err := getLastName("Honoka")
    assert.EqualError(t, err, "not a first grade member")
}

ErrorContains

ErrorContains(t TestingT, theError error, contains string, msgAndArgs ...interface{}) bool
  • 得られたエラーが、期待される文字列を含んでいるかどうかをテストする
  • テスト対象(theError)のエラーの文字列表現が errString を含んでいればよい
  • EqualError と同様、他の assert 関数と引数の順番が異なるので注意
    • 実際に得られたエラー 期待される部分エラー文字列 の順
func TestErrorContains(t *testing.T) {
    err := errors.New("Honoka and Hanayo were captured by Umi in front of a rice restaurant")
    assert.ErrorContains(t, err, "captured")
}

ErrorIs(NotErrorIs)

ErrorIs(t TestingT, err, target error, msgAndArgs ...interface{}) bool
  • 対象エラーのエラーチェーンの中に、特定のエラーが含まれていることをテストする
  • エラーチェーンの探索には errors.Is()(https://golang.org/pkg/errors/#Is) が使用される
func TestErrorIs(t *testing.T) {
    errA := errors.New("Umi cannot write lyrics")
    errB := fmt.Errorf("Umi has escaped: %w", errA)
    errC := fmt.Errorf("Umi not found: %w", errB)

    assert.ErrorIs(t, errC, errA)
}

ErrorAs

ErrorAs(t TestingT, err error, target interface{}, msgAndArgs ...interface{}) bool
  • 対象エラーのエラーチェーンの中に、target に代入可能なエラーが含まれていることをテストする
  • テストが通った場合、target にそのエラーが格納される
  • エラーチェーンの探索および target への代入は、errors.As()(https://golang.org/pkg/errors/#As) の挙動と同じ
type KotoriSlumpError struct{}

func (e *KotoriSlumpError) Error() string {
    return "Kotori cannot design dresses"
}

func TestErrorAs(t *testing.T) {
    errA := &KotoriSlumpError{}
    errB := fmt.Errorf("Kotori has escaped: %w", errA)
    errC := fmt.Errorf("Kotori not found: %w", errB)

    var err *KotoriSlumpError

    assert.ErrorAs(t, errC, &err)
    fmt.Println(err) // Kotori cannot design dresses
}

Panics(NotPanics)

Panics(t TestingT, f PanicTestFunc, msgAndArgs ...interface{}) bool
  • 指定された関数が panic を起こすことをテストする
  • 指定できる関数の型は func() (assert.PanicTestFunc 型)
  • recover() 実行時に得られる panic value を確認する PanicsWithValue PanicsWithError も提供されている
func TestPanics(t *testing.T) {
    setQuantity := func(qty int) func() {
        return func() {
            if qty <= 0 {
                panic(fmt.Errorf("qty cannot be negative"))
            }
        }
    }
    assert.PanicsWithError(t, "qty cannot be negative", setQuantity(-2))
    assert.NotPanics(t, setQuantity(5))
}

複雑な条件を使う

Condition

Condition(t TestingT, comp Comparison, msgAndArgs ...interface{}) bool
  • テストの成否を複雑な条件で判定させたいときに使う
  • 成否判定を行う func() bool 型の関数を引数で渡す
type Member struct {
    Name  string
    Grade int
}

func TestComparison(t *testing.T) {
    is3rdGrade := func(m Member) func() bool {
        return func() bool { return m.Grade == 3 }
    }

    assert.Condition(t, is3rdGrade(Member{"Niko", 3}))

    //assert.Condition(t, is3rdGrade(Member{"Honoka", 2}))  // fail
}

個人的見解

以上のように、assert には多くのテスト関数が用意されている。しかし実際には Equal() のみ使えば十分で、それ以外の関数は Equal() と go 標準の文法の組み合わせだけで十分表現可能なケースがほとんどと思われる。 むしろ Equal() 以外の様々なテスト関数を駆使「しすぎる」ことによって、テストコードの意図が汲み取りづらくなってしまう恐れがある。また testify/assert の学習コストを上げることにもなってしまう。特に複数メンバーの開発チーム内で使用する場合はこの点に注意すべきで、testify/assert の便利さと、テストコードの共通理解の得られやすさのバランスをうまくとるべきだと思う。 (使うとしても True() Nil() など、誰が見てもほぼ誤解の余地がないようなものにとどめるのが良さそう)

AgentGPTをGCP上で動かすときのメモ

AgentGPT とは

Webブラウザ上で動く自立型AIエージェント。課題を設定すると、その実現のためにタスクを自動的に設定し、それを一つ一つ実行していく。

AgentGPT実行例

ここでは「5000兆円を手に入れる方法を考える」というふざけたゴールを設定して実行してみたが、こんなものでも実現方法を真面目に考え、タスクに分解して解決方法を編み出してくれる。

なお、2023/04/16 時点では Beta 版。

準備するもの

最低限、OpenAI APIAPI key が必要。

Google Cloud Platform 上で動かす

VMインスタンスを立ち上げ、その上で Local 環境と同じように起動すればよい。 いくつか注意点をメモ。

  • マシンタイプが e2-micro だと、AgentGPT のインストールが完了できない。インストール中は最低でも e2-small かそれ以上にする。
    • top で様子を見る限り、メモリが 1GB では足りない模様。
    • AgentGPT 自体はそれほど内部リソースを必要としないので、インストールが完了したら e2-micro にダウングレードしてもよい。
  • nodejs のバージョンが古すぎないか注意
    • apt-get install nodejs でインストールしたバージョンは v12.22.12 だった。(いつの?)
      • ちなみにブートイメージはインスタンス作成時のデフォルトの Debian GNU/Linux 11 (bullseye) だった
    • nodejs.org 公式 から最新バージョンを wget して展開し、その配下の bin に PATH を通すと良い。
  • 必要に応じてファイアウォールの設定を行う
    • VPCネットワーク > ファイアウォール で、ファイアウォールルールを追加する。
    • ターゲットタグ に任意のタグ(例:agentgpt) を追加する。
    • ソースフィルタIP範囲 にした上で、許可する接続元の IP アドレスを追加
    • プロトコルとポートtcp:3000 を追加
    • VMインスタンス側の ネットワークタグ にも同じタグを追加しておく。
  • ./setup.sh --local で起動
    • 2回目からは npm run dev で良い

OpenWeather API で天気情報を取得する

天気情報を取得するAPIを提供しているサービスはいくつかある。 今回は OpenWeather という、世界中の天気情報を提供するサービスを利用する。

事前準備

OpenWeather アカウントを作成し、API key を取得する。

https://openweathermap.org/api にアクセス。使いたい機能を探して、Subscribe をクリック。 今回は現在の天気情報を取得する Current Weather Data を使う。

プラン表が表示される。今回は無料で使うので Free の下の Get API key をクリック

必要な項目を入力し、メール認証を済ませる

API key は画面右上の、自分の名前が表示されている部分をクリックし、プルダウンから My API keys を選択すると確認できる。

なお、登録してから API が利用可能になるまで少し時間がかかる。その間は API リクエストを送信しても 401 エラーが返ってくるので、気長に待つ。 自分の場合は20分ほどで利用可能になった。

動作確認

https://openweathermap.org/current#geoAPI にアクセスしてみる。 最低限 lat(緯度)、lon(経度)、appid(API key) の3つの必須パラメータを指定すればよい。 lang=ja を追加すると日本語にできる。

(以下の例では、環境変数 OPENWEATHER_API_KEY に、取得した API key を格納してある)

curl -s "https://api.openweathermap.org/data/2.5/weather?lat=35.7075&lon=139.6636&appid=$OPENWEATHER_API_KEY&lang=ja" | jq .
{
  "coord": {
    "lon": 139.6636,
    "lat": 35.7075
  },
  "weather": [
    {
      "id": 801,
      "main": "Clouds",
      "description": "薄い雲",
      "icon": "02n"
    }
  ],
  "base": "stations",
  "main": {
    "temp": 288.55,
    "feels_like": 287.69,
    "temp_min": 283.7,
    "temp_max": 290.66,
    "pressure": 1017,
    "humidity": 59,
    "sea_level": 1017,
    "grnd_level": 1012
  },
  "visibility": 10000,
  "wind": {
    "speed": 5.57,
    "deg": 224,
    "gust": 7.58
  },
  "clouds": {
    "all": 20
  },
  "dt": 1681141704,
  "sys": {
    "type": 2,
    "id": 2044139,
    "country": "JP",
    "sunrise": 1681157691,
    "sunset": 1681204194
  },
  "timezone": 32400,
  "id": 1865090,
  "name": "阿佐ヶ谷",
  "cod": 200
}

結果はデフォルトで json 形式で返ってくる。 (mode パラメータを指定することで XML, HTML 形式での取得も可能)

golangci-lint に独自の Linter を統合する

お久しぶりです

ちゃんと元気に生きています。 1ヶ月ほど禁酒していて体の調子がすごく良いです。その代わりアイスの消費量が増えました。毎日ハーゲンダッツとかガリガリ君とか食べてます。

独自Linterをgolangci-lintと統合する手順

まずこの記事でカバーする範囲について補足します。

概要

プロジェクトで Linter として golangci-lint を使用している場合、他の Linter と合わせてプロジェクト独自の Linter を追加し適用させたい、という機会があるかもしれない。 ここでは golangci-lint 用の独自Linter を書いたあと、それを golangci-lint 上で使用できるようにするための手順をまとめた。

環境

本記事は以下の環境で動作確認した。なお golangci-lint のソースインストールが必要なため Windows 環境では動作しない可能性がある(手元に環境がないため検証できなくてすみません)。

ハマりどころ

  • Linter プラグインが依存するパッケージのバージョンをすべて golangci-lint と同一にしなければならない。
    • golangci-lint 本体のバージョンが変わると、プラグイン側の依存パッケージのバージョンも要変更になる可能性がある

大まかな説明の流れ

  • golangci-lint インストール
  • 独自 Linter パッケージを作成
  • プラグインファイルを生成
  • Linter を適用するプロジェクトの作成
  • Linter を適用

ディレクトリ構成

ホームディレクトリ以下に work という作業用ディレクトリを作成し、それ以下で作業を進めた。本記事の手順を試すにはまず任意の場所に空の work ディレクトリ(名前は何でも良い)を作成してから始めれば良い。最終的には以下のようなディレクトリ構成になる。

  • example-plugin-linter: プラグインのコード
  • hello: Linter の適用対象となるサンプルプロジェクト
  • plugins: golangci-lint に組み込み可能なプラグインを配置する場所
work
├── example-plugin-linter
│   ├── README.md
│   ├── example.go
│   ├── go.mod
│   ├── go.sum
│   ├── lint_test.go
│   ├── plugin
│   │   └── example.go
│   ├── testdata
│   │   └── src
│   │       └── testlintdata
│   │           └── todo
│   │               └── todo.go
│   └── tools.go
├── hello
│   ├── .golangci.yml
│   ├── go.mod
│   └── main.go
└── plugins
    └── example.so

準備

PATH$GOPATH/bin を追加しておく。

$ export PATH=$PATH:$GOPATH/bin

golangci-lint インストール

今回は v1.42.0 を使用する。 カスタム Linter プラグインgolangci-lint に組み込むには、本体をソースからインストールする必要がある(といっても go install を使ってインストールするだけ)。バイナリインストールでは Plugin の組み込みができないようだ。 インストール方法の具体的な解説は Install | golangci-lint に掲載されているが、以下の通りに実行すれば良い。 なお、gcc を必要に応じてインストールする。

$ go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.42.0

$ golangci-lint --version
golangci-lint has version v1.42.0 built from (unknown, mod sum: "h1:hqf1zo6zY3GKGjjBk3ttdH22tGwF6ZRpk6j6xyJmE8I=") on (unknown)

Linter プラグインの作成

今回は golangci/example-plugin-linter: example linter that can be used as a plugin for github.com/golangci/golangci-lint のサンプル Linter を使用する。

ちなみにこの LInter は、author の指定がない TODO: コメントを検知するためのもの。以下のような挙動をする。

// TODO: author がないので NG
// TODO(): author がないので NG
// TODO(dareka): これは author を指定しているからOK
main.go:5:1: todo: TODO comment has no author (example)
// TODO: author がないので NG
^
main.go:6:1: todo: TODO comment has no author (example)
// TODO(): author がないので NG
^

というわけで、Linter の構築を進める。

$ git clone https://github.com/golangci/example-plugin-linter.git
$ cd example-plugin-linter

このサンプルを元に、プラグインファイル(*.so)を生成する。

生成するコマンド自体は簡単。

$ go build -buildmode=plugin -o (出力先) (AnalyzerPlugin が定義された .go ファイル)

ただハマりどころなのは、このプラグインが依存するパッケージのバージョンを、golangci-lint 本体のそれと完全に合わせておく必要があるというだ。少しでもバージョンが異なると Linter を適用する段階でエラーとなる。

例えば、Linter のプラグインを書く時に不可欠な go/analysisgolang.org/x/tools パッケージに含まれるが、golangci-lint が依存している golang.org/x/tools のバージョンは、本体のバージョンによって変わる。

(golangci-lint v.1.41.1 の場合)

$ go version -m /home/egawata/go/bin/golangci-lint | grep golang.org/x/tools
    dep golang.org/x/tools  v0.1.3    h1:L69ShwSZEyCsLKoAxDKeMvLDZkumEe8gXUZAjab0tX8=

(golangcl-lint v1.42.0 の場合)

$ go version -m /home/egawata/go/bin/golangci-lint | grep golang.org/x/tools
    dep golang.org/x/tools  v0.1.5    h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=

そして、プラグイン側も golang.org/x/tools のバージョンが上記のものに合うよう、go.mod を調整しておく必要がある。

とはいえ、 go version -m $GOPATH/bin/golangci-lint を実行してすべてのパッケージのバージョンを確認し、go.mod 側を1つずつ書き換えていくのはあまり現実的ではない。そこで以下の方法で、確実にバージョンを合わせていく方法を取る。

(なお、この手順の意味については 参考1 が分かりすい)

golangci-lint パッケージをブランクインポート

tools.go というファイルをプラグインプロジェクトのルートに作成し、以下の内容を記述する。これにより、このプラグインプロジェクトが golangci-lint に依存することを宣言できる。もちろん実際に参照している箇所はないのでブランクインポートで良い。 (tools.go というファイル名や tools というパッケージ名は何でも良いのだが、慣習的に tools を使用するらしい)

// +build tools

package tools

import (
    _ "github.com/golangci/golangci-lint/cmd/golangci-lint"
)

golangci-lint の依存パッケージのバージョンを go.mod に反映させる

いったん go.mod を捨てて作りなおす。 4行目の golangci-lint のバージョンには、インストールしたバージョンと同一のものを指定するところがポイント。 また go mod tidy と順番を逆にすると正しいバージョンのパッケージが入らないので注意。

$ rm go.*
$ go mod init example
$ go clean -modcache
$ go get -d github.com/golangci/golangci-lint/cmd/golangci-lint@v1.42.0
$ go mod tidy

プラグインファイルの出力

$ mkdir ../plugins     # なければ作る
$ go build -buildmode=plugin -o ../plugins/example.so plugins/example.go

-o に出力先を指定する。このパスはあとで設定に使用する。

Linter 適用対象のプロジェクトを作る

ここではサンプルプロジェクトとして hello というものを新規に作成する。もし既存のプロジェクトがあるならそれを利用してみても良い。

$ cd ~/work/
$ mkdir hello
$ cd hello
$ go mod init hello

この下に、チェック対象のソースコード(main.go)を以下のように作成する。 example Linter の検出対象となるよう、main() の前に問題のあるコメントを入れておく。

package main

import "fmt"

// TODO: implement here
func main() {
    fmt.Println("Hello")
}

Linter が example プラグインを使用するよう、 .golangci.yml に設定を追加する(なければ新規作成)

linters-settings:
  custom:
    example:
      path: ../plugins/example.so
      description: Check TODO without author
      original-url: github.com/golangci/example-linter

linters:
  enable:
    - example

linters-settings.custom 以下に Plugin 情報を設定する。pathプラグインの位置(絶対パス、もしくはhello プロジェクトからの相対パス)を設定する。 また linters.enable に、使用するプラグイン名を追加する。

Linter を適用

hello プロジェクトのルートディレクトリで golangci-lint 実行。正しく適用されていることが分かる。

$ golangci-lint run ./...
main.go:5:1: todo: TODO comment has no author (example)
// TODO: implement here
^

補足

ここでは、作成した独自 Linter を golangci-lintプラグインとして統合する方法を説明した。しかし今回の方法は、やや手順が煩雑だということのほかに懸念点が一つある。

それは golangci-lint をソースインストールする必要があるという点で、この方法はいくつか問題を抱えているようだ。

Install | golangci-lint

Note: such go get installation aren't guaranteed to work. We recommend using binary installation.

公式ドキュメントではその理由をいくつか挙げた上で、ソースインストールではなくバイナリインストールを推奨しているようだ。公式が推奨しない方法で利用を続けていくというのはいささか不安を拭えない。

独自 Linter を利用する方法がこれしかない、ということであれば素直に従う必要があるのだろうが、go vet から起動する など他のお手軽な方法もある。今回取り上げた方法はあくまで選択肢の一つとしてとらえるのが丁度良いのかもしれない。

参考サイト