水底

ScalaとかC#とかk8sとか

runn と GitHub Actions でお手軽自動 API テストを導入してみた 😎

この記事は CADDi プロダクトチーム Advent Calendar 2024 8 日目の記事です。

はじめに

最近は CADDi で検索と戯れている伊藤 (@amaya382) です。 いろいろと豪勢な記事が並んでいるので今日はちょっとした小ネタを、少し前に導入して便利だったテストツールセットをゆる〜〜く紹介しようと思います。この記事を読むことで、最小限の労力で API テストがバチっとできる道筋が見えるかもしれません。

最近開発している検索用のバックエンドサービスではユニットテストとシナリオテストを中心に品質を担保することができていました。しかしながら、シナリオテストに頼る箇所が多く、実施頻度が限られるシナリオテストでは品質は保証できても手戻りリスクが大きい状態でした。特に実際のデータに依存するような検査項目やミドルウェア・データベースによる影響がユニットテストで捉えきることができず、悩みのタネでした。

この課題を解消するには「早く気づくための自動化が容易であること」と「テストにリアルなデータを利用すること」という点に着目し、API テストの模索を始めました。その他にも OpenAPI を Schema-first に運用していたためこのあたりの検証もまとめてできないかなーと検討した結果行き着いたのが今回紹介する runn です。

runn とは

既に解説記事がたくさん出始めていますが、2 年前に公開が始まったばかりの API テストツールです。YAML で HTTP、gRPC の呼び出しやさらには DB へのアクセスも簡単に取り回せるのが特徴です。ドキュメントやチュートリアルが充実しているのもチームで活用していくにあたって嬉しいポイントですね。ちなみに作者が日本の方のため、比較的日本語のドキュメントが整備されています。

正直機能紹介はこれらを見れば十分なため、この記事では実運用して便利だったポイントを中心に紹介していきます。

実際にローカル使ってみた

今回の環境

実例を紹介する前に、今回適用した環境を簡単に紹介します。主なコンポーネントREST API サーバーと Elasticsearch です。API サーバーのインターフェースは OpenAPI で管理されています。開発の依存を減らすために Schema-first な戦略を取っているため、API テストの中で「OpenAPI 定義に従っているか?」もチェックしたいところです。

API テストを書いてみる

さてここからは runn で実装したテストを紹介していきます。大まかな流れは以下のとおりです:

  • refreshTestData: Elasticsearch 上のデータのセットアップを行う。
  • searchArticlesBeforeCreation: API server で記事の検索を行う。今の段階では何もヒットしない。
  • createArticle: 記事を作成する。
  • verifyArticleOnElasticsearch: 作成された記事が Elasticsearch にあることを確認する。
  • searchArticlesAfterCreation: API server で記事の検索を行う。直前に作成した記事がヒットする。
desc: test my API server
runners:
  myApi:
    endpoint: ${MY_API_URL:-http://localhost:8080}
    openapi3: path/to/openapi.yaml
    skipValidateRequest: false
    skipValidateResponse: false
  elasticsearch:
    endpoint: ${ELASTICSEARCH_URL:-http://localhost:9200}
vars:
  articleId: article-01
steps:
  refreshTestData:
    include:
      path: ../.common/refresh-test-data.yaml
  searchArticlesBeforeCreation:
    myApi:
      "/v1/articles:search?q=nyan":
        get: {}
    test: current.res.status == 404
  createArticle:
    myApi:
      "/v1/articles":
        post:
          body:
            application/json:
              title: new title
              body: nyan
    test: current.res.status == 201
  verifyArticleOnElasticsearch:
    elasticsearch:
      "/articles/_doc/{{ vars.articleId }}":
        get: {}
    test:
      current.res.status == 200 &&
      current.res.body._source.title == 'new title'
  searchArticlesAfterCreation:
    myApi:
      "/v1/articles:search?q=nyan":
        get: {}
    test:
      current.res.status == 200 &&
      current.res.body.hits == 1 &&
      current.res.body.results[0].title == 'new title'

上から順に runn のどんな機能を使ってどんな検証がなされているか見ていきましょう。

runners

いきなり runn の便利さが詰まっている部分です。ここに HTTP クライアントを宣言することで、後の実行ステップで簡単に利用できるようになります。今回は REST の API サーバーと REST をメインのインターフェースとして Elasticsearch それぞれにクライアントを定義しています。注目してほしいのが myApi の定義にある openapi3 の指定です。ここで OpenAPI 定義ファイルを指定することによって、以降このクライアントを利用したときに自動的にリクエストとレスポンスが定義に従っているかを検証してくれます。わずか 1、2 行のことですが個人的に最も感動したポイントです。endpoint環境変数で外部から切り替えられるようにしています。

今回は利用していませんが、HTTP 以外にも gRPC や RDB クライアントも利用できます。YAML 上の最低限の記述だけでこれらのリクエストも扱えるのは他のツールにはあまり見られない特徴ですね。

runners:
  myApi:
    endpoint: ${MY_API_URL:-http://localhost:8080}
    openapi3: path/to/openapi.yaml
    skipValidateRequest: false
    skipValidateResponse: false
  elasticsearch:
    endpoint: ${ELASTICSEARCH_URL:-http://localhost:9200}

vars

テスト内で使う変数を定義できます。後ほど出てきますが、外部の JSON ファイルを読み込むこともできます。

vars:
  articleId: article-01

steps

ここにはテストの各ステップが記述されます。

steps:
  refreshTestData:
  ...

steps.refreshTestData

テストデータを Elasticsearch 上に準備するためのステップです。include 機能を使っており、これは外部の runn テストケースを呼び出すことになります。テスト環境のセットアップのような、複数のテストで共通化したい処理をまとめておくことができます。

refreshTestData:
  include:
    path: ../.common/refresh-test-data.yaml

通化されたテストケースも簡単に見ていきましょう。Elasticsearch は JSON 形式でインデックス定義 (RDB のテーブル定義のようなもの) を行います。ここでは indexSetting.json という外部の JSON にその定義をおいておき、テスト実行時に読み込んでリクエストボディとして利用しています。サービスからとテストケースからで共通の定義を利用できるのは良いですね。

insertTestData ではデータ投入のためにシェルスクリプトを実行しています。YAML から Elasticsearch へのアクセスを記述していってもよいのですが、ある程度の処理であれば外部のスクリプトにまとめると便利なことが多いです。test でこれらの処理の検証もしていますが次で紹介します。

.common/refresh-test-data.yaml

desc: refresh test data
runners:
  elasticsearch:
    endpoint: ${ELASTICSEARCH_URL:-http://localhost:9200}
vars:
  indexSetting: "json://indexSetting.json"
steps:
  createIndex:
    elasticsearch:
      "/article/":
        put:
          body:
            application/json: "{{ vars.request }}"
    test: current.res.status == 201
  insertTestData:
    exec:
      command: ./insert_data.sh ${ELASTICSEARCH_URL:-http://localhost:9200}
    test: current.exit_code == 0

steps.searchArticlesBeforeCreation

さて元のテストケースに戻りまして、今度は API サーバーに対して GET で検索リクエストを投げています。ただしこの時点でのデータではこの条件には何もヒットしないという想定です。runn では test を書いて検証できます。ここでは何も見つからないことを HTTP ステータスが 404 として検証しています。

searchArticlesBeforeCreation:
  myApi:
    "/v1/articles:search?q=nyan":
      get: {}
  test: current.res.status == 404

steps.createArticle

API サーバーに対して POST リクエストで新しい記事を作成しています。

createArticle:
  myApi:
    "/v1/articles":
      post:
        body:
          application/json:
            title: new title
            body: nyan
  test: current.res.status == 201

steps.verifyArticleOnElasticsearch

API サーバー経由のやり取りだけでなく、データベースへ直接アクセスして内部のデータを検証するステップも入れることができます。ここでは直前に作られた記事が API サーバーから Elasticsearch に期待通り永続化されているかを確かめるために、Elasticsearch に直接取得を試みます。Elasticsearch 用の HTTP クライアントを利用して GET した中身をチェックしています。

RDB を利用している場合は代わりに SQL を書いて検証することができます。

verifyArticleOnElasticsearch:
  elasticsearch:
    "/articles/_doc/{{ vars.articleId }}":
      get: {}
  test:
    current.res.status == 200 &&
    current.res.body._source.title == 'new title'

steps.searchArticlesAfterCreation

再度先ほどと同じ条件で API サーバーに対して GET の検索リクエストを投げています。直前に作成した記事がヒットするという想定で、HTTP ステータスに加えてヒットした記事の title を検証しています。

searchArticlesAfterCreation:
  myApi:
    "/v1/articles:search?q=nyan":
      get: {}
  test:
    current.res.status == 200 &&
    current.res.body.hits == 1 &&
    current.res.body.results[0].title == 'new title'

ここまでのまとめ

あとは runn をインストールして Elasticsearch と API サーバーを動かしておけばすぐにテストを実行できます。runn は golang 製なので バイナリ を拾ってくるだけでよいですし、各種パッケージマネージャー からも簡単にインストールできます。

さてこれで開発中に動かすのは余裕ですね。なんですが、チーム開発で確実に API テストを実施するにはやはりこれを CI に組み込んで自動化しなければなりません。というわけで次は GitHub Actions を CI として利用するまでを紹介します。

GitHub Actions 上で runn による API テストを実行する

全体としては、GitHub Actions 上で docker compose を使って Elasticsearch と API サーバーを立ち上げ、あとのステップで runn を実行するという形を取りました。

GitHub Actions

基本的には以下のように docker compose でサービスを立ち上げて次のステップで runn 用のアクションを指定しているだけです。開発環境で既に docker compose を利用していたことやパラメーターに制約があったことから GitHub Actions サービスコンテナではなくステップ内で docker compose を呼び出しています。

.github/workflows/test.yaml

name: Tests on Pull Request
on: pull_request

jobs:
  api-test:
    runs-on: ubuntu-latest
    steps:
      # (snip. build and push docker image)
      - name: Run services for API testing
        run: |
          IMAGE="my-api:${{ github.sha }}" docker compose up -d --wait
      - name: Run API tests
        uses: k2tzumi/runn-action@v0.120.0
        with:
          path_pattern: api-server/runn/**/*.yaml
          enable-run-exec-scope: true
          verbose: true

docker compose

何の変哲もない docker compose ファイルです。強いて言えば立ち上がりの遅い Elasticsearch を待つために healthcheck を入れていることくらいでしょうか。docker compose up -d するときに --wait をつけることでサービスが healthy になるのを待ってくれます。

compose.yaml

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.16.1
    # (snip.)
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "curl -s http://localhost:9200/_cluster/health?pretty | grep status | grep -q -E '(green|yellow)'",
        ]
      interval: 3s
      timeout: 3s
      retries: 20
  myApi:
    image: ${IMAGE}
    # (snip.)
    depends_on:
      elasticsearch:
        condition: service_healthy
    healthcheck:
      test:
        [
          "CMD-SHELL",
          'node -e ''require("http").get("http://localhost:80/health", (r) => process.exit(r.statusCode === 200 ? 0 : 1));''',
        ]
      interval: 3s
      timeout: 3s
      retries: 20

さいごに

API テストを runn で実現しつつ GitHub Actions 上で動かしてみました。手軽に API テストケースを追加できるようになり、PullRequest の段階でユニットテストと同じくらい簡単に問題に気づけるようにもなりました。ヤッタネ!

どうやらいろんなポジションで人を探しているようです。気になる人は Twitter の DM でもなんでも気軽にどうぞ!(お久しぶりでもはじめましてでも)