いろいろ備忘録日記

主に .NET とか Go とか Flutter とか Python絡みのメモを公開しています。

Goメモ-466 (iotestパッケージのメモ)(03-TimeoutReader)

関連記事

Goメモ-464 (iotestパッケージのメモ)(01-TestReader) - いろいろ備忘録日記

Goメモ-465 (iotestパッケージのメモ)(02-ErrReader) - いろいろ備忘録日記

GitHub - devlights/blog-summary: ブログ「いろいろ備忘録日記」のまとめ

概要

以下、自分用のメモです。忘れないうちにメモメモ。。。

以下の書籍で知ったのですが、testing/iotest というパッケージの存在を知りました。こんなのあったんですね。

このパッケージの中には、io.Readerやio.Writerを受け取り処理する関数をテストする際に便利な関数なども用意されています。

今回は、iotest.TimeoutReader 関数について。

iotest.TimeoutReader 関数は、2回目のReadのときだけタイムアウトエラーを返す io.Reader です。通信処理のように複数回のReadが発生し、タイムアウトも考慮しなければならない処理のテストなどに使えます。個人的には、この iotest.TimeoutReader の存在を知れたのが一番嬉しい知識でした。これは便利。「2回目だけ」タイムアウトするって点も素晴らしい。

サンプル

random.go
package timeoutreader

import (
    "math/rand"
    "time"
)

const (
    charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
)

func randomString(buf []byte) {
    var (
        unixNano  = time.Now().UnixNano()
        rndSource = rand.NewSource(unixNano)
        rnd       = rand.New(rndSource)
    )

    for i := range buf {
        buf[i] = charset[rnd.Intn(len(charset))]
    }
}
read.go
package timeoutreader

import (
    "errors"
    "io"
    "time"
)

var (
    interval     = 10 * time.Millisecond
    ErrRetryOver = errors.New("retry over")
)

func readAllAtOnce(r io.Reader, p []byte) error {
    var (
        b   []byte
        err error
    )

    b, err = io.ReadAll(r)
    if err != nil {
        return err
    }

    copy(p, b)

    return nil
}

func readWithRetry(r io.Reader, p []byte, retries int) error {
    var (
        buf      = make([]byte, 1<<9)
        numRead  int
        offset   int
        count    int
        maxCount = retries + 1
        err      error
    )

    for count = 0; count < maxCount; {
        if len(p) <= offset {
            break
        }

        clear(buf)

        numRead, err = r.Read(buf)
        if err != nil {
            if errors.Is(err, io.EOF) {
                break
            }

            time.Sleep(interval)
            count++
            continue
        }

        copy(p[offset:offset+numRead], buf[:numRead])
        offset += numRead
    }

    if maxCount <= count {
        return ErrRetryOver
    }

    return nil
}
timeoutreader_test.go
package timeoutreader

import (
    "bytes"
    "io"
    "testing"
    "testing/iotest"
)

// TestReadAllAtOnce は、io.ReadAll()を利用してデータを読み取るテストケースです。
// テストで利用している io.Reader は、iotest.TimeoutReader()から生成しています。
//
// iotest.TimeoutReader() は、**2回目のみ**タイムアウトで失敗する
// io.Readerを作成してくれます。なので、通信処理などをテストする際にとても便利です。
// 2回目以降は普通にデータが読み取れるようになっています。
//
// このテストケースでは、読み取りに io.ReadAll() を利用しているため、2回目のエラーで
// 処理が返ってしまい、テストケースがFailとなります。io.ReadAll()には、リトライ処理などは
// 実装されていないため、タイムアウトが発生する可能性がある場面では利用しない方が良いということになります。
//
// # REFERENCES
//   - https://pkg.go.dev/testing/iotest@go1.23.0#TimeoutReader
func TestReadAllAtOnce(t *testing.T) {
    var (
        data   = make([]byte, 1<<10)
        buf    = make([]byte, len(data))
        reader io.Reader
        err    error
    )

    randomString(data)
    reader = iotest.TimeoutReader(bytes.NewReader(data))

    err = readAllAtOnce(reader, buf)
    if err != nil {
        t.Fatal(err)
    }

    if !bytes.Equal(data, buf) {
        t.Fatalf("[want] equal\t[got] not equal")
    }
}

// TestReadWithRetry は、リトライ処理を考慮した読み取り処理を利用してデータを読み取るテストケースです。
// テストで利用している io.Reader は、iotest.TimeoutReader()から生成しています。
//
// iotest.TimeoutReader() は、**2回目のみ**タイムアウトで失敗する
// io.Readerを作成してくれます。なので、通信処理などをテストする際にとても便利です。
// 2回目以降は普通にデータが読み取れるようになっています。
//
// このテストケースでは、読み取り中にタイムアウトを含むエラーが発生しても、指定回数リトライして
// 再試行するようになっているため、テストケースが通ります。
//
// # REFERENCES
//   - https://pkg.go.dev/testing/iotest@go1.23.0#TimeoutReader
func TestReadWithRetry(t *testing.T) {
    var (
        data   = make([]byte, 1<<10)
        buf    = make([]byte, len(data))
        reader io.Reader
        err    error
    )

    randomString(data)
    reader = iotest.TimeoutReader(bytes.NewReader(data))

    err = readWithRetry(reader, buf, 3)
    if err != nil {
        t.Fatal(err)
    }

    if !bytes.Equal(data, buf) {
        t.Fatalf("[want] equal\t[got] not equal")
    }
}

実行すると以下のようになります。

$ task
=== RUN   TestReadAllAtOnce
    timeoutreader_test.go:36: timeout
--- FAIL: TestReadAllAtOnce (0.00s)
=== RUN   TestReadWithRetry
--- PASS: TestReadWithRetry (0.01s)
FAIL
FAIL    github.com/devlights/try-golang/examples/singleapp/iotest/timeoutreader 0.013s
FAIL

io.ReadAll() を使っている方はエラーを検知してFailしていますが、リトライを考慮した方はSuccessしていますね。

参考情報

Goのおすすめ書籍


過去の記事については、以下のページからご参照下さい。

サンプルコードは、以下の場所で公開しています。