第1章:基礎編

runnとは何か

runn(「Run N」/rʌ́n én/)は、k1LoW氏が開発したシナリオベースのテスト・自動化ツールです。

特徴

マルチプロトコル対応

HTTP、gRPC、データベース、CDP(Chrome DevTools Protocol)、SSHを同一のYAML形式で記述できます。

# HTTPもgRPCもDBも、すべて同じ形式!
steps:
  - req: { /users: { get: {} } }           # HTTP
  - grpc: { getUser: { id: 1 } }          # gRPC
  - db: { query: "SELECT * FROM users" }   # Database

シングルバイナリ

単体で実行可能。ダウンロードしてすぐ使えます。

強力な式評価エンジン

前のステップの結果を次のステップで利用できます。

desc: ステップ間でデータを連携

runners:
  blog: http://localhost:8080

steps:
  login:
    blog:
      /auth:
        post:
          body:
            application/json:
              username: "alice"
              password: "secret"
  get_profile:
    blog:
      /profile:
        get:
          headers:
            # 前のステップで取得したトークンを使用
            Authorization: "Bearer {{ steps.login.res.body.token }}"

Go言語統合

go testにシームレスに統合可能です。

用途

  • APIのE2Eテスト
  • CI/CDでの自動テスト
  • 運用タスクの自動化
  • APIの動作確認

インストール

Homebrew

brew install k1LoW/tap/runn

Go install

go install github.com/k1LoW/runn/cmd/runn@latest

直接ダウンロード

GitHub Releasesから環境に合ったバイナリをダウンロード。

Docker

docker container run -it --rm --name runn -v $PWD:/books ghcr.io/k1low/runn:latest list /books/*.yml

確認

runn --version

基本的な使い方

シナリオ実行

runn run scenario.yml

複数ファイルの実行:

runn run scenarios/**/*.yml

はじめてのシナリオ作成

テスト環境準備

docker run -p 8080:8080 mccutchen/go-httpbin

基本的なGETリクエスト

examples/basics/first-scenario.yml:

desc: HTTPBinにGETリクエストを送信

runners:
  httpbin: http://localhost:8080

steps:
  - httpbin:
      /get:
        get:
          headers:
            User-Agent: runn/1.0
    test: |
      current.res.status == 200

実行:

runn run examples/basics/first-scenario.yml --verbose

結果:

1 scenario, 0 skipped, 0 failures

JSONレスポンスの検証

desc: JSONレスポンスの内容を検証
runners:
  httpbin: http://localhost:8080

steps:
  - httpbin:
      /json:
        get: {}
    test: |
      current.res.status == 200 &&
      current.res.body.slideshow.title == "Sample Slide Show"

変数の使用

desc: 変数を使用したPOSTリクエスト

runners:
  httpbin: http://localhost:8080

vars:
  username: testuser
  email: test@example.com

steps:
  - httpbin:
      /post:
        post:
          body:
            application/json:
              name: "{{ vars.username }}"
              email: "{{ vars.email }}"
    test: |
      current.res.status == 200 &&
      current.res.body.json.name == vars.username

ステップ間の連携

desc: ログインしてからデータを取得

runners:
  httpbin: http://localhost:8080

steps:
  # ステップ1: ログイン(シミュレーション)
  login:
    httpbin:
      /post:
        post:
          body:
            application/json:
              username: alice
              password: secret123
    test: current.res.status == 200

  # ステップ2: 認証が必要なエンドポイントにアクセス
  get_data:
    httpbin:
      /bearer:
        get:
          headers:
            # 前のステップの結果を使用(実際のAPIではトークンが返される想定)
            Authorization: "Bearer dummy-token-{{ steps.login.res.body.json.username }}"
    test: |
      current.res.status == 200

steps.login.res.body.json.usernameで前のステップの結果を参照できます。

CLIツール vs Goテストヘルパー

CLIツールとして

適した用途: - 手動でのAPI動作確認 - CI/CDパイプラインでの自動テスト - 外部APIの監視 - デバッグ作業

Goテストヘルパーとして

適した用途: - Goアプリケーションのテスト統合 - テストDBのセットアップ/クリーンアップ - モックサーバーとの連携 - 複雑なテストデータの準備

Goテストヘルパーの実装例

main_test.go:

package main

import (
    "context"
    "database/sql"
    "net/http/httptest"
    "testing"

    "github.com/k1LoW/runn"
)

func setupTestDB(t *testing.T) *sql.DB {
    db, err := setupDB()
    if err != nil {
        t.Fatal(err)
    }
    return db
}

func TestUserAPI(t *testing.T) {
    // テスト用サーバーを起動
    db := setupTestDB(t)
    defer func() {
        err := db.Close()
        if err != nil {
            t.Fatal(err)
        }
    }()

    srv := httptest.NewServer(NewApp(db))
    defer srv.Close()

    // runnでテストを実行
    opts := []runn.Option{
        runn.T(t),
        runn.Runner("blog", srv.URL),
        runn.Scopes("read:parent"),
    }

    o, err := runn.Load("../user-api-test.yml", opts...)
    if err != nil {
        t.Fatal(err)
    }

    if err := o.RunN(context.Background()); err != nil {
        t.Fatal(err)
    }
}

テスト対象のAPIサーバー(main.go):

package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    _ "github.com/mattn/go-sqlite3"
)

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type App struct {
    db *sql.DB
}

func NewApp(db *sql.DB) http.Handler {
    app := &App{db: db}
    mux := http.NewServeMux()

    // ユーザー作成
    mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
        if r.Method == http.MethodPost {
            var user User
            if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
                http.Error(w, err.Error(), http.StatusBadRequest)
                return
            }

            result, err := app.db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", user.Name, user.Email)
            if err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
            }

            id, _ := result.LastInsertId()
            user.ID = int(id)

            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusCreated)
            json.NewEncoder(w).Encode(user)
        }
    })

    // ユーザー取得
    mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
        if r.Method == http.MethodGet {
            id := r.URL.Path[len("/users/"):]

            var user User
            err := app.db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id).Scan(&user.ID, &user.Name, &user.Email)
            if err != nil {
                http.Error(w, "User not found", http.StatusNotFound)
                return
            }

            w.Header().Set("Content-Type", "application/json")
            json.NewEncoder(w).Encode(user)
        }
    })

    return mux
}

func setupDB() (*sql.DB, error) {
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        return nil, err
    }

    _, err = db.Exec(`
        CREATE TABLE users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            email TEXT NOT NULL
        )
    `)
    if err != nil {
        return nil, err
    }

    return db, nil
}

func main() {
    db, err := setupDB()
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    app := NewApp(db)

    fmt.Println("Server starting on :8081...")
    log.Fatal(http.ListenAndServe(":8081", app))
}

テストシナリオ(user-api-test.yml):

desc: ユーザーAPIのテスト

runners:
  blog: http://localhost:8080

steps:
  # ユーザーを作成
  create_user:
    blog:
      /users:
        post:
          body:
            application/json:
              name: "テスト太郎"
              email: "test@example.com"
    test: |
      current.res.status == 201 &&
      current.res.body.name == "テスト太郎" &&
      current.res.body.email == "test@example.com" &&
      current.res.body.id > 0

  # 作成したユーザーを取得
  get_user:
    blog:
      /users/{{ steps.create_user.res.body.id }}:
        get: {}
    test: |
      current.res.status == 200 &&
      current.res.body.id == steps.create_user.res.body.id &&
      current.res.body.name == "テスト太郎" &&
      current.res.body.email == "test@example.com"

  # 存在しないユーザーを取得(404エラー確認)
  get_nonexistent_user:
    blog:
      /users/9999:
        get: {}
    test: |
      current.res.status == 404

このように、SQLiteを使った本格的なREST APIのテストが、わずかなコードで実現できます。